From 97bf3cc50ab96553c97410b0d218c49fd0216bf6 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Mon, 28 Sep 2026 23:15:04 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20React=20(=D1=8D=D1=82=D0=B0=D0=BF=D1=8B=201-5?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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 --- AGENTS.md | 3 +- docs/FRONTEND.md | 280 +- docs/INFRASTRUCTURE.md | 5 +- docs/REACT_MIGRATION.md | 675 ++++ docs/REACT_MIGRATION_RESULT.md | 101 + docs/README.md | 2 +- frontend/Dockerfile | 13 +- frontend/admin/css/auditorium-workload.css | 16 + frontend/admin/css/components.css | 248 ++ frontend/admin/css/layout.css | 14 + frontend/admin/index.html | 238 +- frontend/admin/js/async-combobox.js | 193 -- frontend/admin/js/main.js | 331 -- frontend/admin/js/pagination.js | 34 - frontend/admin/js/views/academic-calendar.js | 1881 ----------- .../admin/js/views/auditorium-workload.js | 1161 ------- frontend/admin/js/views/classrooms.js | 314 -- frontend/admin/js/views/dashboard.js | 376 --- .../admin/js/views/department-workspace.js | 587 ---- frontend/admin/js/views/groups.js | 990 ------ .../admin/js/views/schedule-override-panel.js | 634 ---- frontend/admin/js/views/schedule-quality.js | 406 --- frontend/admin/js/views/schedule-versions.js | 462 --- frontend/admin/js/views/schedule-view.js | 1525 --------- frontend/admin/js/views/schedule.js | 2562 --------------- frontend/admin/js/views/subjects.js | 356 --- frontend/admin/js/views/teacher-absences.js | 571 ---- frontend/admin/js/views/teacher-requests.js | 205 -- .../admin/js/views/university-structure.js | 563 ---- frontend/admin/js/views/users.js | 191 -- frontend/admin/settings/css/layout.css | 10 + frontend/admin/settings/css/main.css | 40 + frontend/admin/settings/index.html | 90 +- frontend/admin/settings/js/main.js | 133 - frontend/admin/settings/js/views/database.js | 163 - frontend/admin/settings/js/views/edu-forms.js | 72 - .../admin/settings/js/views/time-slots.js | 323 -- frontend/admin/settings/views/database.html | 73 - frontend/admin/settings/views/edu-forms.html | 34 - frontend/admin/settings/views/general.html | 11 - frontend/admin/settings/views/time-slots.html | 134 - frontend/admin/views/academic-calendar.html | 316 -- frontend/admin/views/auditorium-workload.html | 89 - frontend/admin/views/classrooms.html | 155 - frontend/admin/views/dashboard.html | 145 - .../admin/views/department-workspace.html | 161 - frontend/admin/views/groups.html | 282 -- frontend/admin/views/schedule-quality.html | 102 - frontend/admin/views/schedule-versions.html | 89 - frontend/admin/views/schedule-view.html | 233 -- frontend/admin/views/schedule.html | 313 -- frontend/admin/views/subjects.html | 116 - frontend/admin/views/teacher-absences.html | 146 - frontend/admin/views/teacher-requests.html | 44 - .../admin/views/university-structure.html | 268 -- frontend/admin/views/users.html | 95 - frontend/auth-session.js | 4 + frontend/index.html | 72 +- frontend/package-lock.json | 31 + frontend/package.json | 12 +- frontend/react-cache.conf | 15 + frontend/react/admin/AdminApp.jsx | 458 +++ .../react/admin/academic-calendar-service.js | 641 ++++ frontend/react/admin/admin-service.js | 131 + .../admin/auditorium-workload-service.js | 681 ++++ frontend/react/admin/classrooms-service.js | 87 + frontend/react/admin/dashboard-service.js | 232 ++ .../admin/department-workspace-service.js | 262 ++ frontend/react/admin/groups-service.js | 354 ++ frontend/react/admin/main.jsx | 22 + frontend/react/admin/react-tabs.js | 25 + .../react/admin/schedule-override-service.js | 333 ++ .../react/admin/schedule-quality-service.js | 95 + frontend/react/admin/schedule-service.js | 859 +++++ .../react/admin/schedule-versions-service.js | 124 + frontend/react/admin/schedule-view-service.js | 767 +++++ frontend/react/admin/subjects-service.js | 153 + .../react/admin/tabs/AcademicCalendarTab.jsx | 2244 +++++++++++++ .../admin/tabs/AuditoriumWorkloadTab.jsx | 775 +++++ frontend/react/admin/tabs/ClassroomsTab.jsx | 632 ++++ frontend/react/admin/tabs/DashboardTab.jsx | 354 ++ .../admin/tabs/DepartmentWorkspaceTab.jsx | 774 +++++ frontend/react/admin/tabs/GroupsTab.jsx | 1279 ++++++++ .../react/admin/tabs/ScheduleQualityTab.jsx | 468 +++ frontend/react/admin/tabs/ScheduleTab.jsx | 2368 ++++++++++++++ .../react/admin/tabs/ScheduleVersionsTab.jsx | 622 ++++ frontend/react/admin/tabs/ScheduleViewTab.jsx | 1771 ++++++++++ frontend/react/admin/tabs/SubjectsTab.jsx | 535 ++++ .../react/admin/tabs/TeacherAbsencesTab.jsx | 940 ++++++ .../react/admin/tabs/TeacherRequestsTab.jsx | 372 +++ .../admin/tabs/UniversityStructureTab.jsx | 1094 +++++++ frontend/react/admin/tabs/UsersTab.jsx | 399 +++ .../react/admin/teacher-absences-service.js | 236 ++ .../react/admin/teacher-requests-service.js | 118 + frontend/react/admin/testing/render-admin.jsx | 83 + .../admin/university-structure-service.js | 150 + frontend/react/admin/users-service.js | 112 + frontend/react/login/LoginApp.jsx | 212 ++ frontend/react/login/login-service.js | 40 + frontend/react/login/main.jsx | 19 + frontend/react/settings/DatabaseTab.jsx | 318 ++ frontend/react/settings/EduFormsTab.jsx | 149 + frontend/react/settings/GeneralTab.jsx | 15 + frontend/react/settings/SettingsApp.jsx | 209 ++ frontend/react/settings/TimeSlotsTab.jsx | 466 +++ frontend/react/settings/main.jsx | 22 + frontend/react/settings/settings-service.js | 213 ++ .../settings/testing/render-settings.jsx | 28 + frontend/react/shared/api-client.js | 99 + frontend/react/shared/auth-client.js | 28 + frontend/react/shared/dates.js | 53 + frontend/react/shared/runtime-entry.jsx | 19 + frontend/react/shared/session-policy.js | 38 + .../react/shared/testing/render-shared.jsx | 22 + frontend/react/shared/ui/AppErrorBoundary.jsx | 28 + frontend/react/shared/ui/AsyncCombobox.jsx | 175 + frontend/react/shared/ui/AsyncState.jsx | 38 + frontend/react/shared/ui/ConfirmDialog.jsx | 186 ++ frontend/react/shared/ui/FormAlert.jsx | 10 + frontend/react/shared/ui/Pagination.jsx | 57 + frontend/react/shared/ui/ScheduleOverview.jsx | 93 + .../react/shared/use-authorized-session.js | 35 + frontend/react/student/StudentApp.jsx | 326 ++ frontend/react/student/WeekGrid.jsx | 58 + frontend/react/student/main.jsx | 22 + frontend/react/student/student-service.js | 73 + .../react/student/testing/render-student.jsx | 23 + frontend/react/teacher/AbsencePanel.jsx | 158 + frontend/react/teacher/PreferencesTab.jsx | 350 ++ frontend/react/teacher/RequestDialog.jsx | 177 + frontend/react/teacher/RequestsTab.jsx | 80 + frontend/react/teacher/TeacherApp.jsx | 363 +++ frontend/react/teacher/TeacherWeekGrid.jsx | 156 + frontend/react/teacher/main.jsx | 22 + frontend/react/teacher/teacher-service.js | 292 ++ .../react/teacher/testing/render-teacher.jsx | 38 + frontend/schedule-overview.js | 12 +- frontend/script.js | 134 - frontend/scripts/build-react-tests.mjs | 83 + frontend/scripts/build-react.mjs | 173 + frontend/security.conf | 3 +- frontend/student/app.js | 285 -- frontend/student/index.html | 43 +- frontend/style.css | 8 + frontend/teacher/app.js | 853 ----- frontend/teacher/index.html | 203 +- .../tests/academic-calendar-tooltip.test.mjs | 2 +- frontend/tests/academic-year-builder.test.mjs | 8 +- frontend/tests/admin-ui-regressions.test.mjs | 112 +- frontend/tests/date-input.test.mjs | 8 +- frontend/tests/react-admin-tabs.test.mjs | 2844 +++++++++++++++++ frontend/tests/react-admin.test.mjs | 213 ++ frontend/tests/react-login.test.mjs | 56 + frontend/tests/react-settings.test.mjs | 257 ++ frontend/tests/react-shared.test.mjs | 96 + frontend/tests/react-student.test.mjs | 191 ++ frontend/tests/react-teacher.test.mjs | 440 +++ frontend/tests/schedule-overrides.test.mjs | 2 +- frontend/tests/schedule-quality.test.mjs | 27 +- frontend/tests/schedule-versions.test.mjs | 58 +- .../tests/schedule-view-semesters.test.mjs | 2 +- frontend/tests/schedule-visual-menu.test.mjs | 2 +- frontend/tests/security-policy.test.mjs | 54 +- frontend/tests/teacher-absences.test.mjs | 12 +- frontend/tests/teacher-preferences.test.mjs | 51 +- frontend/tests/ui-consistency.test.mjs | 50 +- frontend/ui-foundation.css | 66 + 167 files changed, 31283 insertions(+), 19033 deletions(-) create mode 100644 docs/REACT_MIGRATION.md create mode 100644 docs/REACT_MIGRATION_RESULT.md delete mode 100644 frontend/admin/js/async-combobox.js delete mode 100755 frontend/admin/js/main.js delete mode 100644 frontend/admin/js/pagination.js delete mode 100644 frontend/admin/js/views/academic-calendar.js delete mode 100644 frontend/admin/js/views/auditorium-workload.js delete mode 100755 frontend/admin/js/views/classrooms.js delete mode 100644 frontend/admin/js/views/dashboard.js delete mode 100644 frontend/admin/js/views/department-workspace.js delete mode 100644 frontend/admin/js/views/groups.js delete mode 100644 frontend/admin/js/views/schedule-override-panel.js delete mode 100644 frontend/admin/js/views/schedule-quality.js delete mode 100644 frontend/admin/js/views/schedule-versions.js delete mode 100644 frontend/admin/js/views/schedule-view.js delete mode 100644 frontend/admin/js/views/schedule.js delete mode 100755 frontend/admin/js/views/subjects.js delete mode 100644 frontend/admin/js/views/teacher-absences.js delete mode 100644 frontend/admin/js/views/teacher-requests.js delete mode 100644 frontend/admin/js/views/university-structure.js delete mode 100644 frontend/admin/js/views/users.js delete mode 100644 frontend/admin/settings/js/main.js delete mode 100644 frontend/admin/settings/js/views/database.js delete mode 100644 frontend/admin/settings/js/views/edu-forms.js delete mode 100644 frontend/admin/settings/js/views/time-slots.js delete mode 100644 frontend/admin/settings/views/database.html delete mode 100644 frontend/admin/settings/views/edu-forms.html delete mode 100644 frontend/admin/settings/views/general.html delete mode 100644 frontend/admin/settings/views/time-slots.html delete mode 100644 frontend/admin/views/academic-calendar.html delete mode 100644 frontend/admin/views/auditorium-workload.html delete mode 100755 frontend/admin/views/classrooms.html delete mode 100644 frontend/admin/views/dashboard.html delete mode 100644 frontend/admin/views/department-workspace.html delete mode 100755 frontend/admin/views/groups.html delete mode 100644 frontend/admin/views/schedule-quality.html delete mode 100644 frontend/admin/views/schedule-versions.html delete mode 100644 frontend/admin/views/schedule-view.html delete mode 100644 frontend/admin/views/schedule.html delete mode 100755 frontend/admin/views/subjects.html delete mode 100644 frontend/admin/views/teacher-absences.html delete mode 100644 frontend/admin/views/teacher-requests.html delete mode 100644 frontend/admin/views/university-structure.html delete mode 100755 frontend/admin/views/users.html create mode 100644 frontend/react-cache.conf create mode 100644 frontend/react/admin/AdminApp.jsx create mode 100644 frontend/react/admin/academic-calendar-service.js create mode 100644 frontend/react/admin/admin-service.js create mode 100644 frontend/react/admin/auditorium-workload-service.js create mode 100644 frontend/react/admin/classrooms-service.js create mode 100644 frontend/react/admin/dashboard-service.js create mode 100644 frontend/react/admin/department-workspace-service.js create mode 100644 frontend/react/admin/groups-service.js create mode 100644 frontend/react/admin/main.jsx create mode 100644 frontend/react/admin/react-tabs.js create mode 100644 frontend/react/admin/schedule-override-service.js create mode 100644 frontend/react/admin/schedule-quality-service.js create mode 100644 frontend/react/admin/schedule-service.js create mode 100644 frontend/react/admin/schedule-versions-service.js create mode 100644 frontend/react/admin/schedule-view-service.js create mode 100644 frontend/react/admin/subjects-service.js create mode 100644 frontend/react/admin/tabs/AcademicCalendarTab.jsx create mode 100644 frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx create mode 100644 frontend/react/admin/tabs/ClassroomsTab.jsx create mode 100644 frontend/react/admin/tabs/DashboardTab.jsx create mode 100644 frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx create mode 100644 frontend/react/admin/tabs/GroupsTab.jsx create mode 100644 frontend/react/admin/tabs/ScheduleQualityTab.jsx create mode 100644 frontend/react/admin/tabs/ScheduleTab.jsx create mode 100644 frontend/react/admin/tabs/ScheduleVersionsTab.jsx create mode 100644 frontend/react/admin/tabs/ScheduleViewTab.jsx create mode 100644 frontend/react/admin/tabs/SubjectsTab.jsx create mode 100644 frontend/react/admin/tabs/TeacherAbsencesTab.jsx create mode 100644 frontend/react/admin/tabs/TeacherRequestsTab.jsx create mode 100644 frontend/react/admin/tabs/UniversityStructureTab.jsx create mode 100644 frontend/react/admin/tabs/UsersTab.jsx create mode 100644 frontend/react/admin/teacher-absences-service.js create mode 100644 frontend/react/admin/teacher-requests-service.js create mode 100644 frontend/react/admin/testing/render-admin.jsx create mode 100644 frontend/react/admin/university-structure-service.js create mode 100644 frontend/react/admin/users-service.js create mode 100644 frontend/react/login/LoginApp.jsx create mode 100644 frontend/react/login/login-service.js create mode 100644 frontend/react/login/main.jsx create mode 100644 frontend/react/settings/DatabaseTab.jsx create mode 100644 frontend/react/settings/EduFormsTab.jsx create mode 100644 frontend/react/settings/GeneralTab.jsx create mode 100644 frontend/react/settings/SettingsApp.jsx create mode 100644 frontend/react/settings/TimeSlotsTab.jsx create mode 100644 frontend/react/settings/main.jsx create mode 100644 frontend/react/settings/settings-service.js create mode 100644 frontend/react/settings/testing/render-settings.jsx create mode 100644 frontend/react/shared/api-client.js create mode 100644 frontend/react/shared/auth-client.js create mode 100644 frontend/react/shared/dates.js create mode 100644 frontend/react/shared/runtime-entry.jsx create mode 100644 frontend/react/shared/session-policy.js create mode 100644 frontend/react/shared/testing/render-shared.jsx create mode 100644 frontend/react/shared/ui/AppErrorBoundary.jsx create mode 100644 frontend/react/shared/ui/AsyncCombobox.jsx create mode 100644 frontend/react/shared/ui/AsyncState.jsx create mode 100644 frontend/react/shared/ui/ConfirmDialog.jsx create mode 100644 frontend/react/shared/ui/FormAlert.jsx create mode 100644 frontend/react/shared/ui/Pagination.jsx create mode 100644 frontend/react/shared/ui/ScheduleOverview.jsx create mode 100644 frontend/react/shared/use-authorized-session.js create mode 100644 frontend/react/student/StudentApp.jsx create mode 100644 frontend/react/student/WeekGrid.jsx create mode 100644 frontend/react/student/main.jsx create mode 100644 frontend/react/student/student-service.js create mode 100644 frontend/react/student/testing/render-student.jsx create mode 100644 frontend/react/teacher/AbsencePanel.jsx create mode 100644 frontend/react/teacher/PreferencesTab.jsx create mode 100644 frontend/react/teacher/RequestDialog.jsx create mode 100644 frontend/react/teacher/RequestsTab.jsx create mode 100644 frontend/react/teacher/TeacherApp.jsx create mode 100644 frontend/react/teacher/TeacherWeekGrid.jsx create mode 100644 frontend/react/teacher/main.jsx create mode 100644 frontend/react/teacher/teacher-service.js create mode 100644 frontend/react/teacher/testing/render-teacher.jsx delete mode 100755 frontend/script.js create mode 100644 frontend/scripts/build-react-tests.mjs create mode 100644 frontend/scripts/build-react.mjs delete mode 100644 frontend/student/app.js delete mode 100644 frontend/teacher/app.js create mode 100644 frontend/tests/react-admin-tabs.test.mjs create mode 100644 frontend/tests/react-admin.test.mjs create mode 100644 frontend/tests/react-login.test.mjs create mode 100644 frontend/tests/react-settings.test.mjs create mode 100644 frontend/tests/react-shared.test.mjs create mode 100644 frontend/tests/react-student.test.mjs create mode 100644 frontend/tests/react-teacher.test.mjs diff --git a/AGENTS.md b/AGENTS.md index bdb188e..0519e4e 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 37b5a7e..8f94f6c 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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 -Пользователи -Группы -Расписание занятий -Календарный график -``` +- при старте активная вкладка читается из `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`. --- diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 35683b1..f3e9e62 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -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`, diff --git a/docs/REACT_MIGRATION.md b/docs/REACT_MIGRATION.md new file mode 100644 index 0000000..d784ef6 --- /dev/null +++ b/docs/REACT_MIGRATION.md @@ -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 +``` diff --git a/docs/REACT_MIGRATION_RESULT.md b/docs/REACT_MIGRATION_RESULT.md new file mode 100644 index 0000000..a0b777e --- /dev/null +++ b/docs/REACT_MIGRATION_RESULT.md @@ -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 +``` diff --git a/docs/README.md b/docs/README.md index 77d1d0d..c4d33ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 (реверс-прокси) | diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bdb0cbf..20fb186 100755 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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/ diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 79afd49..dd875c6 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -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 { diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 0a28a66..1d51a8d 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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; +} \ No newline at end of file diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index bcfa5cc..4a2856e 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -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; diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 5335343..688924c 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -19,243 +19,9 @@ - - - - - - - -
-
-
-

Загрузка...

- -
-
- - -
- -
-
- - - + diff --git a/frontend/admin/js/async-combobox.js b/frontend/admin/js/async-combobox.js deleted file mode 100644 index ca034d6..0000000 --- a/frontend/admin/js/async-combobox.js +++ /dev/null @@ -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 = ` -
- - -
- - `; - 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 = '
Поиск...
'; - 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 = `
${escapeText(error.message || 'Не удалось выполнить поиск')}
`; - } - } - - renderItems() { - if (!this.items.length) { - this.menu.innerHTML = `
${escapeText(this.emptyLabel)}
`; - return; - } - this.menu.innerHTML = this.items.map((item, index) => { - const value = String(this.optionValue(item)); - const description = this.optionDescription(item); - return ``; - }).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 = ``; - 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 = ''; - 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('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js deleted file mode 100755 index 22fa11c..0000000 --- a/frontend/admin/js/main.js +++ /dev/null @@ -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 = '
Загрузка...
'; - 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(); diff --git a/frontend/admin/js/pagination.js b/frontend/admin/js/pagination.js deleted file mode 100644 index e7d0f81..0000000 --- a/frontend/admin/js/pagination.js +++ /dev/null @@ -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 = ` -
- Показано ${start}–${end} из ${totalItems} ${escapeHtml(label)} -
-
- - - ${totalPages ? page + 1 : 0} / ${totalPages} - -
- `; - 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)); - }); -} diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js deleted file mode 100644 index e41a8a4..0000000 --- a/frontend/admin/js/views/academic-calendar.js +++ /dev/null @@ -1,1881 +0,0 @@ -import { api } from '../api.js'; -import { AsyncCombobox } from '../async-combobox.js'; -import { renderPagination } from '../pagination.js'; -import { debounce } from '../url-state.js'; -import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js'; -import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; -import { academicCalendarWeekNumber } from './academic-calendar-grid.js'; -import { buildAcademicCalendarTitle } from './academic-calendar-title.js'; -import { confirmAction } from '../dialog.js'; -import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js'; -import { trackUxEvent } from '../../../telemetry.js'; - -const DAY_OPTIONS = [ - { value: 1, label: 'Понедельник', shortLabel: 'Пн' }, - { value: 2, label: 'Вторник', shortLabel: 'Вт' }, - { value: 3, label: 'Среда', shortLabel: 'Ср' }, - { value: 4, label: 'Четверг', shortLabel: 'Чт' }, - { value: 5, label: 'Пятница', shortLabel: 'Пт' }, - { value: 6, label: 'Суббота', shortLabel: 'Сб' }, - { value: 7, label: 'Воскресенье', shortLabel: 'Вс' } -]; - -const SEMESTER_LABELS = { - 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('-'); -} - -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 async function initAcademicCalendar() { - const academicYearForm = document.getElementById('academic-year-form'); - const academicYearFormTitle = document.getElementById('academic-year-form-title'); - const academicYearIdInput = document.getElementById('academic-year-id'); - const academicYearStartInput = document.getElementById('academic-year-start'); - const academicYearEndInput = document.getElementById('academic-year-end'); - const academicYearResetButton = document.getElementById('academic-year-reset'); - const academicYearsTbody = document.getElementById('academic-years-tbody'); - const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); - const academicYearSemesterInputs = { - autumn: { - id: document.getElementById('academic-year-autumn-id'), - start: document.getElementById('academic-year-autumn-start'), - end: document.getElementById('academic-year-autumn-end') - }, - spring: { - id: document.getElementById('academic-year-spring-id'), - start: document.getElementById('academic-year-spring-start'), - end: document.getElementById('academic-year-spring-end') - } - }; - - const calendarForm = document.getElementById('academic-calendar-form'); - const calendarFormTitle = document.getElementById('academic-calendar-form-title'); - const calendarIdInput = document.getElementById('academic-calendar-id'); - const calendarYearSelect = document.getElementById('academic-calendar-year'); - const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); - const calendarProfileSelect = document.getElementById('academic-calendar-profile'); - const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); - const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); - const calendarResetButton = document.getElementById('academic-calendar-reset'); - const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); - const calendarsTbody = document.getElementById('academic-calendars-tbody'); - - const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); - const editorLoadButton = document.getElementById('calendar-editor-load'); - const editorSaveButton = document.getElementById('calendar-editor-save'); - const fillCourseSelect = document.getElementById('calendar-fill-course'); - const fillStartInput = document.getElementById('calendar-fill-start'); - const fillEndInput = document.getElementById('calendar-fill-end'); - const fillActivitySelect = document.getElementById('calendar-fill-activity'); - const fillApplyButton = document.getElementById('calendar-fill-apply'); - const calendarGrid = document.getElementById('calendar-grid'); - const calendarTotals = document.getElementById('calendar-totals'); - const calendarDayDialog = document.getElementById('calendar-day-dialog'); - const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close'); - const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date'); - const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course'); - const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week'); - const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); - const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); - const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); - const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); - const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); - const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); - const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]')); - const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]')); - const subjectCalendarSelect = document.getElementById('calendar-subject-calendar'); - const subjectSemesterSelect = document.getElementById('calendar-subject-semester'); - const subjectSelect = document.getElementById('calendar-subject-id'); - const subjectAddButton = document.getElementById('calendar-subject-add'); - const subjectSaveButton = document.getElementById('calendar-subject-save'); - const subjectRefreshButton = document.getElementById('calendar-subject-refresh'); - const subjectSummary = document.getElementById('calendar-subject-summary'); - const subjectCount = document.getElementById('calendar-subject-count'); - - let academicYears = []; - let specialties = []; - let profilesBySpecialty = new Map(); - let studyForms = []; - let activityTypes = []; - let calendars = []; - let calendarPage = 0; - let calendarSize = 25; - let calendarRequest = 0; - const calendarCache = new Map(); - const calendarComboboxes = new Map(); - const calendarSearch = document.getElementById('academic-calendars-search'); - const calendarYearFilter = document.getElementById('academic-calendars-year-filter'); - const calendarPagination = document.getElementById('academic-calendars-pagination'); - let subjects = []; - let calendarSubjectRows = []; - let timeSlotScopes = []; - let timeSlotDateAssignments = []; - let activeCalendarDay = null; - let selectedCalendarDayKey = null; - let selectedCalendarDays = []; - let isCalendarDragSelecting = false; - let didCalendarDragSelect = false; - let suppressCalendarDayClick = false; - let isSyncingCalendarDayDialog = false; - let calendarDayTooltip = null; - let calendarDayTooltipPositionAnimation = null; - let loadedGridCalendarId = ''; - let loadedSubjectCalendarId = ''; - let subjectLoadRequest = 0; - let gridLoadRequest = 0; - const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?'); - const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?'); - - [editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => { - select.innerHTML = ''; - calendarComboboxes.set(select, new AsyncCombobox(select, { - placeholder: 'Поиск по направлению, профилю, форме или году', - optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title, - optionDescription: calendar => calendar.specialtyName || '', - load: async (query, selectedId) => { - const params = new URLSearchParams({ query, limit: 20 }); - if (selectedId) params.set('selectedId', selectedId); - const items = await api.get(`/api/admin/academic-calendars/options?${params}`); - items.forEach(calendar => calendarCache.set(String(calendar.id), calendar)); - return items; - } - })); - }); - - bindEvents(); - - try { - await Promise.all([ - loadBaseLists(), - loadYears(), - loadSubjects(), - loadStudyForms(), - loadActivityTypes(), - loadTimeSlotScopes(), - loadTimeSlotDateAssignments() - ]); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); - } - - function bindEvents() { - [ - academicYearSemesterInputs.autumn.start, - academicYearSemesterInputs.autumn.end, - academicYearSemesterInputs.spring.start, - academicYearSemesterInputs.spring.end, - fillStartInput, - fillEndInput - ].forEach(bindDateInputMask); - - calendarTabButtons.forEach(button => { - button.addEventListener('click', async () => { - if (!await confirmAbandonDirtyChanges()) return; - activateCalendarTab(button.dataset.calendarTab); - }); - }); - academicYearsRefreshButton?.addEventListener('click', loadYears); - calendarSearch?.addEventListener('input', debounce(() => { - calendarPage = 0; - loadCalendars(); - }, 300)); - calendarYearFilter?.addEventListener('change', () => { - calendarPage = 0; - loadCalendars(); - }); - calendarsRefreshButton?.addEventListener('click', loadCalendars); - academicYearResetButton?.addEventListener('click', resetAcademicYearForm); - academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput); - calendarResetButton?.addEventListener('click', resetCalendarForm); - calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); - editorLoadButton?.addEventListener('click', loadCalendarGrid); - editorSaveButton?.addEventListener('click', saveCalendarGrid); - fillApplyButton?.addEventListener('click', applyCalendarFill); - subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects); - subjectAddButton?.addEventListener('click', addCalendarSubject); - subjectSaveButton?.addEventListener('click', saveCalendarSubjects); - subjectRefreshButton?.addEventListener('click', loadCalendarSubjects); - subjectSummary?.addEventListener('click', handleSubjectSummaryClick); - - calendarGrid?.addEventListener('change', (event) => { - if (event.target.matches('.calendar-day-activity')) { - updateCalendarDayVisual(event.target.closest('.calendar-day')); - renderCalendarTotals(); - markGridDirty(); - } - }); - calendarGrid?.addEventListener('mousedown', (event) => { - const day = event.target.closest('.calendar-day'); - if (day) startCalendarDayDragSelection(event, day); - }); - calendarGrid?.addEventListener('mouseover', (event) => { - const day = event.target.closest('.calendar-day'); - if (day) addCalendarDayToDragSelection(day); - }); - calendarGrid?.addEventListener('mousemove', (event) => { - const day = event.target.closest('.calendar-day'); - if (day) showCalendarDayTooltip(day, event); - }); - calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip); - calendarGrid?.addEventListener('focusin', (event) => { - const day = event.target.closest('.calendar-day'); - if (day) showCalendarDayTooltip(day); - }); - calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip); - calendarGrid?.addEventListener('click', (event) => { - if (suppressCalendarDayClick) { - suppressCalendarDayClick = false; - event.preventDefault(); - return; - } - const day = event.target.closest('.calendar-day'); - if (day) openCalendarDayDialog(day); - }); - document.addEventListener('mouseup', finishCalendarDayDragSelection); - calendarGrid?.addEventListener('keydown', (event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - const day = event.target.closest('.calendar-day'); - if (!day) return; - event.preventDefault(); - openCalendarDayDialog(day); - }); - calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog); - calendarDayDialog?.addEventListener('click', (event) => { - if (event.target === calendarDayDialog) closeCalendarDayDialog(); - }); - calendarDayDialogActivitySelect?.addEventListener('change', () => { - if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return; - const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []); - days.forEach(day => { - const select = day.querySelector('.calendar-day-activity'); - select.value = calendarDayDialogActivitySelect.value; - updateCalendarDayVisual(day); - }); - updateCalendarDayDialogSummaryForSelection(days); - renderCalendarTotals(); - markGridDirty(); - }); - calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); - calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); - - academicYearForm?.addEventListener('submit', saveAcademicYear); - calendarForm?.addEventListener('submit', saveCalendar); - - academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); - calendarsTbody?.addEventListener('click', handleCalendarTableClick); - } - - async function loadBaseLists() { - specialties = await api.get('/api/specialties'); - await loadProfiles(); - populateSpecialtySelects(); - } - - 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 loadYears() { - academicYearsTbody.innerHTML = 'Загрузка...'; - try { - academicYears = await api.get('/api/admin/calendar/years'); - renderAcademicYears(); - populateYearSelects(); - const selectedYear = calendarYearFilter.value; - calendarYearFilter.innerHTML = '' + academicYears.map(year => - ``).join(''); - calendarYearFilter.value = selectedYear; - refreshSelectVisuals(calendarYearFilter); - } catch (error) { - academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - throw error; - } - } - - function renderAcademicYears() { - if (!academicYears.length) { - academicYearsTbody.innerHTML = 'Учебные годы не созданы'; - return; - } - academicYearsTbody.innerHTML = academicYears.map(year => ` - - ${escapeHtml(year.title)} - ${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))} - ${renderSemesterList(year.semesters || [])} - -
- - -
- - - `).join(''); - } - - function renderSemesterList(items) { - if (!items.length) return 'Нет семестров'; - return `
${items.map(semester => { - const title = semesterTypeTitle(semester); - const period = formatDisplayDateRange(semester.startDate, semester.endDate); - return ` -
-
- ${escapeHtml(title)} - ${escapeHtml(period)} -
- -
- `; - }).join('')}
`; - } - - async function saveAcademicYear(event) { - event.preventDefault(); - hideAlert('academic-year-alert'); - try { - const defaults = applyAcademicYearDateDefaults(true); - const { startDate, endDate } = defaults; - const payload = { - startDate, - endDate, - semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type)) - }; - validateAcademicYearSemesters(payload.semesters, startDate, endDate); - const id = academicYearIdInput.value; - await (id - ? api.put('/api/admin/calendar/years/' + id, payload) - : api.post('/api/admin/calendar/years', payload)); - showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success'); - resetAcademicYearForm(); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); - } - } - - async function handleAcademicYearTableClick(event) { - const editYearButton = event.target.closest('.btn-edit-year'); - const deleteYearButton = event.target.closest('.btn-delete-year'); - const editSemesterButton = event.target.closest('.btn-edit-semester'); - - if (editYearButton) { - const year = academicYears.find(item => item.id == editYearButton.dataset.id); - if (!year) return; - fillAcademicYearForm(year); - return; - } - - if (editSemesterButton) { - const year = academicYears.find(item => (item.semesters || []) - .some(semester => String(semester.id) === String(editSemesterButton.dataset.id))); - if (!year) return; - const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id)); - fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType)); - return; - } - - if (deleteYearButton) { - const confirmed = await confirmAction({ - title: 'Удаление учебного года', - message: 'Удалить учебный год вместе с семестрами и календарными графиками?', - confirmLabel: 'Удалить', - danger: true - }); - if (!confirmed) return; - try { - await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); - showAlert('academic-year-alert', 'Учебный год удалён', 'success'); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); - } - } - } - - function resetAcademicYearForm() { - academicYearForm.reset(); - academicYearFormTitle.textContent = 'Учебный год и семестры'; - academicYearIdInput.value = ''; - Object.values(academicYearSemesterInputs).forEach(inputs => { - inputs.id.value = ''; - }); - hideAlert('academic-year-alert'); - } - - function fillAcademicYearForm(year, focusedSemesterType = '') { - academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim(); - academicYearIdInput.value = year.id; - academicYearStartInput.value = String(year.startDate || '').slice(0, 4); - academicYearEndInput.value = String(year.endDate || '').slice(0, 4); - ['autumn', 'spring'].forEach(type => { - const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type); - const inputs = academicYearSemesterInputs[type]; - inputs.id.value = semester?.id || ''; - setDateInputIso(inputs.start, semester?.startDate || ''); - setDateInputIso(inputs.end, semester?.endDate || ''); - }); - hideAlert('academic-year-alert'); - academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) { - academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true }); - } - } - - function handleAcademicYearStartInput() { - academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4); - if (academicYearStartInput.value.length !== 4) { - academicYearEndInput.value = ''; - return; - } - try { - applyAcademicYearDateDefaults(); - } catch (_) { - academicYearEndInput.value = ''; - } - } - - function applyAcademicYearDateDefaults(preserveSemesterDates = false) { - const defaults = academicYearDefaults(academicYearStartInput.value); - academicYearEndInput.value = String(defaults.endYear); - const semesterDefaults = { - autumn: defaults.semesters[0], - spring: defaults.semesters[1] - }; - ['autumn', 'spring'].forEach(type => { - const inputs = academicYearSemesterInputs[type]; - if (!preserveSemesterDates || !inputs.start.value) { - setDateInputIso(inputs.start, semesterDefaults[type].startDate); - } - if (!preserveSemesterDates || !inputs.end.value) { - setDateInputIso(inputs.end, semesterDefaults[type].endDate); - } - }); - return defaults; - } - - function readAcademicYearSemester(type) { - const inputs = academicYearSemesterInputs[type]; - return { - id: inputs.id.value ? Number(inputs.id.value) : null, - semesterType: type, - startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`), - endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`) - }; - } - - async function loadStudyForms() { - studyForms = await api.get('/api/education-forms'); - populateStudyFormSelect(); - } - - async function loadSubjects() { - subjects = await api.get('/api/subjects'); - populateSubjectSelect(); - } - - async function loadActivityTypes() { - activityTypes = await api.get('/api/admin/calendar/activity-types'); - populateActivitySelects(); - } - - async function loadTimeSlotScopes() { - timeSlotScopes = await api.get('/api/admin/time-slots/scopes'); - } - - async function loadTimeSlotDateAssignments() { - timeSlotDateAssignments = await api.get('/api/admin/time-slots/date-assignments'); - } - - async function loadCalendars() { - const request = ++calendarRequest; - calendarPagination?.replaceChildren(); - calendarsTbody.innerHTML = 'Загрузка...'; - try { - const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' }); - if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value); - const page = await api.get(`/api/admin/academic-calendars/page?${params}`); - if (request !== calendarRequest) return; - if (calendarPage > 0 && calendarPage >= page.totalPages) { - calendarPage = Math.max(0, page.totalPages - 1); - return loadCalendars(); - } - calendars = page.items; - calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar)); - renderCalendars(); - renderPagination(calendarPagination, page, { - label: 'графиков', - onPage: value => { calendarPage = value; loadCalendars(); }, - onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); } - }); - } catch (error) { - if (request !== calendarRequest) return; - calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - throw error; - } - } - - function renderCalendars() { - if (!calendars.length) { - calendarsTbody.innerHTML = 'Календарные графики не найдены'; - return; - } - calendarsTbody.innerHTML = calendars.map(calendar => ` - - ${escapeHtml(calendar.academicYearTitle || '-')} - ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} - ${escapeHtml(calendar.specialtyProfileName || '-')} - ${escapeHtml(calendar.studyFormName || '-')} - ${escapeHtml(String(calendar.courseCount || '-'))} - - - - - - - - `).join(''); - } - - async function saveCalendar(event) { - event.preventDefault(); - hideAlert('academic-calendar-alert'); - const payload = { - id: calendarIdInput.value ? Number(calendarIdInput.value) : null, - academicYearId: Number(calendarYearSelect.value), - specialtyId: Number(calendarSpecialtySelect.value), - specialtyProfileId: Number(calendarProfileSelect.value), - studyFormId: Number(calendarStudyFormSelect.value), - courseCount: Number(calendarCourseCountInput.value) - }; - try { - if (!payload.academicYearId || !payload.specialtyId - || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { - throw new Error('Заполните все поля календарного графика'); - } - const id = calendarIdInput.value; - const saved = id - ? await api.put('/api/admin/academic-calendars/' + id, payload) - : await api.post('/api/admin/academic-calendars', payload); - calendarCache.set(String(saved.id), saved); - calendarComboboxes.forEach((combobox, select) => { - if (select.value === String(saved.id)) setCalendarSelection(select, saved.id); - }); - const savedTitle = buildAcademicCalendarTitle(saved); - showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success'); - resetCalendarForm(); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); - } - } - - async function handleCalendarTableClick(event) { - const editButton = event.target.closest('.btn-edit-calendar'); - const gridButton = event.target.closest('.btn-open-calendar-grid'); - const subjectsButton = event.target.closest('.btn-open-calendar-subjects'); - const deleteButton = event.target.closest('.btn-delete-calendar'); - - if (editButton) { - activateCalendarTab('manage'); - const calendar = calendars.find(item => item.id == editButton.dataset.id); - if (!calendar) return; - calendarFormTitle.textContent = 'Редактирование календарного графика'; - calendarIdInput.value = calendar.id; - calendarYearSelect.value = calendar.academicYearId || ''; - calendarSpecialtySelect.value = calendar.specialtyId || ''; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); - calendarStudyFormSelect.value = calendar.studyFormId || ''; - calendarCourseCountInput.value = calendar.courseCount || 4; - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - - if (gridButton) { - activateCalendarTab('grid'); - setCalendarSelection(editorCalendarSelect, gridButton.dataset.id); - refreshSelectVisuals(editorCalendarSelect); - await loadCalendarGrid(); - calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - - if (subjectsButton) { - activateCalendarTab('subjects'); - setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id); - refreshSelectVisuals(subjectCalendarSelect); - await loadCalendarSubjects(); - subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - - if (deleteButton) { - const confirmed = await confirmAction({ - title: 'Удаление календарного графика', - message: 'Удалить календарный график? Связанные настройки больше не будут доступны.', - confirmLabel: 'Удалить', - danger: true - }); - if (!confirmed) return; - try { - const deletedId = deleteButton.dataset.id; - await api.delete('/api/admin/academic-calendars/' + deletedId); - calendarCache.delete(deletedId); - calendarComboboxes.forEach((combobox, select) => { - if (select.value === deletedId) { - setCalendarSelection(select, ''); - if (select === editorCalendarSelect) { - loadedGridCalendarId = ''; - calendarGrid.replaceChildren(); - calendarTotals.replaceChildren(); - clearGridDirty(); - } else { - loadCalendarSubjects(); - } - } - }); - showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); - } - } - } - - function resetCalendarForm() { - calendarForm.reset(); - calendarFormTitle.textContent = 'Календарный учебный график'; - calendarIdInput.value = ''; - calendarCourseCountInput.value = 4; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - } - - function activateCalendarTab(tabName) { - const target = tabName || 'manage'; - calendarTabButtons.forEach(button => { - const active = button.dataset.calendarTab === target; - button.classList.toggle('active', active); - button.setAttribute('aria-selected', active ? 'true' : 'false'); - }); - calendarTabPanels.forEach(panel => { - const active = panel.dataset.calendarTabPanel === target; - panel.classList.toggle('active', active); - panel.hidden = !active; - }); - } - - async function loadCalendarSubjects() { - const request = ++subjectLoadRequest; - hideAlert('calendar-subject-alert'); - const calendar = selectedSubjectCalendar(); - if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { - setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId); - refreshSelectVisuals(subjectCalendarSelect); - return; - } - if (request !== subjectLoadRequest) return; - populateSubjectSemesterSelect(); - calendarSubjectRows = []; - loadedSubjectCalendarId = ''; - if (!calendar) { - loadedSubjectCalendarId = ''; - clearSubjectDirty(); - renderCalendarSubjects(); - return; - } - subjectSummary.innerHTML = '
Загрузка...
'; - try { - const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); - if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return; - calendarSubjectRows = sortCalendarSubjectRows(rows || []); - loadedSubjectCalendarId = String(calendar.id); - clearSubjectDirty(); - renderCalendarSubjects(); - } catch (error) { - if (request !== subjectLoadRequest) return; - subjectSummary.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; - showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error'); - } - } - - function addCalendarSubject() { - hideAlert('calendar-subject-alert'); - const calendar = selectedSubjectCalendar(); - if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { - showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error'); - return; - } - const semesterNumber = Number(subjectSemesterSelect.value); - const subjectId = Number(subjectSelect.value); - if (!calendar || !semesterNumber || !subjectId) { - showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error'); - return; - } - if (calendarSubjectRows.some(row => - Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId - )) { - showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error'); - return; - } - const subject = subjects.find(item => Number(item.id) === subjectId); - if (!subject) { - showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error'); - return; - } - calendarSubjectRows = sortCalendarSubjectRows([ - ...calendarSubjectRows, - { - id: null, - calendarId: calendar.id, - semesterNumber, - subjectId, - subjectName: subject.name, - subjectCode: subject.code, - departmentId: subject.departmentId - } - ]); - renderCalendarSubjects(); - markSubjectDirty(); - showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success'); - } - - async function saveCalendarSubjects() { - hideAlert('calendar-subject-alert'); - const calendar = selectedSubjectCalendar(); - if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { - showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error'); - return; - } - if (!calendar) { - showAlert('calendar-subject-alert', 'Выберите календарный график', 'error'); - return; - } - const payload = calendarSubjectRows.map(row => ({ - semesterNumber: Number(row.semesterNumber), - subjectId: Number(row.subjectId) - })); - try { - const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload); - calendarSubjectRows = sortCalendarSubjectRows(saved || []); - renderCalendarSubjects(); - clearSubjectDirty(); - trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: calendarSubjectRows.length }); - showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success'); - } catch (error) { - showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error'); - } - } - - function handleSubjectSummaryClick(event) { - const removeButton = event.target.closest('.calendar-subject-remove'); - if (!removeButton) return; - const semesterNumber = Number(removeButton.dataset.semester); - const subjectId = Number(removeButton.dataset.subject); - calendarSubjectRows = calendarSubjectRows.filter(row => - Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId - ); - renderCalendarSubjects(); - markSubjectDirty(); - showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success'); - } - - function renderCalendarSubjects() { - const calendar = selectedSubjectCalendar(); - if (!calendar) { - subjectCount.textContent = ''; - subjectSummary.innerHTML = '
Выберите календарный график
'; - return; - } - - const semesterCount = calendarSemesterCount(calendar); - subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`; - subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => { - const semesterNumber = index + 1; - const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber); - return ` -
-
- ${escapeHtml(semesterOptionLabel(semesterNumber))} - ${semesterRows.length} дисциплин -
- ${semesterRows.length - ? `
${semesterRows.map(renderSubjectChip).join('')}
` - : '
Нет дисциплин
'} -
- `; - }).join(''); - } - - function renderSubjectChip(row) { - return ` - - ${escapeHtml(subjectRowLabel(row))} - - - `; - } - - async function loadCalendarGrid() { - const request = ++gridLoadRequest; - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { - setCalendarSelection(editorCalendarSelect, loadedGridCalendarId); - refreshSelectVisuals(editorCalendarSelect); - return; - } - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - try { - await loadTimeSlotDateAssignments(); - const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); - if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return; - const gridRows = rows.length ? rows : buildDefaultGrid(calendar); - renderCalendarGrid(gridRows); - loadedGridCalendarId = String(calendar.id); - clearGridDirty(); - trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 }); - showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); - } catch (error) { - if (request !== gridLoadRequest) return; - showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); - } - } - - async function saveCalendarGrid() { - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - if (String(calendar.id) !== loadedGridCalendarId) { - showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error'); - return; - } - const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ - calendarId: calendar.id, - courseNumber: Number(day.dataset.course), - date: day.dataset.date, - weekNumber: Number(day.dataset.week), - dayOfWeek: Number(day.dataset.day), - activityTypeId: Number(day.querySelector('.calendar-day-activity').value) - })); - if (!rows.length) { - showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); - return; - } - try { - await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); - clearGridDirty(); - trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length }); - showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); - await loadCalendarGrid(); - } catch (error) { - showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); - } - } - - function applyCalendarFill() { - hideAlert('calendar-editor-alert'); - const activityId = fillActivitySelect.value; - if (!activityId || !fillStartInput.value || !fillEndInput.value) { - showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); - return; - } - let start; - let end; - try { - start = readDateInputIso(fillStartInput, 'Дата начала диапазона'); - end = readDateInputIso(fillEndInput, 'Дата окончания диапазона'); - } catch (error) { - showAlert('calendar-editor-alert', error.message, 'error'); - return; - } - if (end < start) { - showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); - return; - } - const course = fillCourseSelect.value; - let changed = 0; - calendarGrid.querySelectorAll('.calendar-day').forEach(day => { - const sameCourse = !course || day.dataset.course === course; - const inRange = day.dataset.date >= start && day.dataset.date <= end; - if (sameCourse && inRange) { - day.querySelector('.calendar-day-activity').value = activityId; - updateCalendarDayVisual(day); - changed += 1; - } - }); - renderCalendarTotals(); - if (changed) markGridDirty(); - showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); - } - - function markGridDirty() { - gridDirtyScope.mark(); - if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку •'; - } - - function clearGridDirty() { - gridDirtyScope.clear(); - if (editorSaveButton) editorSaveButton.textContent = 'Сохранить сетку'; - } - - function markSubjectDirty() { - subjectDirtyScope.mark(); - if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки •'; - } - - function clearSubjectDirty() { - subjectDirtyScope.clear(); - if (subjectSaveButton) subjectSaveButton.textContent = 'Сохранить привязки'; - } - - function renderCalendarGrid(rows) { - activeCalendarDay = null; - selectedCalendarDayKey = null; - selectedCalendarDays = []; - const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` -
- - - - ${course} курс - - ${courseRows.length} дней - -
- ${renderCourseSemesters(Number(course), courseRows)} -
-
- `).join(''); - populateFillCourseSelect(); - renderCalendarTotals(); - } - - function renderCourseSemesters(course, courseRows) { - const calendar = selectedEditorCalendar(); - const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); - const maxWeekColumns = Math.max( - ...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length), - 1 - ); - return semestersForCourse.map(block => ` -
-
-

${block.semesterNumber} семестр

- ${escapeHtml(block.caption)} -
- ${renderSemesterTable(block.rows, maxWeekColumns)} -
- `).join(''); - } - - function renderSemesterTable(rows, targetWeekColumns = 0) { - if (!rows.length) { - return '
Нет дат в этом семестре
'; - } - - const rowsByWeek = groupBy(rows, row => row.weekNumber); - const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); - const weekSlots = [...weeks]; - while (weekSlots.length < targetWeekColumns) { - weekSlots.push(null); - } - const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); - return ` -
- - - - ${weekSlots.map(() => '').join('')} - - - - - ${weekSlots.map(week => week === null - ? '' - : ``).join('')} - - - - ${DAY_OPTIONS.map(day => ` - - - ${weekSlots.map(week => { - if (week === null) { - return ''; - } - const row = cellByWeekAndDay.get(`${week}:${day.value}`); - return row ? renderCalendarDay(row) : ''; - }).join('')} - - `).join('')} - -
День${week}
${escapeHtml(day.shortLabel)}
-
- `; - } - - function uniqueWeekNumbers(rows) { - return Array.from(new Set(rows.map(row => row.weekNumber))); - } - - function renderCalendarDay(row) { - const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); - const dayName = shortDayLabel(row.dayOfWeek); - const activity = activityById(selected) || activityTypes[0] || {}; - const code = activity.code || row.activityCode || 'Т'; - return ` - - ${escapeHtml(code)} - ${timeAssignmentForDate(row.date) ? 'вр.' : ''} - - - `; - } - - function openCalendarDayDialog(day) { - openCalendarDayDialogForSelection([day]); - } - - function openCalendarDayDialogForSelection(days) { - const selectedDays = uniqueCalendarDays(days); - if (!selectedDays.length) return; - - activeCalendarDay = selectedDays[0]; - selectCalendarDays(selectedDays); - - const dates = selectedDays.map(day => day.dataset.date).sort(); - const courses = new Set(selectedDays.map(day => day.dataset.course)); - const weeks = new Set(selectedDays.map(day => day.dataset.week)); - const weekDays = new Set(selectedDays.map(day => day.dataset.day)); - const isSingle = selectedDays.length === 1; - - calendarDayDialogDate.textContent = isSingle - ? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})` - : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; - calendarDayDialogCourse.textContent = courses.size === 1 - ? `${activeCalendarDay.dataset.course} курс` - : `${courses.size} курса`; - calendarDayDialogWeek.textContent = weeks.size === 1 - ? `${activeCalendarDay.dataset.week} неделя` - : `${weeks.size} недель`; - calendarDayDialogDay.textContent = weekDays.size === 1 - ? dayLabel(activeCalendarDay.dataset.day) - : `${selectedDays.length} ячеек`; - calendarDayDialogActivitySelect.innerHTML = options( - activityTypes.map(toActivityOption), - commonCalendarDayActivityValue(selectedDays) - ); - if (isSingle) { - populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); - } else { - populateMultiSelectionTimeScopeDialogSelect(); - } - isSyncingCalendarDayDialog = true; - syncSelects(calendarDayDialogActivitySelect); - syncSelects(calendarDayDialogTimeScopeSelect); - isSyncingCalendarDayDialog = false; - updateCalendarDayDialogSummaryForSelection(selectedDays); - renderCalendarTotals(); - calendarDayDialog.classList.add('open'); - } - - function closeCalendarDayDialog() { - activeCalendarDay = null; - calendarDayDialog.classList.remove('open'); - } - - function updateCalendarDayDialogSummary(day) { - const activity = activityById(day.querySelector('.calendar-day-activity').value); - if (!activity) { - applyActivityColorClass(calendarDayDialogActivitySummary, null); - calendarDayDialogActivitySummary.innerHTML = ''; - return; - } - applyActivityColorClass(calendarDayDialogActivitySummary, activity); - calendarDayDialogActivitySummary.innerHTML = ` - ${escapeHtml(activity.code)} -
- ${escapeHtml(activity.name)} -

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

-
- `; - } - - function updateCalendarDayDialogSummaryForSelection(days) { - if (days.length <= 1) { - updateCalendarDayDialogSummary(days[0]); - return; - } - - const activity = activityById(commonCalendarDayActivityValue(days)); - if (!activity) { - applyActivityColorClass(calendarDayDialogActivitySummary, null); - calendarDayDialogActivitySummary.innerHTML = ` - ... -
- Выбрано ячеек: ${days.length} -

Выберите код активности, чтобы применить его ко всему выделению

-
- `; - return; - } - - applyActivityColorClass(calendarDayDialogActivitySummary, activity); - calendarDayDialogActivitySummary.innerHTML = ` - ${escapeHtml(activity.code)} -
- ${escapeHtml(activity.name)} · ${days.length} ячеек -

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

-
- `; - } - - async function saveTimeSlotDateAssignment() { - if (!activeCalendarDay) return; - hideAlert('calendar-day-dialog-time-alert'); - const scopeId = calendarDayDialogTimeScopeSelect.value; - if (!scopeId) { - await clearTimeSlotDateAssignment(); - return; - } - try { - await api.post('/api/admin/time-slots/date-assignments', { - date: activeCalendarDay.dataset.date, - scopeId: Number(scopeId) - }); - await loadTimeSlotDateAssignments(); - updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); - populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); - syncSelects(calendarDayDialogTimeScopeSelect); - showAlert('calendar-day-dialog-time-alert', 'Ручная сетка применена к дате', 'success'); - } catch (error) { - showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка применения временной сетки', 'error'); - } - } - - async function clearTimeSlotDateAssignment() { - if (!activeCalendarDay) return; - hideAlert('calendar-day-dialog-time-alert'); - const assignment = timeAssignmentForDate(activeCalendarDay.dataset.date); - if (!assignment) { - calendarDayDialogTimeScopeSelect.value = ''; - syncSelects(calendarDayDialogTimeScopeSelect); - showAlert('calendar-day-dialog-time-alert', 'Для даты нет ручной сетки', 'success'); - return; - } - try { - await api.delete('/api/admin/time-slots/date-assignments/' + assignment.id); - await loadTimeSlotDateAssignments(); - updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); - populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); - syncSelects(calendarDayDialogTimeScopeSelect); - showAlert('calendar-day-dialog-time-alert', 'Ручная сетка убрана', 'success'); - } catch (error) { - showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка удаления ручной сетки', 'error'); - } - } - - function populateTimeScopeDialogSelect(date) { - const assignment = timeAssignmentForDate(date); - const manualScopes = timeSlotScopes.filter(scope => scope.applyMode === 'MANUAL'); - const automaticLabel = automaticTimeScopeLabel(date); - calendarDayDialogTimeScopeSelect.innerHTML = `` + - manualScopes.map(scope => ``).join(''); - calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; - calendarDayDialogTimeScopeSelect.disabled = false; - calendarDayDialogTimeSaveButton.disabled = false; - calendarDayDialogTimeClearButton.disabled = !assignment; - } - - function populateMultiSelectionTimeScopeDialogSelect() { - calendarDayDialogTimeScopeSelect.innerHTML = ''; - calendarDayDialogTimeScopeSelect.value = ''; - calendarDayDialogTimeScopeSelect.disabled = true; - calendarDayDialogTimeSaveButton.disabled = false; - calendarDayDialogTimeClearButton.disabled = true; - } - - function updateTimeAssignmentVisuals(date) { - const assignment = timeAssignmentForDate(date); - calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { - day.dataset.timeAssignmentId = assignment?.id || ''; - day.dataset.timeScopeId = assignment?.scopeId || ''; - const marker = day.querySelector('.calendar-day-time-marker'); - if (assignment && !marker) { - day.insertAdjacentHTML('beforeend', 'вр.'); - } - if (!assignment && marker) { - marker.remove(); - } - }); - } - - function updateCalendarDayVisual(day) { - if (!day) return; - const select = day.querySelector('.calendar-day-activity'); - const activity = activityById(select.value); - const code = activity?.code || '-'; - applyActivityColorClass(day, activity); - day.querySelector('.calendar-day-code').textContent = code; - day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); - } - - function showCalendarDayTooltip(day, event = null) { - if (isCalendarDragSelecting) { - hideCalendarDayTooltip(); - return; - } - const tooltip = ensureCalendarDayTooltip(); - tooltip.innerHTML = renderCalendarDayTooltip(day); - const activity = activityById(day.querySelector('.calendar-day-activity')?.value); - applyActivityColorClass(tooltip, activity); - tooltip.classList.add('visible'); - positionCalendarDayTooltip(tooltip, day, event); - } - - function hideCalendarDayTooltip() { - calendarDayTooltip?.classList.remove('visible'); - } - - function positionCalendarDayTooltip(tooltip, day, event) { - const dayRect = day.getBoundingClientRect(); - const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right; - const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom; - const tooltipRect = tooltip.getBoundingClientRect(); - const { left, top } = calculateCalendarTooltipPosition({ - pointerX, - pointerY, - tooltipWidth: tooltipRect.width, - tooltipHeight: tooltipRect.height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - }); - calendarDayTooltipPositionAnimation?.cancel(); - const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`; - calendarDayTooltipPositionAnimation = tooltip.animate( - [ - { transform: positionedTransform }, - { transform: positionedTransform } - ], - { duration: 1, fill: 'forwards' } - ); - } - - function ensureCalendarDayTooltip() { - if (calendarDayTooltip) return calendarDayTooltip; - calendarDayTooltip = document.createElement('div'); - calendarDayTooltip.className = 'calendar-day-tooltip'; - calendarDayTooltip.setAttribute('role', 'tooltip'); - document.body.appendChild(calendarDayTooltip); - return calendarDayTooltip; - } - - function renderCalendarDayTooltip(day) { - const activity = activityById(day.querySelector('.calendar-day-activity')?.value); - const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; - const timeAssignment = timeAssignmentForDate(day.dataset.date); - const timeLabel = timeAssignment - ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') - : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); - return ` -
- ${escapeHtml(code)} -
- ${escapeHtml(formatLongDate(day.dataset.date))} - ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя -
-
-
-
- Курс - ${escapeHtml(day.dataset.course)} -
-
- Активность - ${escapeHtml(activity?.name || 'Не задана')} -
-
- Время - ${escapeHtml(timeLabel)} -
-
- `; - } - - function startCalendarDayDragSelection(event, day) { - if (event.button !== 0) return; - hideCalendarDayTooltip(); - isCalendarDragSelecting = true; - didCalendarDragSelect = false; - suppressCalendarDayClick = false; - selectCalendarDays([day]); - event.preventDefault(); - } - - function addCalendarDayToDragSelection(day) { - if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return; - didCalendarDragSelect = true; - selectedCalendarDays.push(day); - day.classList.add('is-selected'); - renderCalendarTotals(); - } - - function finishCalendarDayDragSelection() { - if (!isCalendarDragSelecting) return; - isCalendarDragSelecting = false; - if (didCalendarDragSelect && selectedCalendarDays.length > 1) { - suppressCalendarDayClick = true; - openCalendarDayDialogForSelection(selectedCalendarDays); - } - } - - function selectCalendarDay(day) { - selectCalendarDays(day ? [day] : []); - } - - function selectCalendarDays(days) { - const uniqueDays = uniqueCalendarDays(days); - selectedCalendarDays = uniqueDays; - selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null; - calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); - uniqueDays.forEach(item => item.classList.add('is-selected')); - renderCalendarTotals(); - } - - function uniqueCalendarDays(days) { - return Array.from(new Set(days.filter(Boolean))); - } - - function commonCalendarDayActivityValue(days) { - const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || ''); - const uniqueValues = Array.from(new Set(values)); - return uniqueValues.length === 1 ? uniqueValues[0] : ''; - } - - function calendarDayKey(day) { - return `${day.dataset.course}:${day.dataset.date}`; - } - - function buildDefaultGrid(calendar) { - 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(), - activityCode: 'Т' - }); - }); - } - return rows; - } - - function semesterBlocksForCourse(calendar, course, courseRows) { - 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 - }; - }); - } - - function renderCalendarTotals() { - const counts = new Map(); - calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { - const activity = activityById(select.value); - const key = activity ? activity.code : '-'; - const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) }; - current.count += 1; - counts.set(key, current); - }); - const selectedDays = selectedCalendarDays.length - ? selectedCalendarDays - : (selectedCalendarDayKey - ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean) - : []); - const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null; - const selectedCode = selectedActivity?.code || null; - calendarTotals.innerHTML = Array.from(counts.entries()) - .sort(([a], [b]) => a.localeCompare(b, 'ru')) - .map(([code, entry]) => { - const selectedClass = selectedCode === code ? ' is-selected' : ''; - const suffix = selectedCode === code ? 'выбрано' : ''; - return `${escapeHtml(code)}: ${entry.count}${suffix}`; - }).join(''); - } - - function populateYearSelects() { - const yearOptions = '' + - academicYears.map(year => ``).join(''); - calendarYearSelect.innerHTML = yearOptions; - syncSelects(calendarYearSelect); - } - - function populateSpecialtySelects() { - calendarSpecialtySelect.innerHTML = '' + - specialties.map(speciality => ``).join(''); - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarSpecialtySelect); - } - - function populateProfileSelect(select, specialtyId, selectedProfileId = null) { - const profiles = profilesBySpecialty.get(String(specialtyId)) || []; - select.innerHTML = profiles.length - ? '' + profiles.map(profile => - `` - ).join('') - : ''; - if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { - select.value = selectedProfileId; - } - syncSelects(select); - } - - function populateStudyFormSelect() { - calendarStudyFormSelect.innerHTML = '' + - studyForms.map(form => ``).join(''); - syncSelects(calendarStudyFormSelect); - } - - function populateSubjectSelect() { - if (!subjectSelect) return; - const current = subjectSelect.value; - const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')); - subjectSelect.innerHTML = sortedSubjects.length - ? '' + sortedSubjects.map(subject => - `` - ).join('') - : ''; - if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) { - subjectSelect.value = current; - } - syncSelects(subjectSelect); - } - - function populateActivitySelects() { - const activityOptions = '' + - activityTypes.map(type => ``).join(''); - fillActivitySelect.innerHTML = activityOptions; - syncSelects(fillActivitySelect); - } - - function setCalendarSelection(select, id) { - const calendar = calendarCache.get(String(id)); - select.innerHTML = calendar - ? `` - : ''; - calendarComboboxes.get(select)?.syncFromSelect(); - } - - function populateSubjectSemesterSelect() { - if (!subjectSemesterSelect) return; - const calendar = selectedSubjectCalendar(); - const current = subjectSemesterSelect.value; - if (!calendar) { - subjectSemesterSelect.innerHTML = ''; - syncSelects(subjectSemesterSelect); - return; - } - const semesterCount = calendarSemesterCount(calendar); - subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => { - const semesterNumber = index + 1; - return ``; - }).join(''); - if (current && Number(current) >= 1 && Number(current) <= semesterCount) { - subjectSemesterSelect.value = current; - } - syncSelects(subjectSemesterSelect); - } - - function populateFillCourseSelect() { - const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); - fillCourseSelect.innerHTML = '' + - Array.from(courses).sort((a, b) => Number(a) - Number(b)) - .map(course => ``).join(''); - syncSelects(fillCourseSelect); - } - - function options(items, selectedValue) { - const selected = selectedValue == null ? '' : String(selectedValue); - return '' + items.map(item => { - const value = String(item.value); - return ``; - }).join(''); - } - - function toActivityOption(type) { - return { value: type.id, label: `${type.code} - ${type.name}` }; - } - - function selectedEditorCalendar() { - return calendarCache.get(String(editorCalendarSelect.value)); - } - - function selectedSubjectCalendar() { - return calendarCache.get(String(subjectCalendarSelect?.value)); - } - - function calendarSemesterCount(calendar) { - return Math.max(1, Number(calendar?.courseCount || 0) * 2); - } - - function semesterOptionLabel(semesterNumber) { - const course = Math.ceil(Number(semesterNumber) / 2); - const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний'; - return `${semesterNumber} семестр · ${course} курс, ${type}`; - } - - 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'); - }); - } - - function subjectLabel(subject) { - const code = subject.code ? `${subject.code} · ` : ''; - return `${code}${subject.name || 'Без названия'}`; - } - - function subjectRowLabel(row) { - const code = row.subjectCode ? `${row.subjectCode} · ` : ''; - return `${code}${row.subjectName || row.subjectId || 'Без названия'}`; - } - - function theoryActivityId() { - return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; - } - - function activityIdByCode(code) { - return activityTypes.find(type => type.code === code)?.id || null; - } - - function activityById(id) { - return activityTypes.find(type => String(type.id) === String(id)); - } - - function applyActivityColorClass(element, activity) { - if (!element) return; - Array.from(element.classList) - .filter(className => className.startsWith('calendar-activity-color-')) - .forEach(className => element.classList.remove(className)); - if (activity) { - element.classList.add(activityColorClass(activity)); - } - } - - function timeAssignmentForDate(date) { - return timeSlotDateAssignments.find(item => item.date === date); - } - - function automaticTimeScopeLabel(date) { - const jsDate = new Date(`${date}T00:00:00`); - const jsDay = jsDate.getDay(); - const isoDay = jsDay === 0 ? 7 : jsDay; - const weekdayScope = timeSlotScopes.find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); - const defaultScope = timeSlotScopes.find(scope => scope.applyMode === 'DEFAULT'); - const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; - return `По правилу даты (${scopeName})`; - } - - function semesterLabel(semester) { - const type = normalizeSemesterType(semester.semesterType); - return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`; - } - - function semesterTypeTitle(semester) { - const type = normalizeSemesterType(semester.semesterType); - const label = SEMESTER_LABELS[type] || type || 'семестр'; - return label.charAt(0).toUpperCase() + label.slice(1); - } - - function normalizeSemesterType(type) { - return String(type || '').toLowerCase(); - } - - function specialityLabel(speciality) { - const code = specialityCode(speciality) || speciality.id; - const name = speciality.specialityName || speciality.name || ''; - return name ? `${code} - ${name}` : String(code); - } - - function specialityCode(speciality) { - return speciality?.specialityCode - || speciality?.specialtyCode - || speciality?.speciality_code - || speciality?.specialty_code - || ''; - } - - function dayLabel(value) { - return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; - } - - function shortDayLabel(value) { - return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-'; - } - - function formatShortDate(value) { - const [year, month, day] = String(value).split('-'); - return `${day}.${month}`; - } - - function formatLongDate(value) { - const [year, month, day] = String(value).split('-'); - return `${day}.${month}.${year}`; - } - - function formatDisplayDate(value) { - const [year, month, day] = String(value || '').split('-'); - if (!year || !month || !day) return String(value || ''); - return `${day}-${month}-${year}`; - } - - function formatDisplayDateRange(startDate, endDate) { - return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`; - } - - function dateRangeCaption(rows) { - if (!rows.length) return ''; - const dates = rows.map(row => row.date).sort(); - return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; - } - - function minIso(a, b) { - return String(a) <= String(b) ? a : b; - } - - function maxIso(a, b) { - return String(a) >= String(b) ? a : b; - } - - function previousIsoDate(value) { - const date = parseLocalIsoDate(value); - date.setDate(date.getDate() - 1); - return formatLocalDate(date); - } - - 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 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; - } - - function syncSelects(...selects) { - selects.filter(Boolean).forEach(select => { - refreshSelectVisuals(select); - select.dispatchEvent(new Event('change', { bubbles: true })); - }); - } - - function refreshSelectVisuals(...selects) { - selects.filter(Boolean).forEach(select => { - calendarComboboxes.get(select)?.syncFromSelect(); - if (select.customSelectInstance) { - select.customSelectInstance.rebuildMenu(); - } - }); - } -} diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js deleted file mode 100644 index 09b3161..0000000 --- a/frontend/admin/js/views/auditorium-workload.js +++ /dev/null @@ -1,1161 +0,0 @@ -import { api } from '../api.js'; -import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js'; - -const NO_BUILDING = '__no_building__'; -const DISPLAY_ALL = 'all'; -const TARGET_CLASSROOMS = 'classrooms'; -const TARGET_TEACHERS = 'teachers'; -const TARGET_DEPARTMENTS = 'departments'; -const ROOM_PREFIX = 'room:'; -const TEACHER_PREFIX = 'teacher:'; -const DEPARTMENT_PREFIX = 'department:'; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -const WORKLOAD_TARGETS = { - [TARGET_CLASSROOMS]: { - allLabel: 'Все аудитории', - emptyLabel: 'Нет аудиторий по выбранным фильтрам', - rowLabel: 'Аудитория', - summaryLabel: 'Аудиторий', - periodTitle: 'Нагрузка аудитории по неделям', - selectError: 'Выберите аудиторию', - prefix: ROOM_PREFIX - }, - [TARGET_TEACHERS]: { - allLabel: 'Все преподаватели', - emptyLabel: 'Нет преподавателей', - rowLabel: 'Преподаватель', - summaryLabel: 'Преподавателей', - periodTitle: 'Нагрузка преподавателя по неделям', - selectError: 'Выберите преподавателя', - prefix: TEACHER_PREFIX - }, - [TARGET_DEPARTMENTS]: { - allLabel: 'Все кафедры', - emptyLabel: 'Нет кафедр', - rowLabel: 'Кафедра', - summaryLabel: 'Кафедр', - periodTitle: 'Нагрузка кафедры по неделям', - selectError: 'Выберите кафедру', - prefix: DEPARTMENT_PREFIX - } -}; - -const DAY_SHORT_LABELS = { - 1: 'Пн', - 2: 'Вт', - 3: 'Ср', - 4: 'Чт', - 5: 'Пт', - 6: 'Сб', - 7: 'Вс' -}; - -const DAY_FULL_LABELS = { - 1: 'Понедельник', - 2: 'Вторник', - 3: 'Среда', - 4: 'Четверг', - 5: 'Пятница', - 6: 'Суббота', - 7: 'Воскресенье' -}; - -const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; - -const CELL_PARITY_LAYOUT = [ - { parity: 'ODD', label: 'Нечётная' }, - { parity: 'EVEN', label: 'Чётная' } -]; - -export async function initAuditoriumWorkload() { - const dateInput = document.getElementById('workload-date'); - const refreshButton = document.getElementById('workload-refresh'); - const targetSelect = document.getElementById('workload-target-select'); - const displaySelect = document.getElementById('workload-display-select'); - const headerRow = document.getElementById('workload-header-row'); - const tbody = document.getElementById('workload-tbody'); - const summary = document.getElementById('workload-summary'); - const overviewBlock = document.getElementById('workload-overview-block'); - const roomBlock = document.getElementById('room-workload-block'); - const roomSummary = document.getElementById('room-workload-summary'); - const roomTables = document.getElementById('room-workload-tables'); - const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter')); - const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); - const minCapacityInput = document.getElementById('workload-min-capacity'); - - let classrooms = []; - let teachers = []; - let departments = []; - let timeSlots = []; - let equipments = []; - let groups = []; - let semesters = []; - let lessonsByEntitySlot = new Map(); - let failedGroupLoads = 0; - let currentTarget = TARGET_CLASSROOMS; - let currentView = DISPLAY_ALL; - let selectedRoomId = null; - let selectedEntityId = null; - - setDefaultDate(); - initFilters(); - bindEvents(); - await loadInitialData(); - - function setDefaultDate() { - if (!dateInput || dateInput.value) return; - const today = new Date(); - dateInput.value = formatIsoDate(today); - } - - function initFilters() { - initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); - initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); - } - - function bindEvents() { - refreshButton?.addEventListener('click', loadCurrentView); - dateInput?.addEventListener('change', loadCurrentView); - targetSelect?.addEventListener('change', () => { - currentTarget = targetSelect.value || TARGET_CLASSROOMS; - currentView = DISPLAY_ALL; - selectedRoomId = null; - selectedEntityId = null; - populateDisplayOptions(); - applyDisplaySelection(); - syncDisplayMode(); - loadCurrentView(); - }); - displaySelect?.addEventListener('change', () => { - applyDisplaySelection(); - syncDisplayMode(); - loadCurrentView(); - }); - - [ - ['building-checkboxes', 'building-text', 'Все корпуса'], - ['equipment-checkboxes', 'equipment-text', 'Любое оборудование'] - ].forEach(([containerId, textId, emptyText]) => { - const container = document.getElementById(containerId); - container?.addEventListener('change', () => { - updateFilterText(containerId, textId, emptyText); - if (currentView === DISPLAY_ALL) { - renderGrid(); - } - }); - }); - - minCapacityInput?.addEventListener('input', () => { - if (currentView === DISPLAY_ALL) { - renderGrid(); - } - }); - } - - async function loadInitialData() { - setTableLoading('Загрузка справочников и расписания...'); - try { - const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([ - api.get('/api/classrooms'), - api.get('/api/equipments'), - api.get('/api/groups'), - api.get('/api/admin/calendar/years').catch(() => []), - api.get('/api/users/teachers'), - api.get('/api/departments') - ]); - classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name)); - teachers = [...loadedTeachers].sort((a, b) => naturalCompare(userName(a), userName(b))); - departments = [...loadedDepartments].sort((a, b) => naturalCompare(departmentName(a), departmentName(b))); - equipments = loadedEquipments; - groups = loadedGroups; - semesters = (academicYears || []).flatMap(year => year.semesters || []); - currentTarget = targetSelect?.value || TARGET_CLASSROOMS; - populateFilters(); - populateDisplayOptions(); - applyDisplaySelection(); - syncDisplayMode(); - await loadCurrentView(); - } catch (error) { - const message = error.message || 'Ошибка загрузки данных загруженности'; - setTableError(message); - setRoomError(message); - } - } - - async function loadCurrentView() { - applyDisplaySelection(); - syncDisplayMode(); - try { - if (currentView === DISPLAY_ALL) { - await loadScheduleForSelectedDate(); - } else { - await loadSelectedEntityWorkload(); - } - } catch (error) { - const message = error.message || 'Ошибка загрузки загруженности'; - if (currentView === DISPLAY_ALL) { - setTableError(message); - } else { - setRoomError(message); - } - } - } - - async function loadScheduleForSelectedDate() { - const date = dateInput?.value; - if (!date) { - showAlert('workload-alert', 'Выберите дату', 'error'); - return; - } - - hideAlert('workload-alert'); - failedGroupLoads = 0; - setTableLoading('Загрузка расписания на выбранную дату...'); - timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`); - - if (!groups.length) { - lessonsByEntitySlot = new Map(); - renderGrid(); - showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error'); - return; - } - - const responses = await Promise.allSettled( - groups.map(group => api.get(`/api/schedule?${scheduleParams(group, date, date)}`)) - ); - failedGroupLoads = responses.filter(response => response.status === 'rejected').length; - const lessons = responses - .filter(response => response.status === 'fulfilled') - .flatMap(response => response.value || []); - - lessonsByEntitySlot = groupLessonsByEntitySlot(lessons); - renderGrid(); - - if (failedGroupLoads > 0) { - showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error'); - } - } - - async function loadSelectedEntityWorkload() { - const date = dateInput?.value; - const entity = selectedEntity(); - const target = targetConfig(); - if (!date) { - showAlert('workload-alert', 'Выберите дату', 'error'); - setRoomError('Выберите дату для расчёта двухнедельного периода'); - return; - } - if (!entity) { - showAlert('workload-alert', target.selectError, 'error'); - setRoomError(`${target.selectError} в поле «Отображение»`); - return; - } - - hideAlert('workload-alert'); - setRoomLoading('Загрузка расписания на две недели...'); - const range = twoWeekRange(date); - const dates = datesBetween(range.startDate, range.endDate); - const [slotLoad, lessonLoad] = await Promise.all([ - loadSlotsForDates(dates), - loadEntityLessons(range, entity.id) - ]); - - renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, { - failedSlotLoads: slotLoad.failedLoads, - failedGroupLoads: lessonLoad.failedLoads, - parityLessons: lessonLoad.parityLessons - }); - - const warnings = []; - if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`); - if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`); - if (warnings.length) { - showAlert('workload-alert', `Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`, 'error'); - } - } - - async function loadSlotsForDates(dates) { - const responses = await Promise.allSettled( - dates.map(date => api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`)) - ); - 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 - }; - } - - async function loadEntityLessons(range, entityId) { - if (!groups.length) { - return { - lessons: [], - lessonsByDateSlot: new Map(), - parityLessons: [], - failedLoads: 0 - }; - } - - const responses = await Promise.allSettled( - groups.map(group => api.get(`/api/schedule?${scheduleParams(group, range.startDate, range.endDate)}`)) - ); - const parityLessons = responses - .filter(response => response.status === 'fulfilled') - .flatMap(response => response.value || []); - const lessons = uniqueLessons(parityLessons) - .filter(lesson => entityIdsForLesson(lesson).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 - }; - } - - function scheduleParams(group, startDate, endDate) { - return new URLSearchParams({ - groupId: String(group.id), - startDate, - endDate - }).toString(); - } - - function populateFilters() { - renderCheckboxes( - 'building-checkboxes', - buildingOptions(), - item => item.value, - item => item.label - ); - renderCheckboxes( - 'equipment-checkboxes', - equipmentOptions(), - item => item.id, - item => item.name - ); - - updateFilterText('building-checkboxes', 'building-text', 'Все корпуса'); - updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); - } - - function populateDisplayOptions() { - if (!displaySelect) return; - const selectedValue = displaySelect.value; - const target = targetConfig(); - displaySelect.innerHTML = ''; - displaySelect.append(new Option(target.allLabel, DISPLAY_ALL)); - allEntities().forEach(entity => { - displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`)); - }); - if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) { - displaySelect.value = selectedValue; - } - } - - function applyDisplaySelection() { - const value = displaySelect?.value || DISPLAY_ALL; - selectedRoomId = null; - selectedEntityId = null; - const target = targetConfig(); - if (value.startsWith(target.prefix)) { - currentView = 'detail'; - selectedEntityId = value.slice(target.prefix.length); - if (currentTarget === TARGET_CLASSROOMS) { - selectedRoomId = selectedEntityId; - } - return; - } - currentView = DISPLAY_ALL; - } - - function syncDisplayMode() { - const roomMode = currentView !== DISPLAY_ALL; - overviewFilters.forEach(filter => { - filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS; - }); - if (overviewBlock) overviewBlock.hidden = roomMode; - if (roomBlock) roomBlock.hidden = !roomMode; - if (dateLabel) { - dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата'; - } - } - - function selectedEntity() { - const id = selectedEntityId || selectedRoomId; - if (!id) return null; - return allEntities().find(entity => sameId(entity.id, id)) || null; - } - - function targetConfig() { - return WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS]; - } - - function allEntities() { - if (currentTarget === TARGET_TEACHERS) return teachers; - if (currentTarget === TARGET_DEPARTMENTS) return departments; - return classrooms; - } - - function visibleEntities() { - const source = currentTarget === TARGET_CLASSROOMS ? filteredClassrooms() : allEntities(); - if (!selectedEntityId) return source; - return source.filter(entity => sameId(entity.id, selectedEntityId)); - } - - function entityOptionLabel(entity) { - if (currentTarget === TARGET_TEACHERS) { - const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); - return details ? `${userName(entity)} · ${details}` : userName(entity); - } - if (currentTarget === 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; - } - - function buildingOptions() { - 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)); - } - - function equipmentOptions() { - 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)); - } - - function renderCheckboxes(containerId, items, valueFn, labelFn) { - const container = document.getElementById(containerId); - if (!container) return; - if (!items.length) { - container.innerHTML = '
Нет данных
'; - return; - } - container.innerHTML = items.map(item => ` - - `).join(''); - } - - 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()); - } - - function groupLessonsByEntitySlot(lessons) { - const grouped = new Map(); - uniqueLessons(lessons).forEach(lesson => { - entityIdsForLesson(lesson).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; - } - - function entityIdsForLesson(lesson) { - if (currentTarget === TARGET_TEACHERS) { - return lesson.teacherId ? [lesson.teacherId] : []; - } - if (currentTarget === TARGET_DEPARTMENTS) { - return departmentIdsForLesson(lesson); - } - return lesson.classroomId ? [lesson.classroomId] : []; - } - - function departmentIdsForLesson(lesson) { - const departmentId = teacherDepartmentId(lesson.teacherId); - return departmentId == null ? [] : [departmentId]; - } - - function renderGrid() { - const rows = visibleEntities(); - const target = targetConfig(); - renderHeader(); - tbody.innerHTML = ''; - - if (!rows.length) { - tbody.innerHTML = `${escapeHtml(target.emptyLabel)}`; - renderSummary(0, 0, 0); - return; - } - - rows.forEach(entity => { - const tr = document.createElement('tr'); - const entityCell = document.createElement('td'); - entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`; - entityCell.innerHTML = entityAxisHtml(entity); - tr.appendChild(entityCell); - - timeSlots.forEach(slot => { - const td = document.createElement('td'); - const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []; - td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell'; - td.innerHTML = lessons.length - ? lessons.map(renderLessonCard).join('') - : 'Свободно'; - tr.appendChild(td); - }); - - tbody.appendChild(tr); - }); - - const occupiedCells = countOccupiedCells(rows); - renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length); - } - - function renderHeader() { - const target = targetConfig(); - headerRow.innerHTML = ` - - Время - ${escapeHtml(target.rowLabel)} - - `; - - timeSlots.forEach(slot => { - const th = document.createElement('th'); - th.innerHTML = ` - ${escapeHtml(slot.orderNumber || '')} пара - ${escapeHtml(timeSlotLabel(slot))} - ${slot.scopeApplyMode === 'DEFAULT' ? '' : `${escapeHtml(scopeShort(slot))}`} - `; - headerRow.appendChild(th); - }); - } - - function entityAxisHtml(entity) { - if (currentTarget === TARGET_TEACHERS) { - return ` - ${escapeHtml(userName(entity))} - ${escapeHtml(entity.jobTitle || 'Должность не указана')} - ${entity.departmentName ? `${escapeHtml(entity.departmentName)}` : ''} - `; - } - if (currentTarget === TARGET_DEPARTMENTS) { - return ` - ${escapeHtml(departmentName(entity))} - ${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'} - `; - } - return ` - ${escapeHtml(entity.name)} - ${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'} - ${entity.building ? `${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}` : ''} - `; - } - - function entityName(entity) { - if (currentTarget === TARGET_TEACHERS) return userName(entity); - if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity); - return entity?.name || 'Аудитория без названия'; - } - - function entitySummaryDetails(entity) { - if (currentTarget === TARGET_TEACHERS) { - return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', '); - } - if (currentTarget === TARGET_DEPARTMENTS) { - return entity.departmentCode ? `код ${entity.departmentCode}` : ''; - } - return [ - entity.capacity ? `${entity.capacity} мест` : null, - entity.building || null, - entity.floor ? `${entity.floor} этаж` : null - ].filter(Boolean).join(', '); - } - - function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) { - const slotRows = buildSlotRows(dates, slotsByDate, lessons); - const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons); - renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates); - - if (!slotRows.length) { - roomTables.innerHTML = '
За выбранный период нет временных слотов
'; - return; - } - - roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot); - } - - function renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) { - if (!roomSummary) return; - const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot); - const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot); - const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; - const details = entitySummaryDetails(entity); - const warnings = []; - if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`); - if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`); - if (unknownParityDates) warnings.push('часть дат вне семестра'); - - roomSummary.innerHTML = ` -
- ${escapeHtml(entityName(entity))} - ${details ? `${escapeHtml(details)}` : ''} -
- ${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))} - Занято: ${occupiedCells} из ${totalCells} (${percent}%) - ${warnings.length ? `${escapeHtml(warnings.join(', '))}` : ''} - `; - } - - function renderRoomCombinedWeekTable(datesByDay, slotRows, slotsByDate, lessonsByDateSlot) { - const dayHeaders = DAY_ORDER.map(day => ` - - ${escapeHtml(dayShort(day))} - ${escapeHtml(DAY_FULL_LABELS[day] || '')} -
- ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(datesByDay[day]?.[parity], label)).join('')} -
- - `).join(''); - const rows = slotRows.map(row => ` - - - ${escapeHtml(row.orderLabel)} - ${escapeHtml(row.timeLabel)} - - ${DAY_ORDER.map(day => renderRoomCombinedSlotCell(datesByDay[day] || {}, row, slotsByDate, lessonsByDateSlot)).join('')} - - `).join(''); - - return ` -
-
-

${escapeHtml(targetConfig().periodTitle)}

- ${escapeHtml(roomWeekCaption(datesByDay))} -
-
- - - - - ${dayHeaders} - - - - ${rows} - -
Время
-
-
- `; - } - - function renderDayParityDate(date, label) { - return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; - } - - function renderRoomCombinedSlotCell(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)) { - return `${renderRoomUnifiedSlot(states[0].state)}`; - } - - const halves = states.map(({ parity, label, state }) => - renderRoomSlotHalf(state, parity, label) - ).join(''); - return `${halves}`; - } - - 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: [] }; - } - - function renderRoomUnifiedSlot(state) { - if (state.kind === 'busy') { - return `
${state.lessons.map(renderLessonCard).join('')}
`; - } - if (state.kind === 'missing') { - return '
Нет слота
'; - } - if (state.kind === 'empty') { - return '
Нет даты
'; - } - return '
Свободно
'; - } - - function renderRoomSlotHalf(state, parity, label) { - if (state.kind === 'busy') { - return ` -
- ${escapeHtml(label)} - ${state.lessons.map(renderLessonCard).join('')} -
- `; - } - if (state.kind === 'missing') { - return ` -
- ${escapeHtml(label)} - Нет слота -
- `; - } - if (state.kind === 'empty') { - return ` -
- ${escapeHtml(label)} - Нет даты -
- `; - } - return ` -
- ${escapeHtml(label)} - Свободно -
- `; - } - - 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(','); - } - - 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); - } - } - - function buildDateGridByDay(dates, lessons) { - const datesByDay = {}; - let unknownParityDates = 0; - dates.forEach((date, index) => { - let parity = parityForDate(date, lessons); - 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 }; - } - - function parityForDate(date, lessons) { - const lessonParity = lessons.find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity))?.parity; - if (lessonParity) return lessonParity; - const weekNumber = weekNumberForDate(date); - if (!weekNumber) return null; - return weekNumber % 2 === 0 ? 'EVEN' : 'ODD'; - } - - function weekNumberForDate(date) { - 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; - } - - function uniqueWeekNumbers(dates) { - return Array.from(new Set(dates.map(weekNumberForDate).filter(Boolean))) - .sort((a, b) => a - b); - } - - function roomWeekCaption(datesByDay) { - return CELL_PARITY_LAYOUT - .map(({ parity, label }) => { - const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean); - const weeks = uniqueWeekNumbers(dates); - const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра'; - const positionText = parity === 'ODD' ? 'сверху' : 'снизу'; - return `${label} ${positionText}, ${weekText}`; - }) - .join('; '); - } - - function hasSlotForDate(date, key, slotsByDate) { - return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key); - } - - function renderLessonCard(lesson) { - const subgroupText = (lesson.subgroupNames || []).join(', '); - return ` -
-
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
-
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
- ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''} -
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
-
${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}
-
- `; - } - - function filteredClassrooms() { - const selectedBuildings = selectedValues('building-checkboxes'); - const minCapacity = parseInt(minCapacityInput?.value || '0', 10) || 0; - const selectedEquipment = selectedValues('equipment-checkboxes'); - - return classrooms.filter(room => { - const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room)); - const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity; - const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id => - (room.equipments || []).some(item => String(item.id) === String(id)) - ); - return buildingMatches && capacityMatches && equipmentMatches; - }); - } - - function selectedValues(containerId) { - const container = document.getElementById(containerId); - if (!container) return []; - return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) - .map(input => input.value); - } - - function updateFilterText(containerId, textId, emptyText) { - const container = document.getElementById(containerId); - const text = document.getElementById(textId); - if (!container || !text) return; - const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked')); - if (!checked.length) { - text.textContent = emptyText; - } else if (checked.length === 1) { - text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText; - } else { - text.textContent = `Выбрано: ${checked.length}`; - } - } - - function setTableLoading(message) { - renderHeader(); - tbody.innerHTML = `${escapeHtml(message)}`; - if (summary) summary.textContent = ''; - } - - function setTableError(message) { - renderHeader(); - tbody.innerHTML = `${escapeHtml(message)}`; - showAlert('workload-alert', message, 'error'); - } - - function setRoomLoading(message) { - if (roomSummary) roomSummary.textContent = ''; - if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; - } - - function setRoomError(message) { - if (roomSummary) roomSummary.textContent = ''; - if (roomTables) roomTables.innerHTML = `
${escapeHtml(message)}
`; - showAlert('workload-alert', message, 'error'); - } - - function renderSummary(entityCount, occupiedCells, totalCells) { - if (!summary) return; - const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0; - const date = dateInput?.value ? formatDate(dateInput.value) : '-'; - const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-'; - const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : ''; - const selected = selectedEntityId ? ', выбран один объект' : ''; - summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`; - } - - function countOccupiedCells(entities) { - return entities.reduce((count, entity) => count + timeSlots.filter(slot => - (lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0 - ).length, 0); - } - - 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); - } - - function countOccupiedRoomCells(lessonsByDateSlot) { - return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length; - } - - function entitySlotKey(entityId, timeSlotId) { - return `${entityId}:${timeSlotId}`; - } - - function dateSlotKey(date, slotKey) { - return `${date}:${slotKey}`; - } - - function slotOrderKey(slot) { - const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id; - return value == null ? null : String(value); - } - - function lessonSlotKey(lesson) { - const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId; - return value == null ? null : String(value); - } - - function buildingKey(room) { - return room.building ? String(room.building) : NO_BUILDING; - } - - function teacherDepartmentId(teacherId) { - 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; - } - - function userName(user) { - return user?.fullName || user?.username || 'Преподаватель без имени'; - } - - function departmentName(department) { - return department?.departmentName || department?.name || 'Кафедра без названия'; - } - - 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))); - } - - function timeSlotLabel(slot) { - return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`; - } - - function selectedIsoDay() { - if (!dateInput?.value) return null; - return isoDay(dateInput.value); - } - - function scopeShort(slot) { - if (slot.scopeApplyMode === 'WEEKDAY') { - return dayShort(slot.scopeDayOfWeek); - } - return 'Ручн.'; - } - - function dayShort(dayOfWeek) { - return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; - } - - function isoDay(value) { - const day = parseIsoDate(value).getDay(); - return day === 0 ? 7 : day; - } - - function twoWeekRange(value) { - const selected = parseIsoDate(value); - const start = addDays(selected, 1 - isoDay(value)); - const end = addDays(start, 13); - return { - startDate: formatIsoDate(start), - endDate: formatIsoDate(end) - }; - } - - function datesBetween(startDate, endDate) { - const dates = []; - for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) { - dates.push(formatIsoDate(date)); - } - return dates; - } - - 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 trimTime(value) { - return value ? String(value).slice(0, 5) : ''; - } - - function formatIsoDate(date) { - return [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0') - ].join('-'); - } - - function formatDate(value) { - const [year, month, day] = String(value).split('-'); - return `${day}.${month}.${year}`; - } - - function formatDateShort(value) { - const [, month, day] = String(value).split('-'); - return `${day}.${month}`; - } - - function compareSlots(a, b) { - return Number(a.orderNumber || 0) - Number(b.orderNumber || 0) - || naturalCompare(a.startTime, b.startTime); - } - - 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); - } - - function sameId(first, second) { - return String(first) === String(second); - } - - function naturalCompare(a, b) { - return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); - } -} diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js deleted file mode 100755 index 2200a4e..0000000 --- a/frontend/admin/js/views/classrooms.js +++ /dev/null @@ -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 = '

Оборудование отсутствует

'; - updateSelectText(containerId, selectTextId); - return; - } - - container.innerHTML = equipments.map(eq => ` - - `).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 = 'Ошибка загрузки данных'; - } - } - - async function loadClassrooms() { - try { - const classrooms = await api.get('/api/classrooms?includeArchived=true'); - renderClassrooms(classrooms); - } catch (e) { - classroomsTbody.innerHTML = 'Ошибка загрузки'; - } - } - - function renderClassrooms(classrooms) { - if (!classrooms || !classrooms.length) { - classroomsTbody.innerHTML = 'Нет аудиторий'; - return; - } - classroomsTbody.innerHTML = classrooms.map(c => { - const equipHtml = c.equipments && c.equipments.length - ? c.equipments.map(eq => escapeHtml(eq.name)).join(', ') - : '—'; - - return ` - - ${c.id} - ${escapeHtml(c.name)} - ${c.capacity} чел. - ${equipHtml} - -
- - ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} - - -
- - -
- - ${c.status === 'ARCHIVED' - ? `` - : ``} -
- - `; - }).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 = 'Загрузка...'; - try { - allEquipments = await fetchEquipments(); - renderManageEquipmentsTable(); - // Обновим чекбоксы на основной странице - renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text'); - } catch (e) { - manageEquipmentTbody.innerHTML = 'Ошибка загрузки'; - } - } - - function renderManageEquipmentsTable() { - if (!allEquipments || !allEquipments.length) { - manageEquipmentTbody.innerHTML = 'Нет оборудования'; - return; - } - manageEquipmentTbody.innerHTML = allEquipments.map(eq => ` - - ${eq.id} - ${escapeHtml(eq.name)} - - - `).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(); -} diff --git a/frontend/admin/js/views/dashboard.js b/frontend/admin/js/views/dashboard.js deleted file mode 100644 index bf45dc0..0000000 --- a/frontend/admin/js/views/dashboard.js +++ /dev/null @@ -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 = 'В данный момент учебные пары не проводятся'; - - // Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные) - renderFreeClassrooms(null); - } - } - - function renderCurrentLessons(slotOrder) { - const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder); - if (!activeLessons.length) { - currentLessonsTbody.innerHTML = 'В текущий слот занятий не запланировано'; - return; - } - - currentLessonsTbody.innerHTML = activeLessons.map(l => ` - - ${escapeHtml(l.classroomName)} - ${escapeHtml(l.subjectName)} ${escapeHtml(l.lessonTypeName)} - ${escapeHtml(l.teacherName)} - ${escapeHtml(l.groupNames.join(', '))} - - `).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 = 'Все аудитории заняты'; - return; - } - - freeClassroomsList.innerHTML = free.map(c => ` - - ${escapeHtml(c.name)} (${c.capacity} м.) - - `).join(''); - } - - // --- Анализ конфликтов (Red Zone) --- - async function checkConflicts() { - conflictsContainer.setAttribute('aria-busy', 'true'); - conflictsContainer.innerHTML = '
Анализ расписания и выявление конфликтов...
'; - - 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: `Преподаватель ${escapeHtml(first.teacherName)} назначен одновременно в разные места: ${escapeHtml(listStr)} на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, - 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: `Аудитория ${escapeHtml(first.classroomName)} занята одновременно разными занятиями: ${escapeHtml(details)} на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, - 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: `Вместимость аудитории ${escapeHtml(first.classroomName)} составляет ${capacity} чел., однако на занятие назначено ${totalStudents} человек (группы: ${escapeHtml(groupNamesList)}) на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, - 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 - ? `` - : ``; - - return ` -
-
${badgeIcon}
-
-

${escapeHtml(c.title)}

-

${escapeHtml(c.description)}

-
-
- `; - }).join(''); - conflictsContainer.innerHTML = stateCards.join('') + conflictCards; - } - - function renderCheckStateCard({ tone, title, description, meta = '' }) { - const icon = tone === 'success' - ? '' - : tone === 'warning' - ? '' - : ''; - return ` -
- - - ${escapeHtml(title)} - ${escapeHtml(description)} - ${meta ? `${escapeHtml(meta)}` : ''} - -
- `; - } - - // Загружаем данные при старте - await loadData(); - - // Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени) - const realtimeTimer = setInterval(() => { - if (document.getElementById('current-time-slot')) { - updateRealtimeMonitoring(); - } else { - clearInterval(realtimeTimer); - } - }, 30000); -} diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js deleted file mode 100644 index 2e301e2..0000000 --- a/frontend/admin/js/views/department-workspace.js +++ /dev/null @@ -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 = 'Загрузка...'; - subjectCount.textContent = 'Загрузка...'; - try { - const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`); - subjectCount.textContent = subjectCountLabel(subjects.length); - if (!subjects.length) { - subjectsTbody.innerHTML = 'Дисциплины не найдены'; - return; - } - subjectsTbody.innerHTML = subjects.map(subject => ` - - ${escapeHtml(subject.code || '-')} - ${escapeHtml(subject.name || '-')} - ${escapeHtml(subject.description || '-')} - - - - - `).join(''); - } catch (error) { - subjectsTbody.innerHTML = `${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}`; - subjectCount.textContent = '0 дисциплин'; - } - } - - function renderImportRows(rows) { - if (!importRowsContainer) return; - importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => ` -
-
- - -
-
- - -
- -
- `).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 => ` -
- ${escapeHtml(comment.authorName || 'Автор не указан')} - ${escapeHtml(formatDateTime(comment.createdAt))} -

${escapeHtml(comment.comment)}

-
- `).join('') - : '
Комментариев пока нет
'; - } catch (error) { - commentsList.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки комментариев')}
`; - } - } - - 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 = ``; - } - } - } - - 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 = `${escapeHtml(error.message || 'Ошибка загрузки заявок')}`; - } - 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 = `
${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}
`; - } - - function renderTeacherSelect() { - if (!assignTeacherSelect) return; - if (lastTeachers === null) { - assignTeacherSelect.innerHTML = ''; - return; - } - const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id))); - const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id))); - assignTeacherSelect.innerHTML = '' + - availableTeachers.map(teacher => ` - - `).join(''); - if (!availableTeachers.length) { - assignTeacherSelect.innerHTML = ''; - } - } - - function renderTeacherRequests() { - if (!teacherRequestsTbody) return; - if (teacherRequests === null) { - teacherRequestsTbody.innerHTML = 'Загрузка...'; - if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...'; - return; - } - if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length); - if (!teacherRequests.length) { - teacherRequestsTbody.innerHTML = 'Заявок пока нет'; - return; - } - teacherRequestsTbody.innerHTML = teacherRequests.map(request => ` - - ${escapeHtml(requestStatusLabel(request.status))} - ${escapeHtml(request.username || '-')} - ${escapeHtml(request.fullName || '-')} - ${escapeHtml(request.jobTitle || '-')} - ${escapeHtml(request.comment || '-')} - ${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')} - - `).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 = `` + - (items || []).map(item => ``).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 ` -
- ${escapeHtml(item.name || '-')} - ${escapeHtml(item.meta || 'Должность не указана')} - ${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))} -
- `; -} - -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; -} diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js deleted file mode 100644 index 34b7d3f..0000000 --- a/frontend/admin/js/views/groups.js +++ /dev/null @@ -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 = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; - } - } - - 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 => ` - - ${group.id} - ${escapeHtml(group.name)} - ${escapeHtml(String(group.groupSize))} - ${escapeHtml(group.educationFormName)} - ${escapeHtml(departmentLabel(group.departmentId))} - ${group.course || '-'} - ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} - ${escapeHtml(group.specialtyProfileName || '-')} - ${escapeHtml(statusLabel(group))} - -
- - - - ${group.status === 'ARCHIVED' - ? `` - : ``} -
- - - `).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 = ` - - ${forms.map(form => { - const id = String(form.id); - return ` - - `; - }).join('')} - `; - updateFilterEfText(); - } - - function populateEfSelect(select, forms) { - const currentVal = select.value; - select.innerHTML = forms.map(form => ``).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 = '' + - items.map(department => { - const name = department.departmentName || department.name || 'Без названия'; - const code = department.departmentCode || department.code || department.id; - return ``; - }).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 = '' + - items.map(speciality => ``).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 - ? '' + profiles.map(profile => - `` - ).join('') - : ''; - if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { - select.value = selectedProfileId; - } - syncSelects(select); - } - - function populateYearSelect() { - manageCalendarYearSelect.innerHTML = '' + - academicYears.map(year => ``).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 = 'Загрузка подгрупп...'; - try { - await loadSubgroups(group.id); - resetSubgroupForm(); - renderSubgroupsList(group.id); - } catch (error) { - showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error'); - manageSubgroupsTbody.innerHTML = 'Не удалось загрузить подгруппы'; - } - } - - function renderSubgroupsList(groupId) { - const list = subgroups.filter(sub => String(sub.groupId) === String(groupId)); - if (!list.length) { - manageSubgroupsTbody.innerHTML = 'Подгруппы не созданы'; - return; - } - const canDeleteSingleSubgroup = list.length === 1; - const deleteTitle = canDeleteSingleSubgroup - ? 'Удалить подгруппу' - : 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»'; - manageSubgroupsTbody.innerHTML = list.map(sub => ` - - ${escapeHtml(sub.name)} - ${escapeHtml(String(sub.studentCapacity ?? '-'))} чел. - - - - - - `).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 ` -
- - -
- `; - }).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 = 'Загрузка...'; - try { - currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); - renderAssignmentsList(); - } catch (error) { - manageCalendarTbody.innerHTML = `Ошибка: ${escapeHtml(error.message)}`; - } - } - - function renderAssignmentsList() { - if (!currentAssignments.length) { - manageCalendarTbody.innerHTML = 'Календарные графики не назначены'; - return; - } - manageCalendarTbody.innerHTML = currentAssignments.map(assignment => ` - - ${escapeHtml(assignment.academicYearTitle)} - ${escapeHtml(assignmentCalendarTitle(assignment))} - ${renderAssignmentSubjects(assignment.subjects || [])} - - - - - `).join(''); - } - - function assignmentCalendarTitle(assignment) { - return assignment.calendarTitle || '-'; - } - - function renderAssignmentSubjects(subjects) { - if (!subjects.length) { - return 'Не привязаны'; - } - 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 `
${Array.from(grouped.entries()) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([semesterNumber, items]) => ` -
- ${escapeHtml(semesterNumber)} семестр - ${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')} -
- `).join('')}
`; - } - - 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 })); - }); - } -} diff --git a/frontend/admin/js/views/schedule-override-panel.js b/frontend/admin/js/views/schedule-override-panel.js deleted file mode 100644 index ac1723f..0000000 --- a/frontend/admin/js/views/schedule-override-panel.js +++ /dev/null @@ -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 ` - - `; - } - - async function loadTimeSlots(preference) { - timeSelect.disabled = true; - timeSelect.innerHTML = ''; - 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 = '' + state.timeSlots.map(slot => - `` - ).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 = '
За выбранный период разовых изменений нет.
'; - 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 ` -
-
- ${escapeHtml(ACTION_LABELS[override.action] || override.action)} - -
-

${escapeHtml(override.subjectName || 'Занятие')}

-

${escapeHtml([ - override.lessonTypeName, - (override.groupNames || []).join(', '), - result || 'Параметры изменены' - ].filter(Boolean).join(' · '))}

- ${override.comment ? `
${escapeHtml(override.comment)}
` : ''} - -
- `; - }).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 }, () => '').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 ``; - }).join(''); - return ` -
-

${MONTH_LABELS[monthIndex]} ${year}

-
- ${DAY_LABELS.map(label => `${label}`).join('')} - ${blanks}${days} -
-
- `; -} - -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('-'); -} diff --git a/frontend/admin/js/views/schedule-quality.js b/frontend/admin/js/views/schedule-quality.js deleted file mode 100644 index 99eb8cf..0000000 --- a/frontend/admin/js/views/schedule-quality.js +++ /dev/null @@ -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 = ''; - 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 => ``).join('') - : ''; - yearSelect.value = qualityState.academicYearId; - - const yearSemesters = semestersForAcademicYear(qualityState.semesters, qualityState.academicYearId); - semesterSelect.innerHTML = yearSemesters.length - ? yearSemesters.map(semester => ` - - `).join('') - : ''; - 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 = ''; - try { - qualityState.versions = await api.get( - `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}` - ); - select.innerHTML = qualityState.versions.map(version => ` - - `).join(''); - if (!qualityState.versions.length) { - select.innerHTML = ''; - 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 = ''; - 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 = ` - ${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))} - ${escapeHtml(String(analysis.lessonCount))} занятий - правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))} - штраф ${escapeHtml(String(analysis.rawPenalty))} - `; - qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || []) - .map((metric, index) => ` -
- ${String(index + 1).padStart(2, '0')} - ${escapeHtml(formatMetricValue(metric))} -

${escapeHtml(metric.label)}

-

${escapeHtml(metric.explanation)}

- штраф ${escapeHtml(String(metric.penalty))} -
- `).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 = ` -
- По выбранному фильтру проблем нет - Измените уровень важности или категорию. -
- `; - 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 ` -
- -
- ${canEdit - ? `` - : 'Только просмотр'} -
-
- `; - }).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 = ` -
- Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))} -

${escapeHtml(problem.subjectName || problem.title)}

-

Затронуто ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))}; суммарный штраф −${escapeHtml(String(problem.penalty))}.

-
-
- ${issueLabels.map(label => `${escapeHtml(label)}`).join('')} -
- ${canEdit ? ` - - ` : '

Архивное расписание нельзя изменить.

'} - `; -} - -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 = ` -
- ↗ -

${escapeHtml(title)}

-

${escapeHtml(message)}

-
- `; -} - -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] || 'Версия'; -} diff --git a/frontend/admin/js/views/schedule-versions.js b/frontend/admin/js/views/schedule-versions.js deleted file mode 100644 index 421924c..0000000 --- a/frontend/admin/js/views/schedule-versions.js +++ /dev/null @@ -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 => ``).join('') - : ''; - yearSelect.value = versionState.academicYearId; - - const yearSemesters = semestersForAcademicYear(versionState.semesters, versionState.academicYearId); - semesterSelect.innerHTML = yearSemesters.length - ? yearSemesters.map(semester => ` - - `).join('') - : ''; - 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 = ` -
ПУСТО
-

Опубликованной версии нет

Создайте первый черновик и опубликуйте его после проверки.

- `; - return; - } - container.innerHTML = ` -
LIVE
-
- Сейчас видят пользователи -

${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}

-

${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}

-
- ${escapeHtml(String(version.ruleCount))} правил - ${escapeHtml(String(version.slotCount))} слотов - ${escapeHtml(formatDateTime(version.publishedAt))} -
-
-
- - -
- `; -} - -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 = ` -
- +

Нет активных черновиков

-

Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.

-
- `; - return; - } - container.innerHTML = drafts.map(version => ` -
-
-
- Черновик · v${escapeHtml(String(version.versionNumber))} -

${escapeHtml(version.name)}

-
-
-

Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}

- ${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))} -
- - - - -
-
- `).join(''); -} - -function renderArchive(versions) { - const container = versionState.root.querySelector('#version-archive'); - if (!versions.length) { - container.innerHTML = '

Предыдущих публикаций пока нет.

'; - return; - } - container.innerHTML = versions.map(version => ` -
- Архив · v${escapeHtml(String(version.versionNumber))} -

${escapeHtml(version.name)}

-

${escapeHtml(version.changeReason || 'Причина не указана')}

- ${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))} -
- - - -
-
- `).join(''); -} - -function renderHistory(history) { - const container = versionState.root.querySelector('#version-history'); - if (!history.length) { - container.innerHTML = '

Действия ещё не зафиксированы.

'; - return; - } - container.innerHTML = history.map(item => ` -
- -
- ${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)} -

${escapeHtml(item.reason || 'Без комментария')}

-
- ${escapeHtml(item.actorName || 'Система')} -
- `).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 = '
…

Сравниваем расписания

Генерируем занятия обеих версий по всему семестру.

'; - try { - const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`); - renderDiff(diff); - } catch (error) { - panel.innerHTML = `
!

Сравнение недоступно

${escapeHtml(error.message)}

`; - } -} - -function renderDiff(diff) { - const panel = versionState.root.querySelector('#version-diff'); - panel.innerHTML = ` - ${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)} -

Что изменится после публикации

-
-
+${escapeHtml(String(diff.addedLessonCount))}занятий
-
${escapeHtml(String(diff.changedLessonCount))}изменено
-
−${escapeHtml(String(diff.removedLessonCount))}удалено
-
-

Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}

-
- ${(diff.ruleChanges || []).length - ? diff.ruleChanges.map(ruleChangeCard).join('') - : '

Правила совпадают

Структурных изменений между версиями нет.

'} -
- `; -} - -function ruleChangeCard(change) { - const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type; - return ` -
- ${escapeHtml(label)} · ${escapeHtml(change.subjectName)} - ${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')} - ${change.beforeSummary ? `Было: ${escapeHtml(change.beforeSummary)}` : ''} - ${change.afterSummary ? `Стало: ${escapeHtml(change.afterSummary)}` : ''} -
- `; -} - -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}`; -} diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js deleted file mode 100644 index 7001c91..0000000 --- a/frontend/admin/js/views/schedule-view.js +++ /dev/null @@ -1,1525 +0,0 @@ -import { api, getSession } from '../api.js'; -import { - academicYearsFromSemesters, - resolveAcademicPeriod, - semestersForAcademicYear -} from '../academic-period.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; -import { preferredScheduleSemester } from '../schedule-period.js'; -import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js'; -import { AsyncCombobox } from '../async-combobox.js'; -import { readUrlState, writeUrlState } from '../url-state.js'; -import { trackUxEvent } from '../../../telemetry.js'; - -const ROLE_DEPARTMENT = 'DEPARTMENT'; -const TARGET_GROUPS = 'groups'; -const TARGET_TEACHERS = 'teachers'; -const TARGET_CLASSROOMS = 'classrooms'; -const TARGET_DEPARTMENTS = 'departments'; -const PARITY_LABELS = { - BOTH: 'каждая', - ODD: 'нечётная', - EVEN: 'чётная' -}; -const DAY_SHORT_LABELS = { - 1: 'Пн', - 2: 'Вт', - 3: 'Ср', - 4: 'Чт', - 5: 'Пт', - 6: 'Сб', - 7: 'Вс' -}; -const DAY_FULL_LABELS = { - 1: 'Понедельник', - 2: 'Вторник', - 3: 'Среда', - 4: 'Четверг', - 5: 'Пятница', - 6: 'Суббота', - 7: 'Воскресенье' -}; -const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; -const CELL_PARITY_LAYOUT = [ - { parity: 'ODD', label: 'Нечётная' }, - { parity: 'EVEN', label: 'Чётная' } -]; -const MOBILE_QUERY = '(max-width: 760px)'; -const DEFAULT_SEMESTER_WEEKS = 22; -const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание'; - -export async function initScheduleView() { - const session = getSession(); - const role = session?.role; - const userDepartmentId = normalizeId(session?.departmentId); - const dateInput = document.getElementById('schedule-view-date'); - const targetSelect = document.getElementById('schedule-view-target'); - const loadButton = document.getElementById('schedule-view-load'); - const previousPeriodButton = document.getElementById('schedule-view-prev-period'); - const todayButton = document.getElementById('schedule-view-today'); - const nextPeriodButton = document.getElementById('schedule-view-next-period'); - const resultPreviousButton = document.getElementById('schedule-view-result-prev'); - const resultNextButton = document.getElementById('schedule-view-result-next'); - const resultTabs = document.getElementById('schedule-view-result-tabs'); - const activeLabel = document.getElementById('schedule-view-active-label'); - const countLabel = document.getElementById('schedule-view-count'); - const periodLabel = document.getElementById('schedule-view-period-label'); - const tableTitle = document.getElementById('schedule-view-table-title'); - const tableCaption = document.getElementById('schedule-view-table-caption'); - const dayTabs = document.getElementById('schedule-view-day-tabs'); - const tables = document.getElementById('schedule-view-tables'); - const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); - const departmentSelect = document.getElementById('schedule-view-department'); - const academicYearSelect = document.getElementById('schedule-view-year'); - const semesterSelect = document.getElementById('schedule-view-semester'); - const restored = readUrlState({ - scheduleTarget: TARGET_GROUPS, - scheduleEntity: '', - scheduleDate: '', - scheduleYear: '', - scheduleSemester: '', - scheduleSubject: '', - scheduleLessonType: '', - scheduleParity: '' - }); - - const state = { - dictionaries: { - groups: [], - teachers: [], - classrooms: [], - departments: [] - }, - sections: [], - activeSectionIndex: 0, - mobileDay: 1, - range: null, - totalLessons: 0, - lessons: [], - overrides: [] - }; - let overridePanel = createEmptyOverridePanel(); - const mobileMedia = window.matchMedia(MOBILE_QUERY); - - setDefaultDate(dateInput); - if (restored.scheduleDate) dateInput.value = restored.scheduleDate; - if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget; - restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity); - const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId); - configureDepartmentTarget(); - updatePeriodLabel(); - updateEntityFieldVisibility(); - renderResultNavigation(); - - targetSelect?.addEventListener('change', () => { - clearInactiveEntityValues(); - updateEntityFieldVisibility(); - resetResults(SCHEDULE_REFRESH_PROMPT); - persistFilters(); - }); - loadButton?.addEventListener('click', loadData); - previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); - todayButton?.addEventListener('click', () => { - dateInput.value = toIsoDate(new Date()); - loadData(); - }); - nextPeriodButton?.addEventListener('click', () => movePeriod(1)); - dateInput?.addEventListener('change', () => { - updatePeriodLabel(); - resetResults(SCHEDULE_REFRESH_PROMPT); - persistFilters(); - }); - academicYearSelect?.addEventListener('change', () => { - const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value); - const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value }); - renderPeriodSelects(academicYearSelect.value, selected.semesterId); - synchronizeDateWithSemester(); - updatePeriodLabel(); - resetResults(SCHEDULE_REFRESH_PROMPT); - persistFilters(); - }); - semesterSelect?.addEventListener('change', () => { - synchronizeDateWithSemester(); - updatePeriodLabel(); - resetResults(SCHEDULE_REFRESH_PROMPT); - persistFilters(); - }); - resultPreviousButton?.addEventListener('click', () => moveResult(-1)); - resultNextButton?.addEventListener('click', () => moveResult(1)); - tables?.addEventListener('click', event => { - const reloadButton = event.target.closest('[data-schedule-view-reload]'); - if (reloadButton) { - loadData(); - return; - } - const button = event.target.closest('[data-schedule-override-edit]'); - if (!button) return; - const lesson = state.lessons.find(item => - String(item.scheduleRuleSlotId) === button.dataset.baseRuleSlotId - && (item.originalLessonDate || item.date) === button.dataset.lessonDate - ); - if (lesson) overridePanel.openLesson(lesson); - }); - if (typeof mobileMedia.addEventListener === 'function') { - mobileMedia.addEventListener('change', renderActiveSection); - } else if (typeof mobileMedia.addListener === 'function') { - mobileMedia.addListener(renderActiveSection); - } - - try { - Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId)); - mergeLoadedDictionaries(state.dictionaries, entityComboboxes); - restoreSimpleFilters(restored); - selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester); - overridePanel = createScheduleOverridePanel({ - role, - dictionaries: state.dictionaries, - getRange: () => state.range, - onChanged: loadData - }); - configureDepartmentAccess(); - updateEntityFieldVisibility(); - await loadData(); - } catch (error) { - const message = error.message || 'Ошибка загрузки справочников'; - showAlert('schedule-view-alert', message, 'error'); - resetResults(message); - } - - ['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department', - 'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => { - document.getElementById(id)?.addEventListener('change', persistFilters); - }); - - function configureDepartmentTarget() { - if (role !== ROLE_DEPARTMENT) return; - targetSelect.value = TARGET_DEPARTMENTS; - targetSelect.disabled = true; - } - - function configureDepartmentAccess() { - if (role !== ROLE_DEPARTMENT) return; - targetSelect.value = TARGET_DEPARTMENTS; - targetSelect.disabled = true; - if (departmentSelect) { - departmentSelect.value = userDepartmentId || ''; - departmentSelect.disabled = true; - if (userDepartmentId && departmentSelect.value !== userDepartmentId) { - departmentSelect.insertAdjacentHTML('afterbegin', - ``); - departmentSelect.value = userDepartmentId; - } - } - } - - function updateEntityFieldVisibility() { - const target = activeTarget(); - entityFields.forEach(field => { - field.hidden = field.dataset.scheduleViewTargetField !== target; - }); - } - - function clearInactiveEntityValues() { - const target = activeTarget(); - [ - [TARGET_GROUPS, 'schedule-view-group'], - [TARGET_TEACHERS, 'schedule-view-teacher'], - [TARGET_CLASSROOMS, 'schedule-view-classroom'], - [TARGET_DEPARTMENTS, 'schedule-view-department'] - ].forEach(([fieldTarget, id]) => { - if (fieldTarget === target) return; - if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return; - if (entityComboboxes[fieldTarget]) { - entityComboboxes[fieldTarget].clear(); - } else { - const select = document.getElementById(id); - if (select) select.value = ''; - } - }); - } - - function activeTarget() { - if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS; - return targetSelect?.value || TARGET_GROUPS; - } - - async function movePeriod(direction) { - if (!dateInput.value) { - dateInput.value = toIsoDate(new Date()); - } - const range = twoWeekRange(dateInput.value); - const nextDate = addDays(parseIsoDate(range.startDate), direction * 14); - dateInput.value = toIsoDate(nextDate); - await loadData(); - } - - async function loadData() { - hideAlert('schedule-view-alert'); - - if (!dateInput.value) { - const message = 'Выберите дату в периоде'; - showAlert('schedule-view-alert', message, 'error'); - resetResults(message); - return; - } - - if (role === ROLE_DEPARTMENT && !userDepartmentId) { - const message = 'Не удалось определить кафедру пользователя'; - showAlert('schedule-view-alert', message, 'error'); - resetResults(message); - return; - } - - const target = activeTarget(); - const range = scheduleRangeForSemester(dateInput.value, selectedSemester()); - const previousKey = state.sections[state.activeSectionIndex]?.key; - state.range = range; - updatePeriodLabel(range); - setTableState('Загрузка...'); - setActiveSummary('Загрузка расписаний...'); - countLabel.textContent = 'Загрузка...'; - resultTabs.innerHTML = ''; - dayTabs.hidden = true; - dayTabs.innerHTML = ''; - - const params = new URLSearchParams({ - startDate: range.startDate, - endDate: range.endDate - }); - applyTargetFilter(params, target); - addOptional(params, 'subjectId', valueOf('schedule-view-subject')); - addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); - addOptional(params, 'parity', valueOf('schedule-view-parity')); - - try { - const [lessons, overrides] = await Promise.all([ - api.get(`/api/schedule/search?${params}`), - canEditScheduleOverrides(role) - ? api.get(`/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`) - : Promise.resolve([]) - ]); - state.overrides = overrides; - overridePanel.setRegistry(overrides, range); - renderLessons(lessons, range, target, previousKey); - persistFilters(); - trackUxEvent('расписание_найдено', { target, lessons: lessons.length }); - } catch (error) { - const message = error.message || 'Ошибка загрузки расписания'; - showAlert('schedule-view-alert', message, 'error'); - resetResults(message); - } - } - - function applyTargetFilter(params, target) { - if (role === ROLE_DEPARTMENT) { - params.set('departmentId', userDepartmentId); - return; - } - if (target === TARGET_GROUPS) { - addOptional(params, 'groupId', valueOf('schedule-view-group')); - return; - } - if (target === TARGET_TEACHERS) { - addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); - return; - } - if (target === TARGET_CLASSROOMS) { - addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); - return; - } - if (target === TARGET_DEPARTMENTS) { - addOptional(params, 'departmentId', valueOf('schedule-view-department')); - } - } - - function renderLessons(lessons, range, target, previousKey) { - state.lessons = lessons; - state.totalLessons = lessons.length; - if (!lessons.length) { - state.sections = []; - state.activeSectionIndex = 0; - renderResultNavigation(); - tableTitle.textContent = 'Расписание'; - tableCaption.textContent = periodCaption(range); - setTableState('Занятия не найдены'); - countLabel.textContent = '0 занятий'; - return; - } - - const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target)); - if (!sections.length) { - state.sections = []; - state.activeSectionIndex = 0; - renderResultNavigation(); - tableTitle.textContent = 'Расписание'; - tableCaption.textContent = periodCaption(range); - setTableState('Нет данных для выбранного режима просмотра'); - countLabel.textContent = lessonCountLabel(lessons.length); - return; - } - - state.sections = sections; - state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey)); - if (state.activeSectionIndex < 0) { - state.activeSectionIndex = 0; - } - state.mobileDay = preferredMobileDay(range); - renderResultNavigation(); - renderActiveSection(); - } - - function buildSectionContext(target) { - const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; - const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId); - return { - selectedGroupId, - selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', - allowedGroupIds, - groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name])) - }; - } - - function splitTypeForTarget(target) { - if (target === TARGET_TEACHERS) return TARGET_TEACHERS; - if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS; - return TARGET_GROUPS; - } - - function renderResultNavigation() { - const section = activeSection(); - const totalSections = state.sections.length; - countLabel.textContent = state.totalLessons - ? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}` - : '0 занятий'; - - resultPreviousButton.disabled = totalSections < 2; - resultNextButton.disabled = totalSections < 2; - - if (!section) { - setActiveSummary('Нет выбранного расписания'); - resultTabs.innerHTML = 'Ничего не найдено'; - return; - } - - setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`); - resultTabs.innerHTML = state.sections.map((item, index) => ` - - `).join(''); - resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => { - button.addEventListener('click', () => { - state.activeSectionIndex = Number(button.dataset.scheduleViewSection); - state.mobileDay = preferredMobileDay(state.range); - renderResultNavigation(); - renderActiveSection(); - }); - }); - } - - function setActiveSummary(text) { - activeLabel.textContent = text; - } - - function moveResult(direction) { - if (state.sections.length < 2) return; - state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length; - state.mobileDay = preferredMobileDay(state.range); - renderResultNavigation(); - renderActiveSection(); - } - - function activeSection() { - return state.sections[state.activeSectionIndex] || null; - } - - function renderActiveSection() { - const section = activeSection(); - if (!section || !state.range) { - dayTabs.hidden = true; - dayTabs.innerHTML = ''; - return; - } - - tableTitle.textContent = section.title; - tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`; - - if (mobileMedia.matches) { - renderMobileDayTabs(); - renderMobileSchedule(section, state.range); - return; - } - - dayTabs.hidden = true; - dayTabs.innerHTML = ''; - renderDesktopSchedule(section, state.range); - } - - function renderDesktopSchedule(section, range) { - const slots = collectSlots(section.lessons); - const semester = selectedSemester(); - if (semester) { - renderDesktopSemesterSchedule(section, slots, semester); - return; - } - const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); - const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); - if (!blocks.length || !slots.length) { - setTableState('Занятия не найдены'); - return; - } - tables.innerHTML = blocks - .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) - .join(''); - } - - function renderDesktopSemesterSchedule(section, slots, semester) { - if (!slots.length) { - setTableState('Занятия не найдены'); - return; - } - const lessonMatrix = buildSemesterLessonMatrix(section.lessons); - const dayHeaders = DAY_ORDER.map(day => ` - - ${escapeHtml(dayShort(day))} - ${escapeHtml(DAY_FULL_LABELS[day] || '')} - - `).join(''); - const rows = slots.map(slot => ` - - - ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} - ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - - ${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')} - - `).join(''); - - tables.innerHTML = ` -
-
-

${escapeHtml(section.title)}

- ${escapeHtml(semesterScheduleCaption(semester))} -
-
- - - - - ${dayHeaders} - - - ${rows} -
Время
-
-
- `; - } - - function renderMobileDayTabs() { - dayTabs.hidden = false; - dayTabs.innerHTML = DAY_ORDER.map(day => ` - - `).join(''); - dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => { - button.addEventListener('click', () => { - state.mobileDay = Number(button.dataset.scheduleViewDay); - renderActiveSection(); - }); - }); - } - - function renderMobileSchedule(section, range) { - const slots = collectSlots(section.lessons); - const semester = selectedSemester(); - if (semester) { - renderMobileSemesterSchedule(section, slots, semester); - return; - } - const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); - const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); - if (!blocks.length || !slots.length) { - setTableState('Занятия не найдены'); - return; - } - tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); - } - - function renderMobileSemesterSchedule(section, slots, semester) { - if (!slots.length) { - setTableState('Занятия не найдены'); - return; - } - const lessonMatrix = buildSemesterLessonMatrix(section.lessons); - const rows = slots.map(slot => ` -
-
- ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} -
-
- ${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))} -
-
- `).join(''); - tables.innerHTML = ` -
-
- ${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} - ${escapeHtml(semesterScheduleCaption(semester))} -
- ${rows} -
- `; - } - - function renderMobileDayBlock(block, slots, lessonsByDateSlot) { - const dayDates = block.datesByDay[state.mobileDay] || {}; - const rows = slots.map(slot => { - const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot); - return ` -
-
- ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} -
-
- ${renderSlotContent(states)} -
-
- `; - }).join(''); - - return ` -
-
- ${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} - ${escapeHtml(mobileDayCaption(dayDates))} -
- ${rows} -
- `; - } - - function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { - const dayHeaders = DAY_ORDER.map(day => ` - - ${escapeHtml(dayShort(day))} - ${escapeHtml(DAY_FULL_LABELS[day] || '')} - - `).join(''); - const rows = slots.map(slot => ` - - - ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} - ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - - ${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')} - - `).join(''); - - return ` -
-
-

${escapeHtml(section.title)}

- ${escapeHtml(combinedWeekCaption(block))} -
-
- - - - - ${dayHeaders} - - - ${rows} -
Время
-
-
- `; - } - - function renderDayParityDate(date, label) { - return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; - } - - function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { - const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot); - const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; - return `${renderSlotContent(states)}`; - } - - function renderSemesterSlotCell(day, slot, lessonMatrix) { - const states = slotStatesForSemester(day, slot, lessonMatrix); - const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; - return `${renderSlotContent(states)}`; - } - - function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { - return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ - parity, - label, - state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) - })); - } - - 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: [] } - }; - }); - } - - 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)) - ])); - } - - function renderSlotContent(states) { - if (slotStatesEqual(states[0].state, states[1].state)) { - return renderUnifiedSlot(states[0].state); - } - return states.map(({ parity, label, state }) => - renderSlotHalf(state, parity, label) - ).join(''); - } - - 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: [] }; - } - - function renderUnifiedSlot(state) { - if (state.kind === 'busy') { - return `
${state.lessons.map(renderLessonCard).join('')}
`; - } - if (state.kind === 'empty') { - return '
Нет даты
'; - } - return '
Нет занятий
'; - } - - function renderSlotHalf(state, parity, label) { - if (state.kind === 'busy') { - return ` -
- ${escapeHtml(label)} - ${state.lessons.map(renderLessonCard).join('')} -
- `; - } - if (state.kind === 'empty') { - return ` -
- ${escapeHtml(label)} - Нет даты -
- `; - } - return ` -
- ${escapeHtml(label)} - Нет занятий -
- `; - } - - 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('|'); - } - - 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(','); - } - - 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, 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); - } - - function renderLessonCard(lesson) { - const subgroupText = (lesson.subgroupNames || []).join(', '); - const meta = [ - lesson.lessonTypeName, - lesson.lessonFormat, - PARITY_LABELS[lesson.parity] || lesson.parity - ].filter(Boolean).join(' · '); - - const weeksText = getLessonWeeksText(lesson); - const weeksBadge = weeksText ? `${escapeHtml(weeksText)}` : ''; - const sourceDate = lesson.originalLessonDate || lesson.date; - const overrideBadge = lesson.scheduleOverrideId - ? 'Разовая правка' - : ''; - const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate - ? ` - - ` - : ''; - - return ` -
-
- ${escapeHtml(lesson.subjectName || 'Без дисциплины')} - ${weeksBadge} -
- ${overrideBadge} -
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
- ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''} -
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
-
${escapeHtml(lesson.classroomName || 'Аудитория не указана')}
- ${meta ? `
${escapeHtml(meta)}
` : ''} - ${lesson.scheduleRuleSlotId ? `
ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}
` : ''} - ${editButton} -
- `; - } - - function setTableState(message, showReloadButton = false) { - if (!tables) return; - tables.innerHTML = ` -
- ${escapeHtml(message)} - ${showReloadButton ? ` - - ` : ''} -
- `; - } - - function resetResults(message) { - state.sections = []; - state.activeSectionIndex = 0; - state.mobileDay = 1; - state.totalLessons = 0; - state.lessons = []; - renderResultNavigation(); - tableTitle.textContent = 'Расписание'; - tableCaption.textContent = message; - setTableState(message, message === SCHEDULE_REFRESH_PROMPT); - countLabel.textContent = '0 занятий'; - dayTabs.hidden = true; - dayTabs.innerHTML = ''; - } - - function updatePeriodLabel(range) { - if (!periodLabel) return; - if (!dateInput.value) { - periodLabel.textContent = 'Период не выбран'; - return; - } - periodLabel.textContent = periodCaption( - range || scheduleRangeForSemester(dateInput.value, selectedSemester()), - selectedSemester() - ); - } - - function selectedSemester() { - const semesterId = semesterSelect?.value; - if (!semesterId) return null; - return (state.dictionaries.semesters || []) - .find(semester => String(semester.id) === String(semesterId)) || null; - } - - function selectCurrentSemester(academicYearId = '', semesterId = '') { - if (!academicYearSelect || !semesterSelect) return; - const selected = resolveAcademicPeriod(state.dictionaries.semesters, { - academicYearId, - semesterId, - date: dateInput.value - }); - renderPeriodSelects(selected.academicYearId, selected.semesterId); - synchronizeDateWithSemester(); - updatePeriodLabel(); - } - - function renderPeriodSelects(academicYearId, semesterId) { - const years = academicYearsFromSemesters(state.dictionaries.semesters); - const selectedYearId = String(academicYearId || years[0]?.id || ''); - academicYearSelect.innerHTML = years.length - ? years.map(year => ``).join('') - : ''; - academicYearSelect.value = selectedYearId; - - const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId); - semesterSelect.innerHTML = yearSemesters.length - ? yearSemesters.map(semester => ` - - `).join('') - : ''; - const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId)) - || preferredScheduleSemester(yearSemesters, dateInput.value) - || yearSemesters[0]; - semesterSelect.value = String(selectedSemester?.id || ''); - } - - function synchronizeDateWithSemester() { - const semester = selectedSemester(); - if (!semester) { - dateInput.value = toIsoDate(new Date()); - return; - } - if (!dateWithinSemester(dateInput.value, semester)) { - dateInput.value = semester.startDate; - } - } - - function groupNameById(id) { - return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; - } - - function preferredMobileDay(range) { - if (!range) return 1; - return isoDay(dateInput.value || range.startDate); - } - - function persistFilters() { - const target = activeTarget(); - const entitySelectId = { - groups: 'schedule-view-group', - teachers: 'schedule-view-teacher', - classrooms: 'schedule-view-classroom', - departments: 'schedule-view-department' - }[target]; - writeUrlState({ - scheduleTarget: role === ROLE_DEPARTMENT ? '' : target, - scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '', - scheduleDate: dateInput.value, - scheduleYear: academicYearSelect?.value || '', - scheduleSemester: semesterSelect?.value || '', - scheduleSubject: valueOf('schedule-view-subject'), - scheduleLessonType: valueOf('schedule-view-lesson-type'), - scheduleParity: valueOf('schedule-view-parity') - }); - } -} - -function createEmptyOverridePanel() { - return { - setRegistry() {}, - openLesson() {}, - openRegistry() {} - }; -} - -async function loadDictionaries(role, userDepartmentId) { - const [departments, subjects, lessonTypes, semesters] = await Promise.all([ - api.get('/api/departments'), - api.get('/api/subjects'), - api.get('/api/lesson-types'), - api.get('/api/schedule/semesters') - ]); - - fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); - fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); - fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); - - return { - groups: [], - teachers: [], - classrooms: [], - departments, - semesters: semesters || [] - }; -} - -function setupEntityComboboxes(dictionaries, role, userDepartmentId) { - const definitions = { - groups: { - select: document.getElementById('schedule-view-group'), - load: async (query, selectedId) => { - const params = new URLSearchParams({ query, limit: '30' }); - if (selectedId) params.set('selectedId', selectedId); - if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId); - return api.get(`/api/groups/options?${params}`); - }, - label: item => item.name, - description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ') - }, - teachers: { - select: document.getElementById('schedule-view-teacher'), - load: async (query, selectedId) => { - const params = new URLSearchParams({ query, limit: '30' }); - if (selectedId) params.set('selectedId', selectedId); - return api.get(`/api/users/teachers/options?${params}`); - }, - label: item => item.fullName || item.username, - description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ') - }, - classrooms: { - select: document.getElementById('schedule-view-classroom'), - load: async (query, selectedId) => { - const params = new URLSearchParams({ query, limit: '30' }); - if (selectedId) params.set('selectedId', selectedId); - return api.get(`/api/classrooms/options?${params}`); - }, - label: roomLabel, - description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ') - } - }; - return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => { - const combo = new AsyncCombobox(definition.select, { - placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`, - optionLabel: definition.label, - optionDescription: definition.description, - load: async (query, selectedId) => { - const items = await definition.load(query, selectedId); - mergeItems(dictionaries, key, items); - return items; - } - }); - combo.preload(''); - return [key, combo]; - })); -} - -function mergeLoadedDictionaries(dictionaries, combos) { - Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || [])); -} - -function mergeItems(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()]; -} - -function restoreEntitySelect(target, value) { - if (!value) return; - const id = { - groups: 'schedule-view-group', - teachers: 'schedule-view-teacher', - classrooms: 'schedule-view-classroom', - departments: 'schedule-view-department' - }[target]; - const select = document.getElementById(id); - if (select) select.innerHTML = ``; -} - -function restoreSimpleFilters(restored) { - [ - ['schedule-view-subject', restored.scheduleSubject], - ['schedule-view-lesson-type', restored.scheduleLessonType], - ['schedule-view-parity', restored.scheduleParity] - ].forEach(([id, value]) => { - const select = document.getElementById(id); - if (select && value && Array.from(select.options).some(option => option.value === String(value))) { - select.value = String(value); - } - }); -} - -function setDefaultDate(dateInput) { - const today = new Date(); - dateInput.value = toIsoDate(today); -} - -function fillSelect(id, items, valueFn, labelFn, emptyLabel) { - const select = document.getElementById(id); - if (!select) return; - select.innerHTML = `` + - (items || []).map(item => ``).join(''); -} - -function addOptional(params, key, value) { - if (value) params.set(key, value); -} - -function valueOf(id) { - return document.getElementById(id)?.value || ''; -} - -function normalizeId(value) { - if (!value || value === 'null' || value === 'undefined') return ''; - return String(value); -} - -function roomLabel(room) { - return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); -} - -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); -} - -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; -} - -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)); -} - -export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) { - return target === TARGET_GROUPS && selectedGroupId - ? new Set([String(selectedGroupId)]) - : new Set(); -} - -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; -} - -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 - }; -} - -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; -} - -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; -} - -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; -} - -function combinedWeekCaption(block) { - return 'Нечётная и чётная недели'; -} - -function mobileDayCaption(datesByParity) { - return 'Нечётная / Чётная'; -} - -function periodCaption(range, semester = null) { - if (!range) return 'Учебное расписание'; - const rangeLabel = `${formatDate(range.startDate)} — ${formatDate(range.endDate)}`; - return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel; -} - -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 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} нед.)`; -} - -function semesterScheduleCaption(semester) { - const weeks = semesterWeekCount(semester); - const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`; - return `Весь семестр · ${weekLabel}`; -} - -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)); -} - -function dateWithinSemester(date, semester) { - return Boolean( - date - && semester?.startDate - && semester?.endDate - && date >= semester.startDate - && date <= semester.endDate - ); -} - -function lessonSlotKey(lesson) { - const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; - return value ? String(value) : 'unknown'; -} - -function dateSlotKey(date, slotKey) { - return `${date}:${slotKey}`; -} - -function weekStart(value) { - const date = parseIsoDate(value); - const day = date.getDay() || 7; - date.setDate(date.getDate() + 1 - day); - return toIsoDate(date); -} - -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) - }; -} - -function addDays(date, days) { - const next = new Date(date); - next.setDate(next.getDate() + days); - return next; -} - -function parseIsoDate(value) { - const [year, month, day] = String(value).split('-').map(Number); - return new Date(year, month - 1, day); -} - -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; -} - -function isoDay(value) { - const day = parseIsoDate(value).getDay(); - return day === 0 ? 7 : day; -} - -function dayShort(dayOfWeek) { - return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; -} - -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 toIsoDate(date) { - return [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0') - ].join('-'); -} - -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} занятий`; -} - -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} расписаний`; -} - -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' }); -} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js deleted file mode 100644 index 1e79c58..0000000 --- a/frontend/admin/js/views/schedule.js +++ /dev/null @@ -1,2562 +0,0 @@ -import { api } from '../api.js'; -import { renderPagination } from '../pagination.js'; -import { refreshCustomDropdowns } from '../dropdown.js'; -import { - academicYearsFromSemesters, - resolveAcademicPeriod, - semestersForAcademicYear -} from '../academic-period.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; -import { confirmAction } from '../dialog.js'; -import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js'; -import { trackUxEvent } from '../../../telemetry.js'; -import { AsyncCombobox } from '../async-combobox.js'; -import { debounce } from '../url-state.js'; - -const DAY_OPTIONS = [ - { value: 1, label: 'Понедельник' }, - { value: 2, label: 'Вторник' }, - { value: 3, label: 'Среда' }, - { value: 4, label: 'Четверг' }, - { value: 5, label: 'Пятница' }, - { value: 6, label: 'Суббота' }, - { value: 7, label: 'Воскресенье' } -]; - -const PARITY_LABELS = { - BOTH: 'каждую неделю', - ODD: 'нечётная', - EVEN: 'чётная' -}; - -const CELL_PARITY_LAYOUT = [ - { parity: 'ODD', label: 'Нечётная' }, - { parity: 'EVEN', label: 'Чётная' } -]; - -const SEMESTER_LABELS = { - autumn: 'осенний', - spring: 'весенний' -}; - -const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; -const ACADEMIC_HOURS_PER_SLOT = 2; -const VISUAL_MENU_GAP = 8; -const VISUAL_MENU_VIEWPORT_PADDING = 8; - -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; -} - -const LESSON_TYPE_LIMITS = [ - { - key: 'lecture', - label: 'Лекции', - hoursField: 'lectureAcademicHours', - startWeekField: 'lectureStartWeek', - hoursInputId: 'schedule-rule-lecture-hours', - startWeekInputId: 'schedule-rule-lecture-start-week' - }, - { - key: 'laboratory', - label: 'Лабораторные', - hoursField: 'laboratoryAcademicHours', - startWeekField: 'laboratoryStartWeek', - hoursInputId: 'schedule-rule-laboratory-hours', - startWeekInputId: 'schedule-rule-laboratory-start-week' - }, - { - key: 'practice', - label: 'Практики', - hoursField: 'practiceAcademicHours', - startWeekField: 'practiceStartWeek', - hoursInputId: 'schedule-rule-practice-hours', - startWeekInputId: 'schedule-rule-practice-start-week' - } -]; - -export async function initSchedule() { - const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?'); - const initialState = document.getElementById('schedule-initial-state'); - const initialContent = document.getElementById('schedule-initial-content'); - const ruleForm = document.getElementById('schedule-rule-form'); - const ruleFormTitle = document.getElementById('schedule-rule-form-title'); - const ruleIdInput = document.getElementById('schedule-rule-id'); - const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); - const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); - const scheduleVersionYearSelect = document.getElementById('schedule-version-year'); - const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester'); - const scheduleVersionSelect = document.getElementById('schedule-version-select'); - const scheduleVersionContext = document.getElementById('schedule-version-context'); - const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow'); - const scheduleVersionTitle = document.getElementById('schedule-version-title'); - const scheduleVersionNote = document.getElementById('schedule-version-note'); - const scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft'); - const scheduleVersionManage = document.getElementById('schedule-version-manage'); - const draftModal = document.getElementById('schedule-draft-modal'); - const draftForm = document.getElementById('schedule-draft-form'); - const draftNameInput = document.getElementById('schedule-draft-name'); - const draftCopy = document.getElementById('schedule-draft-copy'); - const draftSummary = document.getElementById('schedule-draft-summary'); - const draftCloseButton = document.getElementById('schedule-draft-close'); - const draftCancelButton = document.getElementById('schedule-draft-cancel'); - const draftSubmitButton = document.getElementById('schedule-draft-submit'); - const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, { - hours: document.getElementById(config.hoursInputId), - startWeek: document.getElementById(config.startWeekInputId) - }])); - const ruleGroupsBox = document.getElementById('schedule-rule-groups-box'); - const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu'); - const ruleGroupsText = document.getElementById('schedule-rule-groups-text'); - const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes'); - const ruleGroupSearch = document.getElementById('schedule-rule-group-search'); - const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); - const ruleResetButton = document.getElementById('schedule-rule-reset'); - const slotAddButton = document.getElementById('schedule-slot-add'); - const rulesTbody = document.getElementById('schedule-tbody'); - const refreshRulesButton = document.getElementById('schedule-refresh'); - const visualToggleButton = document.getElementById('schedule-visual-toggle'); - const visualCloseButton = document.getElementById('schedule-visual-close'); - const visualDrawer = document.getElementById('schedule-visual-drawer'); - const visualBackdrop = document.getElementById('schedule-visual-backdrop'); - const visualStage = document.getElementById('schedule-visual-stage'); - const visualSummary = document.getElementById('schedule-visual-summary'); - const visualYearSelect = document.getElementById('schedule-visual-year'); - const visualSemesterSelect = document.getElementById('schedule-visual-semester'); - const visualGroupFilter = document.getElementById('schedule-visual-group-filter'); - const visualUseFormButton = document.getElementById('schedule-visual-use-form'); - const visualSelectAllButton = document.getElementById('schedule-visual-select-all'); - const conflictModal = document.getElementById('schedule-conflict-modal'); - const conflictForm = document.getElementById('schedule-conflict-form'); - const conflictCloseButton = document.getElementById('schedule-conflict-close'); - const conflictCancelButton = document.getElementById('schedule-conflict-cancel'); - const conflictSubmitButton = document.getElementById('schedule-conflict-submit'); - const conflictSlotsContainer = document.getElementById('schedule-conflict-slots'); - const conflictSummary = document.getElementById('schedule-conflict-summary'); - const conflictReasons = document.getElementById('schedule-conflict-reasons'); - const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule'); - const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta'); - const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule'); - const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta'); - const visualContextMenu = document.getElementById('schedule-visual-context-menu'); - const moveSlotModal = document.getElementById('schedule-move-slot-modal'); - const moveSlotForm = document.getElementById('schedule-move-slot-form'); - const moveSlotCloseButton = document.getElementById('schedule-move-slot-close'); - const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel'); - const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit'); - const moveSlotTitle = document.getElementById('schedule-move-slot-title'); - const moveSlotSummary = document.getElementById('schedule-move-slot-summary'); - const moveSlotDaySelect = document.getElementById('schedule-move-slot-day'); - const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time'); - - let rules = []; - let visualRules = []; - let rulesPage = 0; - let rulesSize = 25; - let rulesRequest = 0; - let visualRequest = 0; - let rulesVersionId = ''; - const rulesSearch = document.getElementById('schedule-rules-search'); - const rulesPagination = document.getElementById('schedule-rules-pagination'); - let subjects = []; - let groups = []; - let lessonTypes = []; - let subgroups = []; - let timeSlots = []; - let academicYears = []; - let semesters = []; - let approvedTeacherPreferences = []; - let scheduleVersions = []; - let selectedVisualYearId = ''; - let selectedVisualSemesterId = ''; - let selectedVisualGroupIds = new Set(); - let visualGroupFilterTouched = false; - let visualPeriodTouched = false; - let syncingVisualPeriod = false; - let pendingConflictResolution = null; - let visualContextTarget = null; - let visualContextMenuPositionAnimation = null; - let pendingSlotMove = null; - let creatingDraft = false; - let lastVersionYearValue = ''; - let lastVersionSemesterValue = ''; - let lastVersionValue = ''; - let slotComboboxes = []; - let conflictComboboxes = []; - let groupSearchRequest = 0; - let subgroupRequest = 0; - let syncingRuleForm = false; - - bindEvents(); - - try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]); - await loadScheduleVersions(); - renderRuleGroups([]); - renderRuleSlots([{}]); - syncRuleSemesterWithVersion(); - setRuleEditorEnabled(Boolean(selectedScheduleVersion())); - await loadRules(); - dirtyScope.clear(); - } catch (error) { - showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); - } finally { - initialContent?.classList.remove('is-loading'); - initialContent?.setAttribute('aria-busy', 'false'); - if (initialState) initialState.hidden = true; - } - - function bindEvents() { - rulesSearch?.addEventListener('input', debounce(() => { - rulesPage = 0; - loadRules(); - }, 300)); - refreshRulesButton?.addEventListener('click', loadRules); - scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange); - scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange); - scheduleVersionSelect?.addEventListener('change', async () => { - const requestedValue = scheduleVersionSelect.value; - if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) { - scheduleVersionSelect.value = lastVersionValue; - return; - } - lastVersionValue = requestedValue; - if (scheduleVersionSelect.value) { - localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value); - } - updateVersionContext(); - resetRuleForm(); - syncRuleSemesterWithVersion(); - await loadRules(); - }); - scheduleVersionCreateDraft?.addEventListener('click', openDraftModal); - scheduleVersionManage?.addEventListener('click', () => { - document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click(); - }); - draftCloseButton?.addEventListener('click', closeDraftModal); - draftCancelButton?.addEventListener('click', closeDraftModal); - draftModal?.addEventListener('click', event => { - if (event.target === draftModal) closeDraftModal(); - }); - draftForm?.addEventListener('submit', createDraftFromSelectedVersion); - ruleResetButton?.addEventListener('click', resetRuleForm); - slotAddButton?.addEventListener('click', () => { - renderRuleSlots([...readRuleSlots(false), {}]); - dirtyScope.mark(); - }); - visualToggleButton?.addEventListener('click', toggleVisualDrawer); - visualCloseButton?.addEventListener('click', closeVisualDrawer); - visualBackdrop?.addEventListener('click', closeVisualDrawer); - visualYearSelect?.addEventListener('change', handleVisualYearChange); - visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange); - visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); - visualStage?.addEventListener('click', handleVisualStageClick); - visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true); - conflictCloseButton?.addEventListener('click', closeConflictModal); - conflictCancelButton?.addEventListener('click', closeConflictModal); - conflictModal?.addEventListener('click', (event) => { - if (event.target === conflictModal) closeConflictModal(); - }); - conflictForm?.addEventListener('submit', resolveScheduleRuleConflict); - visualContextMenu?.addEventListener('click', handleVisualContextMenuClick); - moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal); - moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal); - moveSlotModal?.addEventListener('click', (event) => { - if (event.target === moveSlotModal) closeMoveSlotModal(); - }); - moveSlotForm?.addEventListener('submit', saveVisualSlotMove); - visualUseFormButton?.addEventListener('click', () => { - const formSemesterId = ruleSemesterSelect.value; - if (formSemesterId) { - visualPeriodTouched = true; - setVisualPeriodBySemesterId(formSemesterId); - } - visualGroupFilterTouched = false; - selectedVisualGroupIds = new Set(); - renderVisualSchedule(); - }); - visualSelectAllButton?.addEventListener('click', () => { - selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id))); - visualGroupFilterTouched = true; - renderVisualSchedule(); - }); - ruleSemesterSelect?.addEventListener('change', () => { - updatePreferenceHints(); - if (visualPeriodTouched) return; - selectDefaultVisualPeriod(); - renderVisualSchedule(); - }); - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape') return; - if (draftModal && !draftModal.hidden) { - closeDraftModal(); - return; - } - if (moveSlotModal?.classList.contains('open')) { - closeMoveSlotModal(); - return; - } - if (conflictModal?.classList.contains('open')) { - closeConflictModal(); - return; - } - if (visualContextMenu && !visualContextMenu.hidden) { - closeVisualContextMenu(); - return; - } - closeVisualDrawer(); - }); - document.addEventListener('click', (event) => { - if (visualContextMenu?.hidden) return; - if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return; - closeVisualContextMenu(); - }); - - ruleSlotsContainer.addEventListener('click', (event) => { - const removeButton = event.target.closest('.schedule-slot-remove'); - const subgroupBox = event.target.closest('.slot-subgroup-box'); - const subgroupMenu = event.target.closest('.slot-subgroup-menu'); - - if (removeButton) { - const slots = readRuleSlots(false); - slots.splice(Number(removeButton.dataset.index), 1); - renderRuleSlots(slots.length ? slots : [{}]); - dirtyScope.mark(); - return; - } - - if (subgroupBox) { - toggleSlotSubgroupMenu(event, subgroupBox); - return; - } - - if (subgroupMenu) { - event.stopPropagation(); - } - }); - - ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu); - ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown); - ruleGroupsMenu.addEventListener('click', event => event.stopPropagation()); - ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320)); - ruleGroupCheckboxes.addEventListener('change', async () => { - const slots = readRuleSlots(false); - updateRuleGroupsText(); - if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots); - }); - document.addEventListener('click', () => { - closeRuleGroupsMenu(); - closeSlotSubgroupMenus(); - }); - ruleSlotsContainer.addEventListener('change', (event) => { - if (event.target.closest('.slot-lesson-type')) { - renderRuleSlots(readRuleSlots(false)); - return; - } - if (event.target.closest('.slot-subgroup-checkboxes')) { - updateSlotSubgroupText(event.target.closest('.schedule-slot-row')); - } - updatePreferenceHints(); - }); - ruleForm.addEventListener('submit', saveRule); - ruleForm.addEventListener('input', event => { - if (shouldMarkRuleFormDirty({ - programmatic: syncingRuleForm, - type: event.type, - fromAsyncCombobox: Boolean(event.target.closest('.async-combobox')) - })) dirtyScope.mark(); - }); - ruleForm.addEventListener('change', event => { - if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark(); - }); - rulesTbody.addEventListener('click', handleRuleTableClick); - } - - async function loadBaseLists() { - [subjects, lessonTypes] = await Promise.all([ - api.get('/api/subjects'), - api.get('/api/lesson-types') - ]); - subgroups = []; - populateSubjectSelects(); - await searchRuleGroups(''); - } - - async function loadYears() { - academicYears = await api.get('/api/admin/calendar/years'); - semesters = academicYears.flatMap(year => year.semesters || []); - populateSemesterSelects(); - const selected = resolveAcademicPeriod(semesters, { - academicYearId: localStorage.getItem('magistr.schedule.yearId'), - semesterId: localStorage.getItem('magistr.schedule.semesterId'), - date: localIsoDate(new Date()) - }); - renderVersionPeriodSelects(selected.academicYearId, selected.semesterId); - selectDefaultVisualPeriod(); - } - - async function loadApprovedTeacherPreferences() { - approvedTeacherPreferences = await api.get('/api/teacher-preferences?status=APPROVED'); - updatePreferenceHints(); - } - - async function loadScheduleVersions(preferredVersionId = null) { - const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId(); - if (!semesterId) { - scheduleVersions = []; - scheduleVersionSelect.innerHTML = ''; - updateVersionContext(); - setRuleEditorEnabled(false); - return; - } - localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || ''); - localStorage.setItem('magistr.schedule.semesterId', semesterId); - scheduleVersions = (await api.get( - `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}` - )).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); - const requestedVersionId = preferredVersionId - || localStorage.getItem('magistr.schedule.openVersionId') - || localStorage.getItem('magistr.schedule.versionId') - || ''; - localStorage.removeItem('magistr.schedule.openVersionId'); - const selected = chooseScheduleVersion( - scheduleVersions, - semesterId, - requestedVersionId - ); - const published = scheduleVersions.filter(version => version.status === 'PUBLISHED'); - const drafts = scheduleVersions.filter(version => version.status === 'DRAFT'); - scheduleVersionSelect.innerHTML = scheduleVersions.length - ? [ - versionOptionGroup('Опубликованные расписания', published), - versionOptionGroup('Черновики', drafts) - ].filter(Boolean).join('') - : ''; - scheduleVersionSelect.value = selected ? String(selected.id) : ''; - lastVersionValue = scheduleVersionSelect.value; - if (selected) { - localStorage.setItem('magistr.schedule.versionId', String(selected.id)); - } - updateVersionContext(); - setRuleEditorEnabled(Boolean(selected)); - } - - async function handleVersionYearChange() { - if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) { - renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue); - return; - } - const yearId = scheduleVersionYearSelect?.value || ''; - const yearSemesters = semestersForAcademicYear(semesters, yearId); - const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) }); - renderVersionPeriodSelects(yearId, selected.semesterId); - await applyVersionPeriodChange(); - } - - async function handleVersionSemesterChange() { - if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) { - renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue); - return; - } - await applyVersionPeriodChange(); - } - - async function applyVersionPeriodChange() { - lastVersionYearValue = scheduleVersionYearSelect?.value || ''; - lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || ''; - localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue); - localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue); - try { - await loadScheduleVersions(); - resetRuleForm(); - syncRuleSemesterWithVersion(); - await loadRules(); - } catch (error) { - showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error'); - } - } - - function renderVersionPeriodSelects(academicYearId, semesterId) { - if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return; - const years = academicYearsFromSemesters(semesters); - const selectedYearId = String(academicYearId || years[0]?.id || ''); - scheduleVersionYearSelect.innerHTML = years.length - ? years.map(year => ``).join('') - : ''; - scheduleVersionYearSelect.value = selectedYearId; - - const yearSemesters = semestersForAcademicYear(semesters, selectedYearId); - scheduleVersionSemesterSelect.innerHTML = yearSemesters.length - ? yearSemesters.map(semester => ` - - `).join('') - : ''; - const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId)) - || yearSemesters[0]; - scheduleVersionSemesterSelect.value = String(selectedSemester?.id || ''); - lastVersionYearValue = scheduleVersionYearSelect.value; - lastVersionSemesterValue = scheduleVersionSemesterSelect.value; - refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect); - } - - function versionOptionGroup(label, versions) { - if (!versions.length) return ''; - return `${versions.map(version => ` - - `).join('')}`; - } - - function defaultScheduleSemesterId() { - return resolveAcademicPeriod(semesters, { - academicYearId: localStorage.getItem('magistr.schedule.yearId'), - semesterId: localStorage.getItem('magistr.schedule.semesterId'), - date: localIsoDate(new Date()) - }).semesterId; - } - - function updateVersionContext() { - const version = selectedScheduleVersion(); - const isDraft = version?.status === 'DRAFT'; - if (scheduleVersionContext) scheduleVersionContext.dataset.status = isDraft ? 'DRAFT' : 'PUBLISHED'; - if (scheduleVersionEyebrow) scheduleVersionEyebrow.textContent = isDraft ? 'Безопасная рабочая копия' : 'Основной режим'; - if (scheduleVersionTitle) scheduleVersionTitle.textContent = isDraft ? 'Редактирование черновика' : 'Опубликованное расписание'; - if (!version) { - scheduleVersionNote.textContent = 'Расписание для редактирования не найдено.'; - return; - } - scheduleVersionNote.textContent = isDraft - ? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.` - : `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`; - } - - function openDraftModal() { - const base = selectedScheduleVersion(); - if (!base || creatingDraft) return; - draftForm?.reset(); - if (draftCopy) { - draftCopy.textContent = `За основу будет взято «${base.name}» — все его правила и слоты будут скопированы.`; - } - if (draftSummary) { - draftSummary.textContent = `${base.semesterLabel} · версия ${base.versionNumber}`; - draftSummary.dataset.tone = ''; - } - draftModal.hidden = false; - draftNameInput?.focus(); - } - - function closeDraftModal() { - if (!draftModal || creatingDraft) return; - draftModal.hidden = true; - } - - async function createDraftFromSelectedVersion(event) { - event.preventDefault(); - const base = selectedScheduleVersion(); - const name = draftNameInput?.value.trim(); - if (!base || !name || creatingDraft) return; - creatingDraft = true; - if (draftSubmitButton) { - draftSubmitButton.disabled = true; - draftSubmitButton.textContent = 'Создаём…'; - } - try { - const created = await api.post('/api/edu-office/schedule/versions', { - semesterId: Number(base.semesterId), - name, - basedOnVersionId: Number(base.id) - }); - draftModal.hidden = true; - await loadScheduleVersions(created.id); - resetRuleForm(); - syncRuleSemesterWithVersion(); - await loadRules(); - showAlert('schedule-rule-alert', `Черновик «${created.name}» создан и открыт.`, 'success'); - } catch (error) { - if (draftSummary) { - draftSummary.textContent = error.message || 'Не удалось создать черновик.'; - draftSummary.dataset.tone = 'error'; - } - } finally { - creatingDraft = false; - if (draftSubmitButton) { - draftSubmitButton.disabled = false; - draftSubmitButton.textContent = 'Создать и открыть'; - } - } - } - - function selectedScheduleVersion() { - return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null; - } - - function syncRuleSemesterWithVersion() { - const version = selectedScheduleVersion(); - if (!version) return; - ruleSemesterSelect.value = String(version.semesterId); - ruleSemesterSelect.disabled = true; - syncSelects(ruleSemesterSelect); - } - - function setRuleEditorEnabled(enabled) { - document.getElementById('schedule-rule-editor')?.classList.toggle('is-disabled', !enabled); - ruleForm.querySelectorAll('input, select, button').forEach(control => { - control.disabled = !enabled; - }); - if (enabled) ruleSemesterSelect.disabled = true; - } - - async function loadRules() { - const request = ++rulesRequest; - ++visualRequest; - visualRules = []; - rules = []; - rulesPagination?.replaceChildren(); - rulesTbody.innerHTML = 'Загрузка...'; - const version = selectedScheduleVersion(); - if (!version) { - rules = []; - rulesTbody.innerHTML = 'Нет расписания для редактирования'; - setVisualState('Нет выбранного расписания'); - return; - } - if (rulesVersionId !== String(version.id)) { - rulesPage = 0; - rulesVersionId = String(version.id); - } - try { - const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' }); - const page = await api.get(`/api/admin/schedule-rules/page?${params}`); - if (request !== rulesRequest) return; - if (rulesPage > 0 && rulesPage >= page.totalPages) { - rulesPage = Math.max(0, page.totalPages - 1); - return loadRules(); - } - rules = page.items; - renderPagination(rulesPagination, page, { - label: 'правил', - onPage: value => { rulesPage = value; loadRules(); }, - onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); } - }); - if (!visualPeriodTouched) { - selectDefaultVisualPeriod(); - } - renderRules(); - if (visualDrawer?.classList.contains('open')) await loadVisualRules(); - await openRequestedRule(); - } catch (error) { - if (request !== rulesRequest) return; - rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - setVisualState('Ошибка загрузки правил расписания'); - } - } - - async function openRequestedRule() { - const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId'); - if (!requestedRuleId) return; - localStorage.removeItem('magistr.schedule.openRuleId'); - const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`); - if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) { - showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error'); - return; - } - fillRuleForm(rule); - } - - function renderRules() { - if (!rules.length) { - rulesTbody.innerHTML = 'Правила не найдены'; - renderVisualSchedule(); - return; - } - rulesTbody.innerHTML = rules.map(rule => ` - - ${rule.id} - ${escapeHtml(rule.subjectName || '-')} - ${escapeHtml(formatSemester(rule))} - ${escapeHtml((rule.groupNames || []).join(', ') || '-')} - ${renderRuleLoadSummary(rule)} - ${renderRuleSlotsSummary(rule.slots)} - -
- - -
- - - `).join(''); - } - - function toggleVisualDrawer() { - if (!visualDrawer) return; - if (visualDrawer.classList.contains('open')) { - closeVisualDrawer(); - } else { - openVisualDrawer(); - } - } - - function openVisualDrawer() { - if (!visualDrawer) return; - visualDrawer.classList.add('open'); - visualDrawer.setAttribute('aria-hidden', 'false'); - visualToggleButton?.classList.add('open'); - visualToggleButton?.setAttribute('aria-expanded', 'true'); - visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание'); - if (visualBackdrop) visualBackdrop.hidden = false; - loadVisualRules(); - } - - function closeVisualDrawer() { - if (!visualDrawer) return; - closeVisualContextMenu(); - visualDrawer.classList.remove('open'); - visualDrawer.setAttribute('aria-hidden', 'true'); - visualToggleButton?.classList.remove('open'); - visualToggleButton?.setAttribute('aria-expanded', 'false'); - visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание'); - if (visualBackdrop) visualBackdrop.hidden = true; - } - - async function loadVisualRules() { - const version = selectedScheduleVersion(); - if (!version) return; - const request = ++visualRequest; - setVisualState('Загрузка визуального расписания...'); - try { - const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`); - if (request !== visualRequest) return; - visualRules = loaded; - renderVisualSchedule(); - } catch (error) { - if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания'); - } - } - - function renderVisualSchedule() { - if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return; - if (!selectedVisualSemesterId) { - syncVisualGroupFilter([]); - visualSummary.textContent = 'Выберите учебный год и семестр'; - setVisualState('Выберите период, чтобы увидеть правила конкретного семестра'); - return; - } - if (!visualRules.length) { - syncVisualGroupFilter([]); - visualSummary.textContent = 'Нет сохранённых правил'; - setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам'); - return; - } - - const periodRules = visualRulesForSelectedPeriod(); - const periodLabel = visualPeriodLabel(); - if (!periodRules.length) { - syncVisualGroupFilter([]); - visualSummary.textContent = `${periodLabel} · правил нет`; - setVisualState('В выбранном семестре нет сохранённых правил расписания'); - return; - } - - const allGroups = collectVisualGroups(periodRules); - syncVisualGroupFilter(allGroups); - const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id))); - const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules); - if (!allGroups.length || !slots.length) { - visualSummary.textContent = 'Недостаточно данных для построения'; - setVisualState('У правил должны быть группы и слоты занятий'); - return; - } - if (!columns.length) { - visualSummary.textContent = 'Выберите группы для просмотра'; - setVisualState('Отметьте одну или несколько групп в фильтре'); - return; - } - - const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules); - const rows = buildVisualRows(columns, slots, lessonsByCell); - if (!rows.length) { - visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; - setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра'); - return; - } - - visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`; - visualStage.innerHTML = ` -
- - - - - - ${columns.map(group => ``).join('')} - - - ${rows.map(renderVisualRow).join('')} -
ДеньВремя${escapeHtml(group.name)}
-
- `; - } - - 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; - } - - function renderVisualRow(row) { - return ` - - ${row.dayRowspan ? `${escapeHtml(row.day.label)}` : ''} - - ${escapeHtml(row.slot.orderLabel)} - ${escapeHtml(row.slot.timeLabel)} - - ${row.cells.map(cell => { - const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty'; - return `${renderVisualCellContent(cell.lessons)}`; - }).join('')} - - `; - } - - function buildVisualLessonsByCell(allowedGroupIds, visualRules) { - const grouped = new Map(); - const activeWeeks = buildActiveWeeksByRuleSlot(visualRules); - 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), slot.parity), - groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId) - }; - 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; - } - - function buildActiveWeeksByRuleSlot(visualRules) { - const activeWeeks = new Map(); - visualRules.forEach(rule => { - const maxWeeks = maxWeeksForRule(rule); - (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) === 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; - } - - function renderVisualCellContent(lessons) { - if (!lessons.length) return ''; - const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD'); - const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN'); - const shouldSplit = lessons.some(l => l.parity !== 'BOTH'); - - if (!shouldSplit) { - return `
${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}
`; - } - - return CELL_PARITY_LAYOUT.map(({ parity, label }) => { - const parityLessons = parity === 'ODD' ? oddLessons : evenLessons; - return ` -
- ${escapeHtml(label)} - ${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''} -
- `; - }).join(''); - } - - function uniqueVisualLessons(lessons) { - const unique = new Map(); - lessons.forEach(lesson => { - unique.set(visualLessonSignature([lesson]), lesson); - }); - return Array.from(unique.values()).sort(compareVisualLessons); - } - - function renderVisualLesson(lesson) { - const meta = [ - lesson.lessonTypeName, - lesson.lessonFormat, - lesson.teacherName, - lesson.classroomName - ].filter(Boolean).join(' · '); - const subgroupText = lesson.subgroupNames.join(', '); - return ` -
- ${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` ${escapeHtml(lesson.weekText)}` : ''} - ${subgroupText ? `${escapeHtml(subgroupText)}` : ''} - ${meta ? `${escapeHtml(meta)}` : ''} - -
- `; - } - - function collectVisualGroups(visualRules = rules) { - 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) - }); - } - }); - }); - return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name)); - } - - function collectVisualSlotsFromRules(visualRules = rules) { - 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); - } - - 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)}` : 'Время не указано' - }; - } - - function setVisualState(message) { - if (!visualStage) return; - visualStage.innerHTML = `
${escapeHtml(message)}
`; - } - - function handleVisualGroupFilterChange(event) { - if (!event.target.matches('input[type="checkbox"]')) return; - selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked')) - .map(input => String(input.value))); - visualGroupFilterTouched = true; - renderVisualSchedule(); - } - - function handleVisualYearChange() { - if (syncingVisualPeriod) return; - selectedVisualYearId = visualYearSelect?.value || ''; - selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id - ? String(firstSemesterForYear(selectedVisualYearId).id) - : ''; - visualPeriodTouched = true; - visualGroupFilterTouched = false; - populateVisualSemesterSelect(); - renderVisualSchedule(); - } - - function handleVisualSemesterChange() { - if (syncingVisualPeriod) return; - selectedVisualSemesterId = visualSemesterSelect?.value || ''; - visualPeriodTouched = true; - visualGroupFilterTouched = false; - renderVisualSchedule(); - } - - function handleVisualStageClick(event) { - const editButton = event.target.closest('.schedule-visual-edit-rule'); - if (!editButton) return; - event.stopPropagation(); - openVisualContextMenu(editButton); - } - - function openVisualContextMenu(button) { - const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId)); - if (!rule) { - showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); - return; - } - visualContextTarget = { - ruleId: button.dataset.ruleId, - slotId: button.dataset.slotId || '', - slotIndex: button.dataset.slotIndex || '' - }; - if (!visualContextMenu) return; - visualContextMenu.hidden = false; - const anchorRect = button.getBoundingClientRect(); - const position = calculateVisualContextMenuPosition({ - anchorRect, - menuWidth: visualContextMenu.offsetWidth, - menuHeight: visualContextMenu.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - }); - visualContextMenuPositionAnimation?.cancel(); - const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`; - visualContextMenuPositionAnimation = visualContextMenu.animate( - [ - { transform: positionedTransform }, - { transform: positionedTransform } - ], - { duration: 1, fill: 'forwards' } - ); - visualContextMenu.querySelector('button')?.focus(); - } - - function closeVisualContextMenu() { - visualContextTarget = null; - if (!visualContextMenu) return; - visualContextMenu.hidden = true; - visualContextMenuPositionAnimation?.cancel(); - visualContextMenuPositionAnimation = null; - } - - async function handleVisualContextMenuClick(event) { - const actionButton = event.target.closest('button[data-action]'); - if (!actionButton || !visualContextTarget) return; - const action = actionButton.dataset.action; - const target = { ...visualContextTarget }; - const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId)); - if (!rule) { - closeVisualContextMenu(); - showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); - return; - } - - if (action === 'edit-rule') { - closeVisualContextMenu(); - fillRuleForm(rule); - closeVisualDrawer(); - showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success'); - return; - } - - if (action === 'move-slot') { - openMoveSlotModal(target); - return; - } - - if (action === 'delete-rule') { - await deleteRuleFromVisual(rule); - } - } - - async function deleteRuleFromVisual(rule) { - closeVisualContextMenu(); - if (!await confirmAction({ - title: 'Удалить правило расписания?', - message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`, - confirmLabel: 'Удалить правило', - danger: true - })) return; - try { - await api.delete('/api/admin/schedule-rules/' + rule.id); - showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); - await loadRules(); - } catch (error) { - showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error'); - } - } - - function openMoveSlotModal(target) { - const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId)); - const slotIndex = findRuleSlotIndex(rule, target); - const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null; - if (!rule || !slot) { - closeVisualContextMenu(); - showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error'); - return; - } - - pendingSlotMove = { - ruleId: String(rule.id), - slotIndex - }; - if (moveSlotTitle) { - moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару'; - } - if (moveSlotSummary) { - moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`; - } - if (moveSlotDaySelect) { - moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek); - } - if (moveSlotTimeSelect) { - moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId); - } - hideAlert('schedule-move-slot-alert'); - closeVisualContextMenu(); - moveSlotModal?.classList.add('open'); - moveSlotModal?.setAttribute('aria-hidden', 'false'); - moveSlotDaySelect?.focus(); - } - - function closeMoveSlotModal() { - pendingSlotMove = null; - moveSlotModal?.classList.remove('open'); - moveSlotModal?.setAttribute('aria-hidden', 'true'); - hideAlert('schedule-move-slot-alert'); - } - - async function saveVisualSlotMove(event) { - event.preventDefault(); - if (!pendingSlotMove) return; - hideAlert('schedule-move-slot-alert'); - const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId); - const slot = rule?.slots?.[pendingSlotMove.slotIndex]; - if (!rule || !slot) { - showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error'); - return; - } - const dayOfWeek = Number(moveSlotDaySelect?.value); - const timeSlotId = Number(moveSlotTimeSelect?.value); - if (!dayOfWeek || !timeSlotId) { - showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error'); - return; - } - - const previousButtonText = moveSlotSubmitButton?.textContent || ''; - if (moveSlotSubmitButton) { - moveSlotSubmitButton.disabled = true; - moveSlotSubmitButton.textContent = 'Сохраняем...'; - } - try { - const slots = (rule.slots || []).map(ruleSlotToPayload); - slots[pendingSlotMove.slotIndex] = { - ...slots[pendingSlotMove.slotIndex], - dayOfWeek, - timeSlotId - }; - const payload = buildRulePayloadFromRule(rule, slots); - const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload); - closeMoveSlotModal(); - showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success'); - await loadRules(); - } catch (error) { - if (isScheduleRuleConflictError(error)) { - showAlert( - 'schedule-move-slot-alert', - `Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`, - 'error' - ); - return; - } - showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error'); - } finally { - if (moveSlotSubmitButton) { - moveSlotSubmitButton.disabled = false; - moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос'; - } - } - } - - function findRuleSlotIndex(rule, target) { - if (!rule?.slots?.length) return -1; - if (target.slotId) { - const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId)); - if (byId >= 0) return byId; - } - const index = Number(target.slotIndex); - return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1; - } - - 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 || 'Очно' - }; - } - - function syncVisualGroupFilter(allGroups) { - ensureVisualGroupSelection(allGroups); - if (!visualGroupFilter) return; - if (!allGroups.length) { - visualGroupFilter.innerHTML = 'Нет групп в правилах'; - return; - } - visualGroupFilter.innerHTML = allGroups.map(group => { - const id = String(group.id); - return ` - - `; - }).join(''); - } - - function ensureVisualGroupSelection(allGroups) { - const availableIds = new Set(allGroups.map(group => String(group.id))); - selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id))); - if (visualGroupFilterTouched) return; - - const formIds = selectedRuleGroupIds() - .map(String) - .filter(id => availableIds.has(id)); - if (!visualGroupFilterTouched && formIds.length) { - selectedVisualGroupIds = new Set(formIds); - return; - } - - if (!selectedVisualGroupIds.size && allGroups.length) { - selectedVisualGroupIds = new Set([String(allGroups[0].id)]); - } - } - - function selectDefaultVisualPeriod() { - const formSemester = semesterById(ruleSemesterSelect?.value); - const ruleSemester = firstSemesterWithRules(); - const fallbackSemester = semesters[0]; - const semester = formSemester || ruleSemester || fallbackSemester; - setVisualPeriodBySemesterId(semester?.id || ''); - } - - function setVisualPeriodBySemesterId(semesterId) { - const semester = semesterById(semesterId); - selectedVisualSemesterId = semester ? String(semester.id) : ''; - selectedVisualYearId = semester ? String(semester.academicYearId) : ''; - populateVisualYearSelect(); - populateVisualSemesterSelect(); - } - - function populateVisualYearSelect() { - if (!visualYearSelect) return; - visualYearSelect.innerHTML = '' + - academicYears.map(year => ``).join(''); - if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) { - visualYearSelect.value = selectedVisualYearId; - } else if (academicYears.length) { - selectedVisualYearId = String(academicYears[0].id); - visualYearSelect.value = selectedVisualYearId; - } else { - selectedVisualYearId = ''; - visualYearSelect.value = ''; - } - syncVisualPeriodSelects(visualYearSelect); - } - - function populateVisualSemesterSelect() { - if (!visualSemesterSelect) return; - const yearSemesters = semestersForYear(selectedVisualYearId); - visualSemesterSelect.innerHTML = yearSemesters.length - ? '' + yearSemesters - .map(semester => ``) - .join('') - : ''; - - if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) { - visualSemesterSelect.value = selectedVisualSemesterId; - } else if (yearSemesters.length) { - selectedVisualSemesterId = String(yearSemesters[0].id); - visualSemesterSelect.value = selectedVisualSemesterId; - } else { - selectedVisualSemesterId = ''; - visualSemesterSelect.value = ''; - } - syncVisualPeriodSelects(visualSemesterSelect); - } - - function syncVisualPeriodSelects(...selects) { - syncingVisualPeriod = true; - syncSelects(...selects); - syncingVisualPeriod = false; - } - - function visualRulesForSelectedPeriod() { - if (!selectedVisualSemesterId) return []; - return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId); - } - - function firstSemesterWithRules() { - const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId))); - return semesters.find(semester => ruleSemesterIds.has(String(semester.id))); - } - - function semesterById(id) { - return semesters.find(semester => String(semester.id) === String(id)); - } - - function semestersForYear(yearId) { - return semesters.filter(semester => String(semester.academicYearId) === String(yearId)); - } - - function firstSemesterForYear(yearId) { - return semestersForYear(yearId)[0] || null; - } - - function visualPeriodLabel() { - const semester = semesterById(selectedVisualSemesterId); - return semester ? shortSemesterLabel(semester) : 'Семестр не выбран'; - } - - function renderRuleSlotsSummary(slots) { - if (!slots || !slots.length) return 'Нет слотов'; - return slots.map(slot => { - const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; - const parts = [ - slot.dayName, - slot.timeSlotLabel, - parity, - slotSubgroupNames(slot).join(', '), - slot.teacherName, - slot.classroomName, - slot.lessonTypeName, - slot.lessonFormat - ].filter(Boolean); - return `
${escapeHtml(parts.join(' · '))}
`; - }).join(''); - } - - function renderRuleLoadSummary(rule) { - const rows = LESSON_TYPE_LIMITS - .map(config => ({ - label: config.label, - hours: Number(rule[config.hoursField] || 0), - startWeek: Number(rule[config.startWeekField] || 1) - })) - .filter(item => item.hours > 0); - if (!rows.length) return 'Часы не указаны'; - return rows.map(item => ( - `
${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
` - )).join(''); - } - - async function handleRuleTableClick(event) { - const editButton = event.target.closest('.btn-edit-rule'); - const deleteButton = event.target.closest('.btn-delete-rule'); - - if (editButton) { - const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id) - || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id); - fillRuleForm(rule); - return; - } - - if (deleteButton) { - if (!await confirmAction({ - title: 'Удалить правило расписания?', - message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.', - confirmLabel: 'Удалить правило', - danger: true - })) return; - try { - await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id); - showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success'); - await loadRules(); - } catch (error) { - showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error'); - } - } - } - - async function saveRule(event) { - event.preventDefault(); - hideAlert('schedule-rule-alert'); - let payload = null; - let id = ''; - try { - payload = buildRulePayload(); - id = ruleIdInput.value; - const saved = await saveRulePayload(id, payload); - await finishSuccessfulRuleSave(saved); - } catch (error) { - if (payload && isScheduleRuleConflictError(error)) { - openConflictModal(error, payload, id); - return; - } - showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error'); - } - } - - async function saveRulePayload(id, payload) { - return id - ? api.put('/api/admin/schedule-rules/' + id, payload) - : api.post('/api/admin/schedule-rules', payload); - } - - async function finishSuccessfulRuleSave(saved) { - const mode = ruleIdInput.value ? 'update' : 'create'; - const versionId = scheduleVersionSelect.value; - dirtyScope.clear(); - resetRuleForm(); - trackUxEvent('правило_расписания_сохранено', { mode, versionId }); - showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success'); - await loadRules(); - } - - function openConflictModal(error, pendingPayload, pendingRuleId) { - const conflictRule = error.data.conflictRule; - pendingConflictResolution = { - conflictRule: clonePlain(conflictRule), - conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [], - pendingPayload: clonePlain(pendingPayload), - pendingRuleId: pendingRuleId || '' - }; - if (conflictSummary) { - conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.'; - } - if (conflictNewRuleTitle) { - conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload); - } - if (conflictNewRuleMeta) { - conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload); - } - if (conflictExistingRuleTitle) { - conflictExistingRuleTitle.textContent = ruleTitle(conflictRule); - } - if (conflictExistingRuleMeta) { - conflictExistingRuleMeta.textContent = ruleMeta(conflictRule); - } - renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons); - renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields); - hideAlert('schedule-conflict-alert'); - conflictModal?.classList.add('open'); - conflictModal?.setAttribute('aria-hidden', 'false'); - } - - function closeConflictModal() { - pendingConflictResolution = null; - conflictModal?.classList.remove('open'); - conflictModal?.setAttribute('aria-hidden', 'true'); - if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = ''; - if (conflictReasons) { - conflictReasons.hidden = true; - conflictReasons.innerHTML = ''; - } - hideAlert('schedule-conflict-alert'); - } - - async function resolveScheduleRuleConflict(event) { - event.preventDefault(); - if (!pendingConflictResolution) return; - hideAlert('schedule-conflict-alert'); - const previousButtonText = conflictSubmitButton?.textContent || ''; - if (conflictSubmitButton) { - conflictSubmitButton.disabled = true; - conflictSubmitButton.textContent = 'Сохраняем...'; - } - - try { - const currentResolution = pendingConflictResolution; - const conflictPayload = buildRulePayloadFromRule( - currentResolution.conflictRule, - readConflictSlots(true) - ); - await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload); - - try { - const saved = await saveRulePayload( - currentResolution.pendingRuleId, - currentResolution.pendingPayload - ); - closeConflictModal(); - await finishSuccessfulRuleSave(saved); - } catch (retryError) { - if (isScheduleRuleConflictError(retryError)) { - await loadRules(); - openConflictModal( - retryError, - currentResolution.pendingPayload, - currentResolution.pendingRuleId - ); - showAlert( - 'schedule-conflict-alert', - 'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.', - 'error' - ); - return; - } - throw retryError; - } - } catch (error) { - if (isScheduleRuleConflictError(error)) { - showAlert( - 'schedule-conflict-alert', - `Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`, - 'error' - ); - return; - } - showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error'); - } finally { - if (conflictSubmitButton) { - conflictSubmitButton.disabled = false; - conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить'; - } - } - } - - function renderConflictReasons(fields, reasons) { - if (!conflictReasons) return; - const labels = Array.isArray(reasons) && reasons.length - ? reasons - : (fields || []).map(conflictFieldLabel).filter(Boolean); - if (!labels.length) { - conflictReasons.hidden = true; - conflictReasons.innerHTML = ''; - return; - } - conflictReasons.hidden = false; - conflictReasons.innerHTML = ` - Причина: - ${labels.map(label => `${escapeHtml(label)}`).join('')} - `; - } - - function renderConflictSlots(rule, conflictFields = []) { - conflictComboboxes.forEach(combobox => combobox.destroy()); - conflictComboboxes = []; - const slots = rule.slots || []; - if (!conflictSlotsContainer) return; - if (!slots.length) { - conflictSlotsContainer.innerHTML = '
У конфликтующего правила нет слотов
'; - return; - } - conflictSlotsContainer.innerHTML = slots.map((slot, index) => { - const fixedParts = [ - slot.lessonTypeName, - slot.lessonFormat, - slotSubgroupNames(slot).join(', ') - ].filter(Boolean); - const groupHitClass = conflictFieldClass('group', conflictFields); - return ` -
-
- Слот ${index + 1} - ${escapeHtml(fixedParts.join(' · ') || 'Занятие')} - ${escapeHtml(conflictSlotCurrentText(slot))} -
-
-
-
-
-
-
- `; - }).join(''); - initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes); - } - - function conflictFieldClass(field, fields) { - return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : ''; - } - - function conflictFieldLabel(field) { - if (field === 'teacher') return 'Преподаватель'; - if (field === 'classroom') return 'Аудитория'; - if (field === 'group') return 'Группа'; - return field; - } - - function readConflictSlots(validate) { - const conflictRule = pendingConflictResolution?.conflictRule; - const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []); - return rows.map((row, index) => { - const original = conflictRule?.slots?.[index] || {}; - const slot = { - id: original.id || null, - dayOfWeek: Number(row.querySelector('.conflict-slot-day').value), - parity: row.querySelector('.conflict-slot-parity').value, - timeSlotId: Number(row.querySelector('.conflict-slot-time').value), - subgroupId: original.subgroupId || null, - subgroupIds: slotSubgroupIds(original), - teacherId: Number(row.querySelector('.conflict-slot-teacher').value), - classroomId: Number(row.querySelector('.conflict-slot-classroom').value), - lessonTypeId: Number(original.lessonTypeId), - lessonFormat: original.lessonFormat || 'Очно' - }; - if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId - || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { - throw new Error(`Заполните слот ${index + 1} конфликтующего правила`); - } - return slot; - }); - } - - function buildRulePayloadFromRule(rule, slots) { - 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 || scheduleVersionSelect.value), - 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 - }; - } - - function isScheduleRuleConflictError(error) { - return error?.status === 409 && Boolean(error.data?.conflictRule); - } - - function clonePlain(value) { - return JSON.parse(JSON.stringify(value)); - } - - function ruleDraftTitle(payload) { - const subject = subjects.find(item => String(item.id) === String(payload.subjectId)); - return subject?.name || `Правило по дисциплине ${payload.subjectId}`; - } - - function ruleTitle(rule) { - return rule?.subjectName || ruleDraftTitle(rule || {}); - } - - function ruleDraftMeta(payload) { - const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны'; - return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)] - .filter(Boolean) - .join(' · '); - } - - function ruleMeta(rule) { - const groupNames = (rule.groupNames || []).join(', ') - || (rule.groupIds || []).map(groupNameById).join(', ') - || 'группы не выбраны'; - return [formatSemester(rule), groupNames, typeLoadText(rule)] - .filter(Boolean) - .join(' · '); - } - - 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('; '); - } - - function semesterTextById(semesterId) { - const semester = semesterById(semesterId); - return semester ? shortSemesterLabel(semester) : 'семестр не выбран'; - } - - function conflictSlotCurrentText(slot) { - const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; - return [ - slot.dayName, - slot.timeSlotLabel, - parity, - slot.teacherName, - slot.classroomName - ].filter(Boolean).join(' · '); - } - - function buildRulePayload() { - const groupIds = selectedRuleGroupIds(); - const slots = readRuleSlots(true).map(ruleSlotToPayload); - if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); - if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания'); - if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); - const typeLimits = readTypeLimits(); - validateTypeLimits(typeLimits, slots); - if (!groupIds.length) throw new Error('Выберите хотя бы одну группу'); - if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия'); - return { - id: ruleIdInput.value ? Number(ruleIdInput.value) : null, - subjectId: Number(ruleSubjectSelect.value), - semesterId: Number(ruleSemesterSelect.value), - scheduleVersionId: Number(scheduleVersionSelect.value), - ...typeLimits, - groupIds, - slots - }; - } - - async function fillRuleForm(rule) { - ruleFormTitle.textContent = 'Редактирование правила расписания'; - ruleIdInput.value = rule.id; - ruleSubjectSelect.value = rule.subjectId || ''; - ruleSemesterSelect.value = rule.semesterId || ''; - fillTypeLimits(rule); - syncSelects(ruleSubjectSelect, ruleSemesterSelect); - cacheRuleGroups(rule.groupIds || [], rule.groupNames || []); - renderRuleGroups(rule.groupIds || []); - searchRuleGroups(ruleGroupSearch?.value || ''); - ruleSlotsContainer.innerHTML = '
Загрузка подгрупп выбранных групп...
'; - if (!await loadSubgroupsForGroups(rule.groupIds || [])) return; - renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); - syncRuleSemesterWithVersion(); - hideAlert('schedule-rule-alert'); - dirtyScope.clear(); - ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - - function resetRuleForm() { - ruleForm.reset(); - ruleFormTitle.textContent = 'Новое правило расписания'; - ruleIdInput.value = ''; - fillTypeLimits({}); - subgroupRequest += 1; - subgroups = []; - renderRuleGroups([]); - renderRuleSlots([{}]); - syncRuleSemesterWithVersion(); - syncSelects(ruleSubjectSelect, ruleSemesterSelect); - hideAlert('schedule-rule-alert'); - dirtyScope.clear(); - } - - function renderRuleGroups(selectedIds) { - if (!groups.length) { - ruleGroupCheckboxes.innerHTML = '
Нет групп
'; - updateRuleGroupsText(); - return; - } - const selected = new Set((selectedIds || []).map(String)); - ruleGroupCheckboxes.innerHTML = groups.map(group => ` - - `).join(''); - updateRuleGroupsText(); - } - - async function searchRuleGroups(query = '') { - const request = ++groupSearchRequest; - const selectedBeforeSearch = selectedRuleGroupIds(); - if (!groups.length) { - ruleGroupCheckboxes.innerHTML = '
Поиск групп...
'; - } - try { - const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' }); - const loaded = await api.get(`/api/groups/options?${params}`); - if (request !== groupSearchRequest) return; - const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch; - const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id))); - groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]); - renderRuleGroups(selectedIds); - } catch (error) { - if (request !== groupSearchRequest) return; - ruleGroupCheckboxes.innerHTML = `
${escapeHtml(error.message || 'Не удалось найти группы')}
`; - } - } - - async function loadSubgroupsForGroups(groupIds) { - const request = ++subgroupRequest; - const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))]; - if (!normalizedIds.length) { - subgroups = []; - return true; - } - try { - const params = new URLSearchParams(); - normalizedIds.forEach(groupId => params.append('groupId', groupId)); - const loaded = await api.get(`/api/subgroups?${params}`); - if (request !== subgroupRequest) return false; - subgroups = loaded || []; - return true; - } catch (error) { - if (request !== subgroupRequest) return false; - subgroups = []; - showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error'); - return true; - } - } - - function cacheRuleGroups(ids, names) { - const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` })); - groups = mergeEntitiesById([...groups, ...additions]); - } - - function mergeEntitiesById(items) { - return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()]; - } - - function renderRuleSlots(slots) { - slotComboboxes.forEach(combobox => combobox.destroy()); - slotComboboxes = []; - const normalizedSlots = slots?.length ? slots : [{}]; - const isSingleSlot = normalizedSlots.length === 1; - const actionLabel = scheduleSlotActionLabel(normalizedSlots.length); - const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger'; - ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => ` -
-
-
-
-
-
-
- ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''} -
- -
-
- `).join(''); - initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes); - updatePreferenceHints(); - } - - function updatePreferenceHints() { - const semesterId = Number(ruleSemesterSelect?.value); - Array.from(ruleSlotsContainer?.querySelectorAll('.schedule-slot-row') || []).forEach(row => { - const hint = row.querySelector('[data-preference-hint]'); - if (!hint) return; - const teacherId = Number(row.querySelector('.slot-teacher')?.value); - const dayOfWeek = Number(row.querySelector('.slot-day')?.value); - const timeSlotId = Number(row.querySelector('.slot-time')?.value); - if (!semesterId || !teacherId) { - hint.innerHTML = ''; - return; - } - const teacherItems = approvedTeacherPreferences.filter(item => - Number(item.semesterId) === semesterId && Number(item.teacherId) === teacherId - ); - const exact = teacherItems.filter(item => - Number(item.dayOfWeek) === dayOfWeek && Number(item.timeSlotId) === timeSlotId - ); - const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length; - const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType)); - const pills = [ - ...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' })) - ]; - hint.innerHTML = pills.length - ? `Пожелания преподавателя${pills.map(pill => `${escapeHtml(pill.text)}`).join('')}` - : 'Согласованных пожеланий для слота нет'; - }); - } - - function preferenceHintLabel(type) { - return { - HARD_UNAVAILABLE: 'Строгая недоступность', - SOFT_PREFERRED: 'Предпочтительно', - SOFT_UNWANTED: 'Нежелательно', - CONSECUTIVE: 'Пары подряд', - NO_GAPS: 'Без окон' - }[type] || type; - } - - function readRuleSlots(validate) { - const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); - return rows.map((row, index) => { - const subgroupIds = readSlotSubgroupIds(row); - const slotId = Number(row.dataset.slotId); - const slot = { - id: slotId || null, - dayOfWeek: Number(row.querySelector('.slot-day').value), - parity: row.querySelector('.slot-parity').value, - timeSlotId: Number(row.querySelector('.slot-time').value), - subgroupId: subgroupIds[0] || null, - subgroupIds, - teacherId: Number(row.querySelector('.slot-teacher').value), - classroomId: Number(row.querySelector('.slot-classroom').value), - teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')), - classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')), - lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), - lessonFormat: row.querySelector('.slot-format').value - }; - if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId - || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { - throw new Error(`Заполните слот ${index + 1}`); - } - if (validate) { - validateSlotSubgroup(slot, index); - } - return slot; - }); - } - - function readTypeLimits() { - return LESSON_TYPE_LIMITS.reduce((payload, config) => { - const inputs = ruleTypeInputs[config.key]; - payload[config.hoursField] = Number(inputs.hours.value || 0); - payload[config.startWeekField] = Number(inputs.startWeek.value || 1); - return payload; - }, {}); - } - - function fillTypeLimits(rule) { - LESSON_TYPE_LIMITS.forEach(config => { - const inputs = ruleTypeInputs[config.key]; - inputs.hours.value = rule[config.hoursField] ?? 0; - inputs.startWeek.value = rule[config.startWeekField] ?? 1; - }); - } - - function validateTypeLimits(typeLimits, slots) { - const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).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('Укажите часы хотя бы для одного типа занятий'); - } - - function validateSlotSubgroup(slot, index) { - const typeKey = lessonTypeKeyById(slot.lessonTypeId); - const subgroupIds = slotSubgroupIds(slot); - if (subgroupIds.length && typeKey !== 'laboratory') { - throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`); - } - if (!subgroupIds.length) return; - - const selectedGroups = new Set(selectedRuleGroupIds().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)); - } - } - - async function loadTimeSlots() { - const slots = await api.get('/api/admin/time-slots'); - timeSlots = slots - .filter(slot => slot.scopeApplyMode === 'DEFAULT') - .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); - } - - function populateSubjectSelects() { - ruleSubjectSelect.innerHTML = '' + - subjects.map(subject => ``).join(''); - syncSelects(ruleSubjectSelect); - } - - function populateSemesterSelects() { - const semesterOptions = '' + - semesters.map(semester => ``).join(''); - ruleSemesterSelect.innerHTML = semesterOptions; - syncSelects(ruleSemesterSelect); - } - - function options(items, selectedValue) { - const selected = selectedValue == null ? '' : String(selectedValue); - return '' + items.map(item => { - const value = String(item.value); - return ``; - }).join(''); - } - - function subgroupOptions(slot) { - if (!isLaboratorySlot(slot)) { - return ''; - } - const selectedGroupIds = selectedRuleGroupIds(); - if (selectedGroupIds.length !== 1) { - return ''; - } - const selected = slotSubgroupIds(slot)[0] == null - ? WHOLE_GROUP_SUBGROUP_VALUE - : String(slotSubgroupIds(slot)[0]); - const selectedGroups = new Set(selectedGroupIds.map(String)); - const matchingSubgroups = subgroups - .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) - .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); - return `` + matchingSubgroups.map(subgroup => { - const value = String(subgroup.id); - return ``; - }).join(''); - } - - function renderSubgroupField(slot, index) { - return selectedRuleGroupIds().length > 1 - ? renderSubgroupMultiSelect(slot, index) - : `
`; - } - - function renderSubgroupMultiSelect(slot, index) { - const selected = new Set(slotSubgroupIds(slot).map(String)); - const selectedGroups = new Set(selectedRuleGroupIds().map(String)); - const matchingSubgroups = subgroups - .filter(subgroup => selectedGroups.has(String(subgroup.groupId))) - .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); - const items = matchingSubgroups.length - ? matchingSubgroups.map(subgroup => { - const value = String(subgroup.id); - return ` - - `; - }).join('') - : '
Нет подгрупп выбранных групп
'; - - return ` -
- -
- - -
-
- `; - } - - function readSlotSubgroupIds(row) { - const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; - const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked')) - .map(input => Number(input.value)) - .filter(Boolean); - return checkboxValues.length - ? checkboxValues - : (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []); - } - - function slotSubgroupIds(slot) { - if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) { - return slot.subgroupIds.map(Number).filter(Boolean); - } - return slot.subgroupId ? [Number(slot.subgroupId)] : []; - } - - function slotSubgroupNames(slot) { - if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) { - return slot.subgroupNames.filter(Boolean); - } - return slot.subgroupName ? [slot.subgroupName] : []; - } - - function slotSubgroupText(slot) { - 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}`; - } - - function selectedRuleGroupIds() { - return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked')) - .map(input => Number(input.value)); - } - - function toggleRuleGroupsMenu(event) { - event.stopPropagation(); - const isOpen = ruleGroupsMenu.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) { - closeRuleGroupsMenu(); - return; - } - ruleGroupsMenu.classList.add('open'); - ruleGroupsBox.classList.add('active'); - ruleGroupsBox.setAttribute('aria-expanded', 'true'); - } - - function handleRuleGroupsKeydown(event) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - toggleRuleGroupsMenu(event); - } - if (event.key === 'Escape') { - closeRuleGroupsMenu(); - } - } - - function closeRuleGroupsMenu() { - ruleGroupsMenu.classList.remove('open'); - ruleGroupsBox.classList.remove('active'); - ruleGroupsBox.setAttribute('aria-expanded', 'false'); - } - - function toggleSlotSubgroupMenu(event, box) { - event.stopPropagation(); - const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu'); - if (!menu) return; - const isOpen = menu.classList.contains('open'); - document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open')); - document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active')); - if (isOpen) { - box.setAttribute('aria-expanded', 'false'); - return; - } - menu.classList.add('open'); - box.classList.add('active'); - box.setAttribute('aria-expanded', 'true'); - } - - function closeSlotSubgroupMenus() { - ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open')); - ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => { - box.classList.remove('active'); - box.setAttribute('aria-expanded', 'false'); - }); - } - - function updateSlotSubgroupText(row) { - if (!row) return; - const text = row.querySelector('.slot-subgroup-box .select-text'); - if (!text) return; - const selectedIds = readSlotSubgroupIds(row); - if (!selectedIds.length) { - text.textContent = 'Все выбранные группы'; - return; - } - if (selectedIds.length === 1) { - const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0])); - text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа'; - return; - } - text.textContent = `Выбрано подгрупп: ${selectedIds.length}`; - } - - function updateRuleGroupsText() { - const selectedIds = selectedRuleGroupIds(); - if (!groups.length) { - ruleGroupsText.textContent = 'Нет групп'; - return; - } - if (!selectedIds.length) { - ruleGroupsText.textContent = 'Выберите группы...'; - return; - } - if (selectedIds.length === 1) { - const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0])); - ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа'; - return; - } - ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`; - } - - function toTimeSlotOption(slot) { - return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; - } - - function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) { - container.querySelectorAll(teacherSelector).forEach(select => { - collector.push(new AsyncCombobox(select, { - allowEmpty: false, - placeholder: 'Найдите преподавателя', - emptyLabel: 'Преподаватели не найдены', - optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`, - 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); - return api.get(`/api/users/teachers/options?${params}`); - } - })); - }); - container.querySelectorAll(classroomSelector).forEach(select => { - collector.push(new AsyncCombobox(select, { - allowEmpty: false, - placeholder: 'Найдите аудиторию', - emptyLabel: 'Аудитории не найдены', - optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`, - optionDescription: classroom => [ - classroom.building, - classroom.floor != null ? `${classroom.floor} этаж` : '', - classroom.capacity ? `${classroom.capacity} мест` : '' - ].filter(Boolean).join(' · '), - load: async (query, selectedId) => { - const params = new URLSearchParams({ query, limit: '30' }); - if (selectedId) params.set('selectedId', selectedId); - return api.get(`/api/classrooms/options?${params}`); - } - })); - }); - } - - function selectedEntityOption(id, label) { - return id - ? `` - : ''; - } - - function selectedOptionLabel(select) { - return select?.selectedOptions?.[0]?.textContent?.trim() || ''; - } - - function toLessonTypeOption(lessonType) { - return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; - } - - function orderedLessonTypes() { - return [...lessonTypes].sort(compareLessonTypes); - } - - function compareLessonTypes(first, second) { - return lessonTypeRank(first) - lessonTypeRank(second) - || naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label); - } - - function lessonTypeRank(lessonType) { - const key = lessonTypeKey(lessonType); - if (key === 'lecture') return 0; - if (key === 'laboratory') return 1; - if (key === 'practice') return 2; - return 3; - } - - function lessonTypeKeyById(lessonTypeId) { - const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); - return lessonType ? lessonTypeKey(lessonType) : null; - } - - function isLaboratorySlot(slot) { - return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory'; - } - - function subgroupLabel(subgroup) { - return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`; - } - - 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; - } - - function semesterLabel(semester) { - const type = normalizeSemesterType(semester.semesterType); - return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; - } - - function shortSemesterLabel(semester) { - const type = normalizeSemesterType(semester.semesterType); - return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`; - } - - function formatSemester(rule) { - const type = normalizeSemesterType(rule.semesterType); - return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; - } - - function normalizeSemesterType(type) { - return String(type || '').toLowerCase(); - } - - function trimTime(value) { - if (!value) return ''; - return String(value).slice(0, 5); - } - - function syncSelects(...selects) { - const targets = selects.filter(Boolean); - const previousSyncState = syncingRuleForm; - if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true; - try { - targets.forEach(select => { - select.dispatchEvent(new Event('change', { bubbles: true })); - }); - } finally { - syncingRuleForm = previousSyncState; - } - } - - function visualCellKey(groupId, dayOfWeek, slotKey) { - return `${groupId}:${dayOfWeek}:${slotKey}`; - } - - function visualSlotKey(slot) { - return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown'); - } - - function visualRuleSlotKey(slot, index) { - return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`); - } - - function activeWeekKey(ruleId, groupId, slotKey) { - return `${ruleId}:${groupId}:${slotKey}`; - } - - function slotAppliesToWeek(slot, week) { - if (!slot.parity || slot.parity === 'BOTH') return true; - const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD'; - return slot.parity === weekParity; - } - - function maxWeeksForRule(rule) { - 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; - } - - function parseIsoDate(value) { - const [year, month, day] = String(value).split('-').map(Number); - return new Date(year, month - 1, day); - } - - 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 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 - }; - } - - 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); - } - - 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); - } - - function visualLessonSignature(lessons) { - return lessons - .map(lesson => [ - lesson.id, - lesson.subjectName, - lesson.weekText, - lesson.lessonTypeName, - lesson.teacherName, - lesson.classroomName, - lesson.subgroupNames.join(',') - ].join(':')) - .sort((a, b) => naturalCompare(a, b)) - .join('|'); - } - - function groupNameById(id) { - return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; - } - - function naturalCompare(a, b) { - return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); - } - - 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 'правил'; - } - - 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 'групп'; - } - - 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 'строк'; - } -} diff --git a/frontend/admin/js/views/subjects.js b/frontend/admin/js/views/subjects.js deleted file mode 100755 index 95d4e97..0000000 --- a/frontend/admin/js/views/subjects.js +++ /dev/null @@ -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 = `Ошибка загрузки справочников: ${escapeHtml(e.message)}`; - } - } - - function populateDepartmentsSelect() { - newSubjectDeptSelect.innerHTML = '' + - allDepartments.map(d => { - const name = d.departmentName || d.name || 'Без названия'; - const code = d.departmentCode || d.code || d.id; - return ``; - }).join(''); - } - - function populateDepartmentFilter() { - departmentFilter.innerHTML = '' + allDepartments.map(department => - `` - ).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 => `${escapeHtml(t.fullName || t.username)}`).join('') - : 'Нет преподавателей'; - - return ` - - ${s.id} - ${escapeHtml(s.name)} - ${escapeHtml(s.code || '-')} - ${escapeHtml(departmentLabel(s.departmentId))} -
${teachersHtml}
- - - - - - `; - }).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 = 'Преподаватели не привязаны'; - return; - } - manageTsTbody.innerHTML = list.map(ts => ` - - ${escapeHtml(ts.fullName || ts.username)} (${escapeHtml(ts.username)}) - - - - - `).join(''); - } - - function populateTeachersSelect(subjectId) { - manageTsTeacherSelect.innerHTML = ''; - 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 - }); - } -} diff --git a/frontend/admin/js/views/teacher-absences.js b/frontend/admin/js/views/teacher-absences.js deleted file mode 100644 index 47aba80..0000000 --- a/frontend/admin/js/views/teacher-absences.js +++ /dev/null @@ -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 = '
Загрузка...
'; - try { - const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : ''; - absences = await api.get(`/api/teacher-absences${params}`); - renderRegistry(); - updateStats(absences); - } catch (error) { - registry.innerHTML = `
${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}
`; - } - } - - 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 = '
Инцидентов не найденоИзмените фильтр или зарегистрируйте отсутствие.
'; - return; - } - registry.innerHTML = absences.map(item => ` -
-
- ${escapeHtml(dayOfMonth(item.startDate))} - ${escapeHtml(monthShort(item.startDate))} - - ${escapeHtml(dayOfMonth(item.endDate))} - ${escapeHtml(monthShort(item.endDate))} -
-
-
-
-

${escapeHtml(item.teacherName || 'Преподаватель не указан')}

-

${escapeHtml(item.reason || 'Причина не указана')}

-
- ${escapeHtml(statusLabel(item.status))} -
-
- ${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))} - ${escapeHtml(decisionCountLabel(item.decisionCount || 0))} - ${item.reviewComment ? `${escapeHtml(item.reviewComment)}` : ''} -
-
-
- ${item.status === 'PENDING' ? ` - - - ` : ''} - ${['APPROVED', 'RESOLVED'].includes(item.status) ? ` - - ` : ''} - ${['PENDING', 'APPROVED'].includes(item.status) ? ` - - ` : ''} -
-
- `).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 = '
Подбираем свободные ресурсы...
'; - 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('') - : '
Затронутых занятий нетИнцидент можно завершить без точечных изменений.
'; - renderJournal(currentWizard.journal || []); - } - - function renderLessonDecision(lesson, index, absenceStatus) { - const options = decisionOptions(lesson); - const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus); - return ` -
-
- ${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} - ${escapeHtml(formatDate(lesson.lessonDate))} -
-
- ${escapeHtml(lesson.lessonTypeName || 'Занятие')} -

${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}

-

${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}

- ${lesson.existingOverrideId ? 'У пары уже есть точечное изменение — доступна только запись отклонения.' : ''} -
-
- - - ${lesson.decisionStatus ? `${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}` : ''} -
-
- `; - } - - 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 = '
Загрузка семестров...
'; - try { - const meta = await api.get('/api/teacher-preferences/meta'); - preferenceSemesterFilter.innerHTML = '' + (meta.semesters || []).map(item => ` - - `).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 = `
${escapeHtml(error.message || 'Не удалось загрузить семестры')}
`; - } - } - - async function loadPreferenceReviews() { - preferenceReviewList.innerHTML = '
Загрузка пожеланий...
'; - 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 = `
${escapeHtml(error.message || 'Не удалось загрузить пожелания')}
`; - } - } - - function renderPreferenceReviews() { - preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => ` -
- -
- ${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')} -

${escapeHtml(item.teacherName)}

-

${escapeHtml(preferenceDescription(item))}

- ${item.comment ? `
${escapeHtml(item.comment)}
` : ''} - ${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''} -
-
- ${escapeHtml(genericStatusLabel(item.status))} - ${item.status === 'PENDING' ? ` - -
- - -
- ` : ''} -
-
- `).join('') : '
Пожеланий не найденоИзмените фильтры или дождитесь новых отметок преподавателей.
'; - } - - 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 = '
Загрузка заявок...
'; - 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 = `
${escapeHtml(error.message || 'Не удалось загрузить заявки')}
`; - } - } - - 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 `
- -
- ${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)} -

${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}

-

${escapeHtml(changeTypeLabel(item.requestType))} → ${escapeHtml(target)}

-
${escapeHtml(item.reason)}
- ${item.reviewComment ? `Решение: ${escapeHtml(item.reviewComment)}` : ''} -
История · ${(item.history || []).length} -
    ${(item.history || []).map(entry => `
  1. ${genericStatusLabel(entry.status)} · ${escapeHtml(entry.comment || 'Без комментария')}
  2. `).join('')}
-
-
-
- ${escapeHtml(genericStatusLabel(item.status))} - ${item.status === 'PENDING' && canApply ? ` - -
- - -
- ` : item.status === 'PENDING' ? 'Применение доступно учебному отделу' : ''} -
-
`; - }).join('') : '
Заявок не найденоОчередь изменений сейчас пуста.
'; - } - - 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 => ` -
- -
${escapeHtml(resolutionLabel(item.resolution))}${escapeHtml(formatDate(item.lessonDate))}
- ${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''} -
- `).join('') : '
Решений пока нет
'; -} - -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} решений`; -} diff --git a/frontend/admin/js/views/teacher-requests.js b/frontend/admin/js/views/teacher-requests.js deleted file mode 100644 index dc57b3e..0000000 --- a/frontend/admin/js/views/teacher-requests.js +++ /dev/null @@ -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 => ` - - - - - - - - - ${escapeHtml(request.comment || '-')} - -
- ${request.status === 'PENDING' ? ` - - - ` : `${escapeHtml(request.status === 'APPROVED' ? 'Одобрена' : 'Отклонена')}`} -
- - - `).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 - }); - } -} diff --git a/frontend/admin/js/views/university-structure.js b/frontend/admin/js/views/university-structure.js deleted file mode 100644 index 175dc76..0000000 --- a/frontend/admin/js/views/university-structure.js +++ /dev/null @@ -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 = 'Ошибка загрузки'; - } - - // Загрузка специальностей - try { - specialties = await api.get('/api/specialties'); - renderSpecialties(); - populateSpecialtiesDropdown(); - } catch (e) { - specTbody.innerHTML = 'Ошибка загрузки'; - } - - // Загрузка всех профилей - try { - allProfiles = await api.get('/api/specialties/profiles'); - renderAllProfiles(); - } catch (e) { - tabProfileTbody.innerHTML = 'Ошибка загрузки'; - } - } - - function renderDepartments() { - const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]); - if (!visible.length) { - deptTbody.innerHTML = 'Ничего не найдено'; - return; - } - deptTbody.innerHTML = visible.map(d => ` - - ${d.id} - ${escapeHtml(d.departmentName || d.name)} - ${escapeHtml(String(d.departmentCode || d.code))} - - - - - - `).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 = 'Ничего не найдено'; - return; - } - specTbody.innerHTML = visible.map(s => ` - - ${s.id} - ${escapeHtml(s.specialityName || s.name)} - ${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} - - - - - - - `).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 = 'Загрузка...'; - try { - currentProfiles = await api.get(`/api/specialties/${specId}/profiles`); - renderProfilesTable(); - } catch (e) { - profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; - } - } - - function renderProfilesTable() { - if (!currentProfiles || !currentProfiles.length) { - profilesTbody.innerHTML = 'Профили не созданы'; - return; - } - profilesTbody.innerHTML = currentProfiles.map(p => ` - - ${p.id} - ${escapeHtml(p.name)} - ${escapeHtml(p.description || '-')} - - - - - - `).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 = '' + - specialties.map(s => ``).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 = 'Ничего не найдено'; - return; - } - tabProfileTbody.innerHTML = visible.map(p => ` - - ${p.id} - ${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)} - ${escapeHtml(p.name)} - ${escapeHtml(p.description || '-')} - - - - - - `).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(); -} diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js deleted file mode 100644 index 9cf42f8..0000000 --- a/frontend/admin/js/views/users.js +++ /dev/null @@ -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 => ` - - ${u.id} - ${escapeHtml(u.username)} - ${escapeHtml(u.fullName || '-')} - ${escapeHtml(u.jobTitle || '-')} - ${escapeHtml(u.departmentName || '-')} - ${ROLE_LABELS[u.role] || escapeHtml(u.role)} - - ${u.status === 'ARCHIVED' - ? `` - : ``} - - - `).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(); -} diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css index 0fbb34c..14cf023 100644 --- a/frontend/admin/settings/css/layout.css +++ b/frontend/admin/settings/css/layout.css @@ -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; diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css index 5469728..7ef983b 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -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; diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index 8b4e762..4deb500 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -12,95 +12,9 @@ - - +
- - - - -
-
-

Загрузка...

-
- -
- -
-
- - - + diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js deleted file mode 100644 index 158bc8f..0000000 --- a/frontend/admin/settings/js/main.js +++ /dev/null @@ -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 = '
Загрузка...
'; - 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(); diff --git a/frontend/admin/settings/js/views/database.js b/frontend/admin/settings/js/views/database.js deleted file mode 100644 index 34efc29..0000000 --- a/frontend/admin/settings/js/views/database.js +++ /dev/null @@ -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 - ? 'Доступно' - : 'Недоступно'; - - statusInfo.innerHTML = ` -
-
- Тенант: - ${escapeHtml(data.tenant || '—')} -
-
- Название: - ${escapeHtml(data.name || '—')} -
-
- Статус: - ${statusBadge} -
- ${data.url ? `
- URL: - ${escapeHtml(data.url)} -
` : ''} -
- `; - } 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 = 'Нет подключённых тенантов'; - return; - } - - tenantsTbody.innerHTML = tenants.map(t => { - const statusBadge = t.connected - ? 'Доступно' - : 'Недоступно'; - - return ` - - ${escapeHtml(t.name || '—')} - ${escapeHtml(t.domain)} - ${escapeHtml(t.url)} - ${escapeHtml(t.username || '—')} - ${statusBadge} - - `; - }).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(); -} diff --git a/frontend/admin/settings/js/views/edu-forms.js b/frontend/admin/settings/js/views/edu-forms.js deleted file mode 100644 index bf737ec..0000000 --- a/frontend/admin/settings/js/views/edu-forms.js +++ /dev/null @@ -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 = 'Ошибка загрузки'; - } - } - - function renderEfTable(forms) { - if (!forms || !forms.length) { - efTbody.innerHTML = 'Нет форм обучения'; - return; - } - efTbody.innerHTML = forms.map(ef => ` - - ${ef.id} - ${escapeHtml(ef.name)} - - `).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(); -} diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js deleted file mode 100644 index fce2ea3..0000000 --- a/frontend/admin/settings/js/views/time-slots.js +++ /dev/null @@ -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 = 'Загрузка...'; - 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 = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function render() { - renderScopeSelects(); - renderActiveScope(); - } - - function renderActiveScope() { - updateScopeActions(); - renderScopeSummary(); - renderSlots(); - } - - function renderScopeSelects() { - scopeSelect.innerHTML = scopes.map(scope => ` - - `).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 = 'Сетки времени не настроены'; - 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 = ` - ${escapeHtml(scopeBadgeLabel(scope))} - ${escapeHtml(mode + day)}. Слотов: ${slotCount}. - `; - } - - function renderSlots() { - const scopeSlots = slots - .filter(slot => slot.scopeId === activeScopeId) - .sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); - - if (!scopeSlots.length) { - tbody.innerHTML = 'В выбранной сетке нет слотов'; - return; - } - - tbody.innerHTML = scopeSlots.map(slot => ` - - ${escapeHtml(String(slot.orderNumber ?? '-'))} - ${escapeHtml(trimTime(slot.startTime))} - ${escapeHtml(trimTime(slot.endTime))} - ${escapeHtml(String(slot.durationMinutes ?? '-'))} - - - - - - `).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) : ''; -} diff --git a/frontend/admin/settings/views/database.html b/frontend/admin/settings/views/database.html deleted file mode 100644 index dab0aca..0000000 --- a/frontend/admin/settings/views/database.html +++ /dev/null @@ -1,73 +0,0 @@ - - - -
-

Текущее подключение

-
-
Загрузка...
-
-
- - -
-

Подключённые университеты

-
- - - - - - - - - - - - - - - - -
НазваниеДоменJDBC URLПользовательСтатусДействия
Загрузка...
-
-
- - -
-

Добавить подключение

-
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - -
- - -
- -
-
diff --git a/frontend/admin/settings/views/edu-forms.html b/frontend/admin/settings/views/edu-forms.html deleted file mode 100644 index 9efdcfc..0000000 --- a/frontend/admin/settings/views/edu-forms.html +++ /dev/null @@ -1,34 +0,0 @@ - -
-

Новая форма обучения

-
-
-
- - -
- -
- -
-
- -
-

Все формы обучения

-
- - - - - - - - - - - - - -
IDНазваниеДействия
Загрузка...
-
-
diff --git a/frontend/admin/settings/views/general.html b/frontend/admin/settings/views/general.html deleted file mode 100644 index bbbebd8..0000000 --- a/frontend/admin/settings/views/general.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - - - -
-

Общие настройки

-

Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.

-
diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html deleted file mode 100644 index 2dfb9d0..0000000 --- a/frontend/admin/settings/views/time-slots.html +++ /dev/null @@ -1,134 +0,0 @@ -
-
-
-

Временные слоты

-

Настройка базовой, субботней и ручных сеток времени.

-
- -
- -
-
-
-

Сетка времени

-
- - -
-
- -
- -
- -
-
- -
-

Как применяется

-
- Базовая - Используется по умолчанию для всех дат. -
-
- Сб - Субботняя сетка применяется автоматически по субботам. -
-
- Ручная - Ручные сетки назначаются в ячейке редактора календарного графика. -
-
-
- -
-
-

Новый слот

- -
- -
- - - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - - - - - -
ПараНачалоОкончаниеМинутДействия
Загрузка...
-
-
- - -
diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html deleted file mode 100644 index aae7f9a..0000000 --- a/frontend/admin/views/academic-calendar.html +++ /dev/null @@ -1,316 +0,0 @@ -
- - - -
- -
-
-
-

Учебные годы

- -
-
- - - - - - - - - - - - - - -
НазваниеПериодСеместрыДействия
Загрузка...
-
-
- -
-
-

Календарный учебный график

- -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

Календарные графики

- -
-
-
- - -
-
- - -
-
-
- - - - - - - - - - - - - - - - -
Учебный годСпециальностьПрофильФормаКурсовДействия
Загрузка...
-
- -
- -
-
-
-

Учебный год и семестры

-

Учебный год создаётся сразу с осенним и весенним семестрами.

-
- -
-
- -
-
- - - Введите четыре цифры — дата начала будет 1 сентября. -
-
- - - Подставляется автоматически и формирует название учебного года. -
-
- -
-
-
-

Семестры

-

Проверьте даты: оба семестра будут сохранены вместе с учебным годом.

-
- Обязательно -
-
-
- -
- - Осенний семестр -
-
-
- - -
-
- - -
-
-
-
- -
- - Весенний семестр -
-
-
- - -
-
- - -
-
-
-
-
- -
- -
- -
-
- -
- - - - - - diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html deleted file mode 100644 index 496ff55..0000000 --- a/frontend/admin/views/auditorium-workload.html +++ /dev/null @@ -1,89 +0,0 @@ -
-
-

Загруженность

- -
- -
-
- - -
- -
- - -
- -
- -
-
- Все корпуса - - - -
- -
-
- -
- - -
- -
- -
-
- Любое оборудование - - - -
- -
-
- -
- - -
-
- - - -
-
- -
- - - - - - - -
- Время - Сущность -
-
-
- - -
diff --git a/frontend/admin/views/classrooms.html b/frontend/admin/views/classrooms.html deleted file mode 100755 index 073608d..0000000 --- a/frontend/admin/views/classrooms.html +++ /dev/null @@ -1,155 +0,0 @@ - -
-
-

Новая аудитория

- -
-
-
-
- - -
-
- - -
-
-
-
- -
-
- Выберите оборудование... - - - -
- -
-
-
- -
-
- -
-
- -
-

Список аудиторий

-
- - - - - - - - - - - - - - - - -
IDНазваниеВместимостьОборудованиеСтатусДействия
Загрузка...
-
-
- - - - - - diff --git a/frontend/admin/views/dashboard.html b/frontend/admin/views/dashboard.html deleted file mode 100644 index 2e67412..0000000 --- a/frontend/admin/views/dashboard.html +++ /dev/null @@ -1,145 +0,0 @@ - -
- -
-
-
- - - - - - -
-
-
Активные группы
-

—

-
-
- -
-
- - - - - - -
-
-
Всего студентов
-

—

-
-
- -
-
- - - - -
-
-
Преподаватели
-

—

-
-
- -
-
- - - - -
-
-
Загрузка аудиторий сегодня
-

—

-
-
-
- - -
- -
-
-

Мониторинг пар (Идет сейчас)

-
—
-
-
- - - - - - - - - - - - - - -
АудиторияДисциплинаПреподавательГруппа
Определение текущего времени...
-
-
- - -
- -
-

Свободные аудитории (ближайшие)

-
-
Загрузка...
-
-
- - - -
-
- - -
-
-
-

- - - - - - Центр предупреждений (Red Zone) -

-
Анализ накладок и коллизий расписания на текущую неделю
-
- -
- -
-
Анализ расписания и выявление конфликтов...
-
-
-
diff --git a/frontend/admin/views/department-workspace.html b/frontend/admin/views/department-workspace.html deleted file mode 100644 index 8f4d13a..0000000 --- a/frontend/admin/views/department-workspace.html +++ /dev/null @@ -1,161 +0,0 @@ -
-
-

Рабочая область кафедры

- -
- -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
-
-

Загрузка дисциплин

-
-
-
-
- - -
-
-
- - -
-
- -
-
-

Добавить преподавателя

-
-
-
- - -
-
- - -
- - -
-
- -
-
-

Заявка на преподавателя

-
-
-
- - -
-
- - -
-
- - -
-
- - -
- - -
-
- -
-
-

Преподаватели и нагрузка

-
-
Загрузка...
-
-
- -
-
-

Заявки кафедры

- 0 заявок -
-
- - - - - - - - - - - - - - -
СтатусЛогинФИОДолжностьКомментарийРассмотрение
Загрузка...
-
-
- -
-
-

Дисциплины кафедры

- 0 дисциплин -
-
- - - - - - - - - - - - -
КодНазваниеОписаниеКомментарии
Загрузка...
-
-
- - diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html deleted file mode 100755 index d3f4923..0000000 --- a/frontend/admin/views/groups.html +++ /dev/null @@ -1,282 +0,0 @@ - -
-

Новая группа

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

Все группы

-
- -
- - -
-
-
-
- -
- - -
- -
-
- - - - - - - - - - - - - - - - - - - - -
IDНазваниеЧисленность (чел.)Форма обученияКафедраКурсСпециальностьПрофильСтатусДействия
Загрузка...
-
- -
- - - - - - - - - diff --git a/frontend/admin/views/schedule-quality.html b/frontend/admin/views/schedule-quality.html deleted file mode 100644 index 2415061..0000000 --- a/frontend/admin/views/schedule-quality.html +++ /dev/null @@ -1,102 +0,0 @@ -
-
-
- Диагностика · публикация или черновик -

Качество видно до перестановок

-

- Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения - пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы - суммируются. Черновик можно оценить до публикации. -

-
-
- - - - - - - -
-
- -
- Выберите семестр, чтобы получить объяснимую оценку. -
- - -
diff --git a/frontend/admin/views/schedule-versions.html b/frontend/admin/views/schedule-versions.html deleted file mode 100644 index 1d0b7b5..0000000 --- a/frontend/admin/views/schedule-versions.html +++ /dev/null @@ -1,89 +0,0 @@ -
-
-
- Контур публикации -

Расписание меняется за кулисами

-

Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.

-
-
-
-
- - -
-
- - -
-
- -
-
- -
Загружаем контур версий…
- -
- -
-
-
-
- Подготовка -

Черновики

-
- 0 -
-
-
- - -
- -
-
-
- Возврат без паники -

Архив публикаций

-
-
-
-
- -
-
-
- Неизменяемый след -

Журнал действий

-
-
-
-
- - -
diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html deleted file mode 100644 index 9859731..0000000 --- a/frontend/admin/views/schedule-view.html +++ /dev/null @@ -1,233 +0,0 @@ -
-
-
-
-

Расписания

- Период не выбран -
- -
- -
-
- - -
- -
- - -
- - - - - - - - -
- -
- Дополнительные фильтры -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- -
-
-
-

Найденные расписания

- 0 занятий -
-
- - - Нет выбранного расписания - -
-
-
-
- -
-
-
-

Расписание

- Выберите параметры и нажмите «Показать» -
- -
-
-
Загрузка...
-
-
-
- - diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html deleted file mode 100644 index 294a7d8..0000000 --- a/frontend/admin/views/schedule.html +++ /dev/null @@ -1,313 +0,0 @@ -
- - Загружаем конструктор правил… -
- -
-
-
- Основной режим -

Опубликованное расписание

-

Изменения применяются сразу. Для безопасной подготовки можно создать черновик.

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - -
-
-

Новое правило расписания

- -
-
- -
-
- - -
-
- - -
-
- -
- -
-
-
Лекции
-
- - -
-
- - -
-
-
-
Лабораторные
-
- - -
-
- - -
-
-
-
Практики
-
- - -
-
- - -
-
-
-
- -
- -
- - -
-
- -
-
-

Слоты правила

- -
-
-
- -
- -
- -
-
- - - - - - - -
-
-

Правила динамического расписания

- -
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
IDДисциплинаСеместрГруппыНагрузкаСлотыДействия
Загрузка...
-
- -
- - - - - - -
diff --git a/frontend/admin/views/subjects.html b/frontend/admin/views/subjects.html deleted file mode 100755 index 01f789a..0000000 --- a/frontend/admin/views/subjects.html +++ /dev/null @@ -1,116 +0,0 @@ - -
-

Новая дисциплина

-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-

Все дисциплины

-
- -
- - -
-
- - -
-
- -
- - - - - - - - - - - - - - - - -
IDНазваниеКод предметаКафедраПреподавателиДействия
Загрузка...
-
- -
- - - diff --git a/frontend/admin/views/teacher-absences.html b/frontend/admin/views/teacher-absences.html deleted file mode 100644 index 2fd1536..0000000 --- a/frontend/admin/views/teacher-absences.html +++ /dev/null @@ -1,146 +0,0 @@ -
-
- Оперативный контур расписания -

От регистрации инцидента до готовой замены

-

Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.

-
-
-
0требуют внимания
-
0ждут подтверждения
-
0разрешены
-
-
- -
-
-
- 01 -

Зарегистрировать отсутствие

-
- Ответственный сотрудник создаёт согласованную запись сразу -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-
- 02 -

Реестр инцидентов

-
-
- - -
-
-
-
Загрузка...
-
-
- -
-
-
-
- 03 -

Пожелания на семестр

-

Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.

-
-
0ожидают
-
-
- - - -
-
-
- -
-
-
- 04 -

Заявки на изменение

-

Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.

-
-
0в очереди
-
-
- - -
-
-
-
- - diff --git a/frontend/admin/views/teacher-requests.html b/frontend/admin/views/teacher-requests.html deleted file mode 100644 index 529c275..0000000 --- a/frontend/admin/views/teacher-requests.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
-

Заявки на преподавателей

-
- 0 заявок - -
-
- -
- -
- - -
-
-
- - - - - - - - - - - - - - - -
КафедраЛогинФИОДолжностьПарольКомментарийДействия
Загрузка...
-
- -
diff --git a/frontend/admin/views/university-structure.html b/frontend/admin/views/university-structure.html deleted file mode 100644 index 819d835..0000000 --- a/frontend/admin/views/university-structure.html +++ /dev/null @@ -1,268 +0,0 @@ - -
-
- - - -
- - -
-
-

Создание кафедры

-
-
-
- - -
-
- - -
- -
- -
-
- -
-
-

Кафедры

-
-
- - -
-
- - - - - - - - - - - - - - -
IDНазваниеКодДействия
Загрузка...
-
- -
-
- - - - - - -
- - - - - - - - - diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html deleted file mode 100755 index 7536183..0000000 --- a/frontend/admin/views/users.html +++ /dev/null @@ -1,95 +0,0 @@ - -
-

Новый пользователь

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-

Все пользователи

-
- -
- - -
-
- - -
-
- -
- - - - - - - - - - - - - - - - - -
IDИмя пользователяФИОДолжностьКафедраРольДействия
Загрузка...
-
- -
diff --git a/frontend/auth-session.js b/frontend/auth-session.js index 1f33894..2d64b28 100644 --- a/frontend/auth-session.js +++ b/frontend/auth-session.js @@ -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)); diff --git a/frontend/index.html b/frontend/index.html index 077316e..12286f6 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,77 +9,9 @@ -
-
-
-
-
- -
- -
+
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3475db2..612a5e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 15b8bfd..191bd80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/react-cache.conf b/frontend/react-cache.conf new file mode 100644 index 0000000..85e872c --- /dev/null +++ b/frontend/react-cache.conf @@ -0,0 +1,15 @@ + + Header always set Cache-Control "public, max-age=31536000, immutable" + + + + Header always set Cache-Control "no-cache" + + +# Стили и HTML не имеют отпечатков в именах файлов: без явного +# no-cache браузеры держат их в heuristic-кэше, и обновления +# (например, правки layout.css) не доходят до пользователей +# до истечения срока годности. ETag делает ревалидацию дешёвой (304). + + Header always set Cache-Control "no-cache" + diff --git a/frontend/react/admin/AdminApp.jsx b/frontend/react/admin/AdminApp.jsx new file mode 100644 index 0000000..d264647 --- /dev/null +++ b/frontend/react/admin/AdminApp.jsx @@ -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: ( + + + + + + + ), + 'teacher-requests': ( + + + + + + + ), + 'department-workspace': ( + + + + + + + + ), + schedule: ( + + + + + + + ), + 'schedule-versions': ( + + + + + + + ), + 'schedule-quality': ( + + + + + + + + ), + 'academic-calendar': ( + + + + + + + + ), + 'auditorium-workload': ( + + + + + + ), + 'schedule-view': ( + + + + + ), + 'teacher-absences': ( + + + + + + + ), + classrooms: ( + + + + ), + groups: ( + + + + + ), + 'university-structure': ( + + + + + ), + subjects: ( + + + + + ), + users: ( + + + + + + + ) +}); + +export function AdminApp() { + const sessionState = useAuthorizedSession(ADMIN_APP_ROLES); + + if (sessionState.phase === SESSION_PHASES.loading) { + return ( + + + + ); + } + + if (sessionState.phase !== SESSION_PHASES.ready) { + return ( + + + + ); + } + + return ; +} + +function AdminFrame({ children }) { + return ( +
+
+
+

Админ-панель

+
+
+
+ {children} +
+
+ ); +} + +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 ( + <> + + +
+ +
+
+
+

{title}

+ {badge && !badge.hidden && ( + + )} +
+ +
+
+ {activeTab && } +
+
+ + ); +} + +function TabContent({ tab }) { + const reactTab = REACT_TABS[tab]; + const TabComponent = reactTab?.Component; + if (!TabComponent) { + return null; + } + return ( +
}> + + + ); +} diff --git a/frontend/react/admin/academic-calendar-service.js b/frontend/react/admin/academic-calendar-service.js new file mode 100644 index 0000000..cabd613 --- /dev/null +++ b/frontend/react/admin/academic-calendar-service.js @@ -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; +} diff --git a/frontend/react/admin/admin-service.js b/frontend/react/admin/admin-service.js new file mode 100644 index 0000000..07a7246 --- /dev/null +++ b/frontend/react/admin/admin-service.js @@ -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); +} diff --git a/frontend/react/admin/auditorium-workload-service.js b/frontend/react/admin/auditorium-workload-service.js new file mode 100644 index 0000000..6016acf --- /dev/null +++ b/frontend/react/admin/auditorium-workload-service.js @@ -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))); +} diff --git a/frontend/react/admin/classrooms-service.js b/frontend/react/admin/classrooms-service.js new file mode 100644 index 0000000..a94c65d --- /dev/null +++ b/frontend/react/admin/classrooms-service.js @@ -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)) + })); +} diff --git a/frontend/react/admin/dashboard-service.js b/frontend/react/admin/dashboard-service.js new file mode 100644 index 0000000..e89340c --- /dev/null +++ b/frontend/react/admin/dashboard-service.js @@ -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(''); +} diff --git a/frontend/react/admin/department-workspace-service.js b/frontend/react/admin/department-workspace-service.js new file mode 100644 index 0000000..76d30c2 --- /dev/null +++ b/frontend/react/admin/department-workspace-service.js @@ -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; +} diff --git a/frontend/react/admin/groups-service.js b/frontend/react/admin/groups-service.js new file mode 100644 index 0000000..8cea91b --- /dev/null +++ b/frontend/react/admin/groups-service.js @@ -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 + ? 'По заданным условиям группы не найдены' + : 'Групп пока нет'; +} diff --git a/frontend/react/admin/main.jsx b/frontend/react/admin/main.jsx new file mode 100644 index 0000000..7cd43a2 --- /dev/null +++ b/frontend/react/admin/main.jsx @@ -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( + + + + + +); diff --git a/frontend/react/admin/react-tabs.js b/frontend/react/admin/react-tabs.js new file mode 100644 index 0000000..d6619ca --- /dev/null +++ b/frontend/react/admin/react-tabs.js @@ -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 }))) } +}); diff --git a/frontend/react/admin/schedule-override-service.js b/frontend/react/admin/schedule-override-service.js new file mode 100644 index 0000000..5c48d29 --- /dev/null +++ b/frontend/react/admin/schedule-override-service.js @@ -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: 'Не удалось удалить разовое изменение' + }); +} diff --git a/frontend/react/admin/schedule-quality-service.js b/frontend/react/admin/schedule-quality-service.js new file mode 100644 index 0000000..49a3bb6 --- /dev/null +++ b/frontend/react/admin/schedule-quality-service.js @@ -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 }; diff --git a/frontend/react/admin/schedule-service.js b/frontend/react/admin/schedule-service.js new file mode 100644 index 0000000..d070861 --- /dev/null +++ b/frontend/react/admin/schedule-service.js @@ -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: 'Не удалось загрузить подгруппы выбранных групп' }); +} diff --git a/frontend/react/admin/schedule-versions-service.js b/frontend/react/admin/schedule-versions-service.js new file mode 100644 index 0000000..a3bce38 --- /dev/null +++ b/frontend/react/admin/schedule-versions-service.js @@ -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 }; diff --git a/frontend/react/admin/schedule-view-service.js b/frontend/react/admin/schedule-view-service.js new file mode 100644 index 0000000..86af832 --- /dev/null +++ b/frontend/react/admin/schedule-view-service.js @@ -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' }); +} diff --git a/frontend/react/admin/subjects-service.js b/frontend/react/admin/subjects-service.js new file mode 100644 index 0000000..23a5ad5 --- /dev/null +++ b/frontend/react/admin/subjects-service.js @@ -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 }; +} diff --git a/frontend/react/admin/tabs/AcademicCalendarTab.jsx b/frontend/react/admin/tabs/AcademicCalendarTab.jsx new file mode 100644 index 0000000..0581661 --- /dev/null +++ b/frontend/react/admin/tabs/AcademicCalendarTab.jsx @@ -0,0 +1,2244 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js'; +import { + dateInputValueToIso, + formatDateInputValue, + isoDateToInputValue +} from '../../../admin/js/date-input.js'; +import { debounce } from '../../../admin/js/url-state.js'; +import { buildAcademicCalendarTitle } from '../../../admin/js/views/academic-calendar-title.js'; +import { trackUxEvent } from '../../../telemetry.js'; +import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; +import { ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx'; +import { FormAlert } from '../../shared/ui/FormAlert.jsx'; +import { Pagination } from '../../shared/ui/Pagination.jsx'; +import { + DAY_OPTIONS, + academicYearDefaults, + activityById, + activityColorClass, + automaticTimeScopeLabel, + buildDefaultGrid, + calculateCalendarTooltipPosition, + calendarOptionDescription, + calendarOptionLabel, + calendarRowKey, + calendarSemesterCount, + commonActivityValue, + dayDialogSummaryParts, + dayDialogTexts, + dayTooltipParts, + deleteAcademicYearRequest, + deleteCalendarRequest, + deleteTimeSlotDateAssignmentRequest, + effectiveActivityTypeId, + fetchAcademicYearsList, + fetchActivityTypes, + fetchCalendarGrid, + fetchCalendarOptions, + fetchCalendarSubjects, + fetchCalendarsPage, + fetchEducationForms, + fetchSpecialties, + fetchSpecialtyProfiles, + fetchSubjectsList, + fetchTimeSlotDateAssignments, + fetchTimeSlotScopes, + formatDisplayDateRange, + formatShortDate, + gridTotalsEntries, + groupBy, + readDisplayDateIso, + saveAcademicYearRequest, + saveCalendarGridRequest, + saveCalendarRequest, + saveCalendarSubjectsRequest, + semesterBlocksForCourse, + semesterOptionLabel, + semesterTypeTitle, + shortDayLabel, + sortCalendarSubjectRows, + specialityLabel, + startYearInputResult, + subjectLabel, + subjectRowLabel, + timeAssignmentForDate, + uniqueWeekNumbers, + validateAcademicYearSemesters +} from '../academic-calendar-service.js'; + +const YEAR_SEMESTER_LABELS = { + autumn: { start: 'Начало осеннего семестра', end: 'Окончание осеннего семестра' }, + spring: { start: 'Начало весеннего семестра', end: 'Окончание весеннего семестра' } +}; + +function initialYearForm() { + return { + id: '', + title: 'Учебный год и семестры', + startYear: '', + endYear: '', + autumn: { id: '', start: '', end: '' }, + spring: { id: '', start: '', end: '' } + }; +} + +function initialCalendarForm() { + return { + id: '', + title: 'Календарный учебный график', + yearId: '', + specialtyId: '', + profileId: '', + studyFormId: '', + courseCount: '4' + }; +} + +// Поле даты с маской ДД.ММ.ГГГГ: реплика bindDateInputMask из legacy — +// форматирование прямо во время ввода, нормализация и ошибка при потере фокуса. +function MaskedDateInput({ id, value, onChange, inputRef, required = false, 'aria-describedby': describedBy }) { + const [invalid, setInvalid] = useState(false); + const innerRef = useRef(null); + const ref = inputRef || innerRef; + + function clearError() { + setInvalid(false); + ref.current?.setCustomValidity?.(''); + } + + return ( + { + clearError(); + onChange(formatDateInputValue(event.target.value)); + }} + onBlur={() => { + if (!value) { + clearError(); + return; + } + try { + onChange(isoDateToInputValue(dateInputValueToIso(value))); + clearError(); + } catch (error) { + setInvalid(true); + ref.current?.setCustomValidity?.(error.message); + } + }} + /> + ); +} + +// Диалог ячейки сетки: код активности выделения и временная сетка даты. +// Как в legacy, кнопка «Применить» закрывает окно, а выбор временной сетки +// носит информационный характер. +function CalendarDayDialog({ + rows, + activityTypes, + scopes, + assignments, + onActivityChange, + onClearAssignment, + onClose, + alert +}) { + const isSingle = rows.length === 1; + const texts = dayDialogTexts(rows); + const summary = dayDialogSummaryParts(rows, activityTypes); + const common = commonActivityValue(rows); + const assignment = isSingle ? timeAssignmentForDate(assignments, rows[0].date) : null; + const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL'); + const [scopeValue, setScopeValue] = useState(assignment?.scopeId || ''); + + useEffect(() => { + setScopeValue(assignment?.scopeId || ''); + }, [assignment?.scopeId]); + + const summaryActivity = summary.kind === 'single' || summary.kind === 'multi' ? summary.activity : null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+

Ячейка графика

+ +
+
+ Дата + {texts.date} +
+
+ Курс + {texts.course} +
+
+ Неделя + {texts.week} +
+
+ День + {texts.day} +
+
+
+ {summaryActivity ? ( + <> + {summaryActivity.code} +
+ {summary.kind === 'multi' + ? `${summaryActivity.name} · ${summary.count} ячеек` + : summaryActivity.name} +

{summaryActivity.description + || (summaryActivity.allowSchedule + ? 'Обычные пары разрешены' + : 'Обычные пары не генерируются')}

+
+ + ) : summary.kind === 'multi-empty' ? ( + <> + ... +
+ Выбрано ячеек: {summary.count} +

Выберите код активности, чтобы применить его ко всему выделению

+
+ + ) : null} +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +export function AcademicCalendarTab() { + const [activeSection, setActiveSection] = useState('manage'); + + // Справочники + const [academicYears, setAcademicYears] = useState([]); + const [specialties, setSpecialties] = useState([]); + const [profilesBySpecialty, setProfilesBySpecialty] = useState(new Map()); + const [studyForms, setStudyForms] = useState([]); + const [activityTypes, setActivityTypes] = useState([]); + const [subjects, setSubjects] = useState([]); + const [timeSlotScopes, setTimeSlotScopes] = useState([]); + const [timeSlotDateAssignments, setTimeSlotDateAssignments] = useState([]); + const [baseLoaded, setBaseLoaded] = useState(false); + const activityTypesRef = useRef([]); + + // Учебные годы + const [yearsState, setYearsState] = useState({ loading: true, error: '' }); + const [yearForm, setYearForm] = useState(initialYearForm); + const [yearAlert, setYearAlert] = useState(null); + const [yearDeleteDialog, setYearDeleteDialog] = useState(null); + + // Форма календарного графика + const [calendarForm, setCalendarForm] = useState(initialCalendarForm); + const [calendarAlert, setCalendarAlert] = useState(null); + const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); + + // Реестр графиков + const [calendarsState, setCalendarsState] = useState({ + loading: true, + error: '', + items: [], + page: 0, + size: 25, + totalItems: 0, + totalPages: 0 + }); + const [calendarSearchValue, setCalendarSearchValue] = useState(''); + const [calendarYearFilter, setCalendarYearFilter] = useState(''); + const calendarsPageRef = useRef(0); + const calendarsSizeRef = useRef(25); + const calendarsQueryRef = useRef(''); + const calendarsYearFilterRef = useRef(''); + const calendarsRequestRef = useRef(0); + + // Сетка годового графика + const [editorCalendar, setEditorCalendar] = useState(null); + const editorCalendarRef = useRef(null); + const [loadedGridCalendarId, setLoadedGridCalendarId] = useState(''); + const loadedGridCalendarRef = useRef(null); + const [gridRows, setGridRows] = useState(null); + const gridRowsRef = useRef(null); + const [gridDirty, setGridDirty] = useState(false); + const [gridAlert, setGridAlert] = useState(null); + const [fillForm, setFillForm] = useState({ course: '', start: '', end: '', activityId: '' }); + const gridLoadRequestRef = useRef(0); + + // Дисциплины графика + const [subjectCalendar, setSubjectCalendar] = useState(null); + const subjectCalendarRef = useRef(null); + const [loadedSubjectCalendarId, setLoadedSubjectCalendarId] = useState(''); + const loadedSubjectCalendarRef = useRef(null); + const [subjectRows, setSubjectRows] = useState([]); + const [subjectsState, setSubjectsState] = useState('idle'); + const [subjectsError, setSubjectsError] = useState(''); + const [subjectDirty, setSubjectDirty] = useState(false); + const [subjectAlert, setSubjectAlert] = useState(null); + const [subjectSemester, setSubjectSemester] = useState(''); + const [subjectIdValue, setSubjectIdValue] = useState(''); + const subjectLoadRequestRef = useRef(0); + + // Диалог ячейки, подсказка и выделение + const [dayDialogKeys, setDayDialogKeys] = useState(null); + const [dayDialogTimeAlert, setDayDialogTimeAlert] = useState(null); + const [tooltipState, setTooltipState] = useState(null); + const [selectedDayKeys, setSelectedDayKeys] = useState([]); + const selectedDayKeysRef = useRef([]); + const dragStateRef = useRef({ dragging: false, didDrag: false }); + const suppressClickRef = useRef(false); + + // Ссылки для прокрутки и фокуса + const yearFormCardRef = useRef(null); + const calendarFormRef = useRef(null); + const calendarGridRef = useRef(null); + const subjectSummaryRef = useRef(null); + const semesterInputRefs = useRef({ autumn: {}, spring: {} }); + const tooltipRef = useRef(null); + const tooltipAnimationRef = useRef(null); + + const gridDirtyScope = useRef(null); + const subjectDirtyScope = useRef(null); + if (!gridDirtyScope.current) { + gridDirtyScope.current = createDirtyScope( + 'academic-calendar-grid', + 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?' + ); + subjectDirtyScope.current = createDirtyScope( + 'academic-calendar-subjects', + 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?' + ); + } + + useEffect(() => () => { + gridDirtyScope.current?.dispose(); + subjectDirtyScope.current?.dispose(); + }, []); + + function setEditorCalendarBoth(calendar) { + editorCalendarRef.current = calendar; + setEditorCalendar(calendar); + } + + function setSubjectCalendarBoth(calendar) { + subjectCalendarRef.current = calendar; + setSubjectCalendar(calendar); + } + + function applyGridRows(rows) { + gridRowsRef.current = rows; + setGridRows(rows); + } + + function setSelectedDayKeysBoth(keys) { + selectedDayKeysRef.current = keys; + setSelectedDayKeys(keys); + } + + function markGridDirty() { + gridDirtyScope.current.mark(); + setGridDirty(true); + } + + function clearGridDirty() { + gridDirtyScope.current.clear(); + setGridDirty(false); + } + + function markSubjectDirty() { + subjectDirtyScope.current.mark(); + setSubjectDirty(true); + } + + function clearSubjectDirty() { + subjectDirtyScope.current.clear(); + setSubjectDirty(false); + } + + // --- Загрузка данных --- + + async function loadYears() { + setYearsState({ loading: true, error: '' }); + try { + const years = await fetchAcademicYearsList(); + setAcademicYears(years); + setYearsState({ loading: false, error: '' }); + const keptFilter = years.some(year => String(year.id) === calendarYearFilter) + ? calendarYearFilter + : ''; + setCalendarYearFilter(keptFilter); + calendarsYearFilterRef.current = keptFilter; + } catch (error) { + setYearsState({ loading: false, error: error.message || 'Ошибка загрузки учебных годов' }); + throw error; + } + } + + async function loadCalendars() { + const request = calendarsRequestRef.current + 1; + calendarsRequestRef.current = request; + setCalendarsState(current => ({ ...current, loading: true, error: '' })); + try { + const pageData = await fetchCalendarsPage({ + page: calendarsPageRef.current, + size: calendarsSizeRef.current, + query: calendarsQueryRef.current, + academicYearId: calendarsYearFilterRef.current + }); + if (request !== calendarsRequestRef.current) return; + if (calendarsPageRef.current > 0 && calendarsPageRef.current >= (pageData.totalPages || 0)) { + calendarsPageRef.current = Math.max(0, (pageData.totalPages || 0) - 1); + return loadCalendars(); + } + setCalendarsState({ + loading: false, + error: '', + items: pageData.items || [], + page: calendarsPageRef.current, + size: calendarsSizeRef.current, + totalItems: pageData.totalItems || 0, + totalPages: pageData.totalPages || 0 + }); + } catch (error) { + if (request !== calendarsRequestRef.current) return; + setCalendarsState(current => ({ + ...current, + loading: false, + error: error.message || 'Ошибка загрузки календарных графиков' + })); + throw error; + } + } + + useEffect(() => { + let active = true; + let yearsLoadError = null; + const yearsPromise = fetchAcademicYearsList().catch(error => { + yearsLoadError = error; + throw error; + }); + (async () => { + try { + const [base, years, subjectsList, forms, activities, scopes, assignments] = await Promise.all([ + (async () => { + const list = await fetchSpecialties(); + const pairs = await Promise.all(list.map(async speciality => [ + String(speciality.id), + await fetchSpecialtyProfiles(speciality.id) + ])); + return { list, profilesBySpecialty: new Map(pairs) }; + })(), + yearsPromise, + fetchSubjectsList(), + fetchEducationForms(), + fetchActivityTypes(), + fetchTimeSlotScopes(), + fetchTimeSlotDateAssignments() + ]); + if (!active) return; + setSpecialties(base.list); + setProfilesBySpecialty(base.profilesBySpecialty); + setAcademicYears(years); + setSubjects(subjectsList); + setStudyForms(forms); + setActivityTypes(activities); + activityTypesRef.current = activities; + setTimeSlotScopes(scopes); + setTimeSlotDateAssignments(assignments); + setBaseLoaded(true); + setYearsState({ loading: false, error: '' }); + await loadCalendars(); + } catch (error) { + if (!active) return; + if (yearsLoadError) { + setYearsState({ loading: false, error: yearsLoadError.message || 'Ошибка загрузки учебных годов' }); + } + setCalendarAlert({ message: error.message || 'Ошибка загрузки календарных графиков', type: 'error' }); + } + })(); + return () => { + active = false; + }; + }, []); + + const debouncedCalendarsSearch = useRef(debounce(() => { + calendarsPageRef.current = 0; + void loadCalendars().catch(() => {}); + }, 300)).current; + + // --- Разделы вкладки --- + + async function switchSection(section) { + if (!await confirmAbandonDirtyChanges()) return; + setActiveSection(section || 'manage'); + } + + // --- Учебные годы --- + + function resetYearForm() { + setYearForm(initialYearForm()); + setYearAlert(null); + } + + function fillYearForm(year, focusedSemesterType = '') { + const semesterFields = type => { + const semester = (year.semesters || []) + .find(item => String(item.semesterType || '').toLowerCase() === type); + return { + id: semester?.id ? String(semester.id) : '', + start: semester?.startDate ? isoDateToInputValue(semester.startDate) : '', + end: semester?.endDate ? isoDateToInputValue(semester.endDate) : '' + }; + }; + setYearForm({ + id: year.id ? String(year.id) : '', + title: `Редактирование учебного года ${year.title || ''}`.trim(), + startYear: String(year.startDate || '').slice(0, 4), + endYear: String(year.endDate || '').slice(0, 4), + autumn: semesterFields('autumn'), + spring: semesterFields('spring') + }); + setYearAlert(null); + yearFormCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (focusedSemesterType) { + semesterInputRefs.current[focusedSemesterType].start?.focus({ preventScroll: true }); + } + } + + function handleStartYearInput(rawValue) { + const result = startYearInputResult(rawValue); + setYearForm(current => { + if (!result.semesterDates) { + return { ...current, startYear: result.startYear, endYear: result.endYear }; + } + return { + ...current, + startYear: result.startYear, + endYear: result.endYear, + autumn: { + ...current.autumn, + start: result.semesterDates.autumn.start, + end: result.semesterDates.autumn.end + }, + spring: { + ...current.spring, + start: result.semesterDates.spring.start, + end: result.semesterDates.spring.end + } + }; + }); + } + + function readYearSemester(type, fallback) { + const fields = yearForm[type]; + const labels = YEAR_SEMESTER_LABELS[type]; + const startDisplay = fields.start || isoDateToInputValue(fallback.startDate); + const endDisplay = fields.end || isoDateToInputValue(fallback.endDate); + return { + id: fields.id ? Number(fields.id) : null, + semesterType: type, + startDate: readDisplayDateIso(startDisplay, labels.start), + endDate: readDisplayDateIso(endDisplay, labels.end) + }; + } + + async function submitYearForm(event) { + event.preventDefault(); + setYearAlert(null); + try { + const defaults = academicYearDefaults(yearForm.startYear); + const payload = { + startDate: defaults.startDate, + endDate: defaults.endDate, + semesters: [ + readYearSemester('autumn', defaults.semesters[0]), + readYearSemester('spring', defaults.semesters[1]) + ] + }; + validateAcademicYearSemesters(payload.semesters, defaults.startDate, defaults.endDate); + await saveAcademicYearRequest(payload, yearForm.id); + setYearAlert({ message: 'Учебный год и семестры сохранены', type: 'success' }); + resetYearForm(); + await loadYears(); + await loadCalendars(); + } catch (error) { + setYearAlert({ message: error.message || 'Ошибка сохранения учебного года', type: 'error' }); + const failedLabel = Object.values(YEAR_SEMESTER_LABELS) + .flatMap(labels => [labels.start, labels.end]) + .find(label => String(error.message || '').startsWith(label)); + if (failedLabel) { + const entry = ['autumn', 'spring'].find(type => + [YEAR_SEMESTER_LABELS[type].start, YEAR_SEMESTER_LABELS[type].end].includes(failedLabel)); + const field = failedLabel.includes('Начало') ? 'start' : 'end'; + semesterInputRefs.current[entry]?.[field]?.focus({ preventScroll: true }); + } + } + } + + async function confirmDeleteYear(id) { + setYearDeleteDialog(null); + try { + await deleteAcademicYearRequest(id); + setYearAlert({ message: 'Учебный год удалён', type: 'success' }); + await loadYears(); + await loadCalendars(); + } catch (error) { + setYearAlert({ message: error.message || 'Ошибка удаления учебного года', type: 'error' }); + } + } + + // --- Форма календарного графика --- + + function resetCalendarForm() { + setCalendarForm(initialCalendarForm()); + setCalendarAlert(null); + } + + async function submitCalendarForm(event) { + event.preventDefault(); + setCalendarAlert(null); + const payload = { + id: calendarForm.id ? Number(calendarForm.id) : null, + academicYearId: Number(calendarForm.yearId), + specialtyId: Number(calendarForm.specialtyId), + specialtyProfileId: Number(calendarForm.profileId), + studyFormId: Number(calendarForm.studyFormId), + courseCount: Number(calendarForm.courseCount) + }; + try { + if (!payload.academicYearId || !payload.specialtyId + || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { + throw new Error('Заполните все поля календарного графика'); + } + const saved = await saveCalendarRequest(payload, calendarForm.id); + if (editorCalendarRef.current && String(editorCalendarRef.current.id) === String(saved.id)) { + setEditorCalendarBoth(saved); + } + if (subjectCalendarRef.current && String(subjectCalendarRef.current.id) === String(saved.id)) { + setSubjectCalendarBoth(saved); + } + setCalendarAlert({ + message: `График "${buildAcademicCalendarTitle(saved)}" сохранён`, + type: 'success' + }); + resetCalendarForm(); + await loadCalendars(); + } catch (error) { + setCalendarAlert({ message: error.message || 'Ошибка сохранения календарного графика', type: 'error' }); + } + } + + function editCalendar(calendar) { + setActiveSection('manage'); + const profiles = profilesBySpecialty.get(String(calendar.specialtyId || '')) || []; + const yearId = calendar.academicYearId + && academicYears.some(year => String(year.id) === String(calendar.academicYearId)) + ? String(calendar.academicYearId) + : ''; + const specialtyId = calendar.specialtyId + && specialties.some(item => String(item.id) === String(calendar.specialtyId)) + ? String(calendar.specialtyId) + : ''; + const profileId = calendar.specialtyProfileId + && profiles.some(profile => String(profile.id) === String(calendar.specialtyProfileId)) + ? String(calendar.specialtyProfileId) + : ''; + const studyFormId = calendar.studyFormId + && studyForms.some(form => String(form.id) === String(calendar.studyFormId)) + ? String(calendar.studyFormId) + : ''; + setCalendarForm({ + id: String(calendar.id ?? ''), + title: 'Редактирование календарного графика', + yearId, + specialtyId, + profileId, + studyFormId, + courseCount: String(calendar.courseCount || 4) + }); + setCalendarAlert(null); + calendarFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function openCalendarGrid(calendar) { + setActiveSection('grid'); + setEditorCalendarBoth(calendar); + void (async () => { + await loadCalendarGrid(calendar); + calendarGridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + })(); + } + + function openCalendarSubjects(calendar) { + setActiveSection('subjects'); + setSubjectCalendarBoth(calendar); + void (async () => { + await loadCalendarSubjects(calendar); + subjectSummaryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + })(); + } + + async function confirmDeleteCalendar(deletedId) { + setCalendarDeleteDialog(null); + try { + await deleteCalendarRequest(deletedId); + if (String(editorCalendarRef.current?.id) === String(deletedId)) { + setEditorCalendarBoth(null); + loadedGridCalendarRef.current = null; + setLoadedGridCalendarId(''); + applyGridRows(null); + setSelectedDayKeysBoth([]); + setTooltipState(null); + clearGridDirty(); + } + if (String(subjectCalendarRef.current?.id) === String(deletedId)) { + setSubjectCalendarBoth(null); + loadedSubjectCalendarRef.current = null; + setLoadedSubjectCalendarId(''); + void loadCalendarSubjects(null); + } + setCalendarAlert({ message: 'Календарный график удалён', type: 'success' }); + await loadCalendars(); + } catch (error) { + setCalendarAlert({ message: error.message || 'Ошибка удаления календарного графика', type: 'error' }); + } + } + + // --- Сетка годового графика --- + + async function loadCalendarGrid(calendar = editorCalendarRef.current) { + const request = gridLoadRequestRef.current + 1; + gridLoadRequestRef.current = request; + setGridAlert(null); + if (gridDirtyScope.current.isDirty() && !await confirmAbandonDirtyChanges()) { + setEditorCalendarBoth(loadedGridCalendarRef.current); + return; + } + if (request !== gridLoadRequestRef.current) return; + if (!calendar) { + setGridAlert({ message: 'Выберите календарный график', type: 'error' }); + return; + } + try { + const assignments = await fetchTimeSlotDateAssignments(); + const rows = await fetchCalendarGrid(calendar.id); + if (request !== gridLoadRequestRef.current + || String(calendar.id) !== String(editorCalendarRef.current?.id)) { + return; + } + setTimeSlotDateAssignments(assignments); + const nextRows = rows.length + ? rows.map(row => ({ ...row, activityTypeId: effectiveActivityTypeId(row, activityTypesRef.current) })) + : buildDefaultGrid(calendar, activityTypesRef.current); + applyGridRows(nextRows); + setSelectedDayKeysBoth([]); + setTooltipState(null); + setFillForm(current => ({ ...current, course: '' })); + loadedGridCalendarRef.current = calendar; + setLoadedGridCalendarId(String(calendar.id)); + clearGridDirty(); + trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 }); + setGridAlert({ + message: rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', + type: 'success' + }); + } catch (error) { + if (request !== gridLoadRequestRef.current) return; + setGridAlert({ message: error.message || 'Ошибка загрузки сетки', type: 'error' }); + } + } + + async function saveCalendarGrid() { + setGridAlert(null); + const calendar = editorCalendarRef.current; + if (!calendar) { + setGridAlert({ message: 'Выберите календарный график', type: 'error' }); + return; + } + if (String(calendar.id) !== loadedGridCalendarId) { + setGridAlert({ message: 'Сначала загрузите сетку выбранного графика', type: 'error' }); + return; + } + const rows = (gridRows || []).map(row => ({ + calendarId: calendar.id, + courseNumber: Number(row.courseNumber), + date: row.date, + weekNumber: Number(row.weekNumber), + dayOfWeek: Number(row.dayOfWeek), + activityTypeId: Number(row.activityTypeId) + })); + if (!rows.length) { + setGridAlert({ message: 'Сначала загрузите сетку графика', type: 'error' }); + return; + } + try { + await saveCalendarGridRequest(calendar.id, rows); + clearGridDirty(); + trackUxEvent('calendar_grid_saved', { calendarId: calendar.id, cells: rows.length }); + setGridAlert({ message: 'Календарный учебный график сохранён', type: 'success' }); + await loadCalendarGrid(calendar); + } catch (error) { + setGridAlert({ message: error.message || 'Ошибка сохранения сетки', type: 'error' }); + } + } + + function applyCalendarFill() { + setGridAlert(null); + const activityId = fillForm.activityId; + if (!activityId || !fillForm.start || !fillForm.end) { + setGridAlert({ message: 'Выберите даты и код активности для заполнения', type: 'error' }); + return; + } + let start; + let end; + try { + start = readDisplayDateIso(fillForm.start, 'Дата начала диапазона'); + end = readDisplayDateIso(fillForm.end, 'Дата окончания диапазона'); + } catch (error) { + setGridAlert({ message: error.message, type: 'error' }); + return; + } + if (end < start) { + setGridAlert({ message: 'Дата окончания не может быть раньше даты начала', type: 'error' }); + return; + } + const course = fillForm.course; + let changed = 0; + const nextRows = (gridRows || []).map(row => { + const sameCourse = !course || String(row.courseNumber) === String(course); + const inRange = row.date >= start && row.date <= end; + if (sameCourse && inRange) { + changed += 1; + return { ...row, activityTypeId: activityId }; + } + return row; + }); + if (changed) { + applyGridRows(nextRows); + markGridDirty(); + } + setGridAlert({ message: `Обновлено ячеек: ${changed}`, type: 'success' }); + } + + // --- Выделение ячеек и подсказка --- + + function hideDayTooltip() { + setTooltipState(null); + } + + function showDayTooltip(row, event = null) { + if (dragStateRef.current.dragging) { + hideDayTooltip(); + return; + } + const rect = event?.currentTarget?.getBoundingClientRect?.(); + const x = Number.isFinite(event?.clientX) ? event.clientX : (rect ? rect.right : 0); + const y = Number.isFinite(event?.clientY) ? event.clientY : (rect ? rect.bottom : 0); + setTooltipState({ row, x, y }); + } + + useEffect(() => { + if (!tooltipState || !tooltipRef.current) return; + const tooltip = tooltipRef.current; + const rect = tooltip.getBoundingClientRect(); + const { left, top } = calculateCalendarTooltipPosition({ + pointerX: tooltipState.x, + pointerY: tooltipState.y, + tooltipWidth: rect.width, + tooltipHeight: rect.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight + }); + tooltipAnimationRef.current?.cancel(); + const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`; + tooltipAnimationRef.current = tooltip.animate( + [ + { transform: positionedTransform }, + { transform: positionedTransform } + ], + { duration: 1, fill: 'forwards' } + ); + }, [tooltipState]); + + function startDayDragSelection(event, key) { + if (event.button !== 0) return; + hideDayTooltip(); + dragStateRef.current = { dragging: true, didDrag: false }; + suppressClickRef.current = false; + setSelectedDayKeysBoth([key]); + event.preventDefault(); + } + + function addDayToDragSelection(key) { + if (!dragStateRef.current.dragging || selectedDayKeysRef.current.includes(key)) return; + dragStateRef.current.didDrag = true; + setSelectedDayKeysBoth([...selectedDayKeysRef.current, key]); + } + + function openDayDialogForKeys(keys) { + const uniqueKeys = Array.from(new Set(keys)); + const rows = uniqueKeys + .map(key => (gridRowsRef.current || []).find(row => calendarRowKey(row) === key)) + .filter(Boolean); + if (!rows.length) return; + setSelectedDayKeysBoth(uniqueKeys); + setDayDialogKeys(uniqueKeys); + } + + useEffect(() => { + function finishDragSelection() { + if (!dragStateRef.current.dragging) return; + dragStateRef.current.dragging = false; + if (dragStateRef.current.didDrag && selectedDayKeysRef.current.length > 1) { + suppressClickRef.current = true; + openDayDialogForKeys(selectedDayKeysRef.current); + } + } + document.addEventListener('mouseup', finishDragSelection); + return () => document.removeEventListener('mouseup', finishDragSelection); + }, []); + + function handleDayClick(event, key) { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.preventDefault(); + return; + } + openDayDialogForKeys([key]); + } + + function handleDayKeyDown(event, key) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openDayDialogForKeys([key]); + } + + function closeDayDialog() { + setDayDialogKeys(null); + } + + function handleDialogActivityChange(event) { + const value = event.target.value; + if (!value) return; + const keys = new Set(dayDialogKeys || []); + applyGridRows((gridRowsRef.current || []).map(row => ( + keys.has(calendarRowKey(row)) ? { ...row, activityTypeId: value } : row + ))); + markGridDirty(); + } + + async function clearTimeSlotDateAssignment() { + if (!dayDialogKeys || dayDialogKeys.length !== 1) return; + setDayDialogTimeAlert(null); + const date = (gridRowsRef.current || []) + .find(row => calendarRowKey(row) === dayDialogKeys[0])?.date; + const assignment = timeAssignmentForDate(timeSlotDateAssignments, date); + if (!assignment) { + setDayDialogTimeAlert({ message: 'Для даты нет ручной сетки', type: 'success' }); + return; + } + try { + await deleteTimeSlotDateAssignmentRequest(assignment.id); + const assignments = await fetchTimeSlotDateAssignments(); + setTimeSlotDateAssignments(assignments); + setDayDialogTimeAlert({ message: 'Ручная сетка убрана', type: 'success' }); + } catch (error) { + setDayDialogTimeAlert({ message: error.message || 'Ошибка удаления ручной сетки', type: 'error' }); + } + } + + // --- Дисциплины графика --- + + async function loadCalendarSubjects(calendar = subjectCalendarRef.current) { + const request = subjectLoadRequestRef.current + 1; + subjectLoadRequestRef.current = request; + setSubjectAlert(null); + if (subjectDirtyScope.current.isDirty() && !await confirmAbandonDirtyChanges()) { + setSubjectCalendarBoth(loadedSubjectCalendarRef.current); + return; + } + if (request !== subjectLoadRequestRef.current) return; + if (!calendar) { + setSubjectSemester(''); + } else { + const count = calendarSemesterCount(calendar); + setSubjectSemester(current => ( + current && Number(current) >= 1 && Number(current) <= count ? current : '1' + )); + } + setSubjectRows([]); + setLoadedSubjectCalendarId(''); + loadedSubjectCalendarRef.current = null; + if (!calendar) { + clearSubjectDirty(); + setSubjectsState('idle'); + return; + } + setSubjectsState('loading'); + try { + const rows = await fetchCalendarSubjects(calendar.id); + if (request !== subjectLoadRequestRef.current + || String(calendar.id) !== String(subjectCalendarRef.current?.id)) { + return; + } + setSubjectRows(sortCalendarSubjectRows(rows || [])); + setLoadedSubjectCalendarId(String(calendar.id)); + loadedSubjectCalendarRef.current = calendar; + clearSubjectDirty(); + setSubjectsState('ready'); + } catch (error) { + if (request !== subjectLoadRequestRef.current) return; + setSubjectsError(error.message || 'Ошибка загрузки дисциплин графика'); + setSubjectsState('error'); + setSubjectAlert({ message: error.message || 'Ошибка загрузки дисциплин графика', type: 'error' }); + } + } + + function addCalendarSubject() { + setSubjectAlert(null); + const calendar = subjectCalendarRef.current; + if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { + setSubjectAlert({ message: 'Дождитесь загрузки дисциплин выбранного графика', type: 'error' }); + return; + } + const semesterNumber = Number(subjectSemester); + const subjectId = Number(subjectIdValue); + if (!calendar || !semesterNumber || !subjectId) { + setSubjectAlert({ message: 'Выберите график, семестр и дисциплину', type: 'error' }); + return; + } + if (subjectRows.some(row => + Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId + )) { + setSubjectAlert({ message: 'Дисциплина уже добавлена в этот семестр', type: 'error' }); + return; + } + const subject = subjects.find(item => Number(item.id) === subjectId); + if (!subject) { + setSubjectAlert({ message: 'Дисциплина не найдена в справочнике', type: 'error' }); + return; + } + setSubjectRows(sortCalendarSubjectRows([ + ...subjectRows, + { + id: null, + calendarId: calendar.id, + semesterNumber, + subjectId, + subjectName: subject.name, + subjectCode: subject.code, + departmentId: subject.departmentId + } + ])); + markSubjectDirty(); + setSubjectAlert({ message: 'Дисциплина добавлена. Сохраните привязки.', type: 'success' }); + } + + async function saveCalendarSubjects() { + setSubjectAlert(null); + const calendar = subjectCalendarRef.current; + if (calendar && String(calendar.id) !== loadedSubjectCalendarId) { + setSubjectAlert({ message: 'Дождитесь загрузки дисциплин выбранного графика', type: 'error' }); + return; + } + if (!calendar) { + setSubjectAlert({ message: 'Выберите календарный график', type: 'error' }); + return; + } + const payload = subjectRows.map(row => ({ + semesterNumber: Number(row.semesterNumber), + subjectId: Number(row.subjectId) + })); + try { + const saved = await saveCalendarSubjectsRequest(calendar.id, payload); + const sorted = sortCalendarSubjectRows(saved || []); + setSubjectRows(sorted); + clearSubjectDirty(); + trackUxEvent('calendar_subjects_saved', { calendarId: calendar.id, count: sorted.length }); + setSubjectAlert({ message: 'Привязки дисциплин сохранены', type: 'success' }); + } catch (error) { + setSubjectAlert({ message: error.message || 'Ошибка сохранения дисциплин графика', type: 'error' }); + } + } + + function removeSubjectRow(row) { + setSubjectRows(current => current.filter(item => + !(Number(item.semesterNumber) === Number(row.semesterNumber) + && Number(item.subjectId) === Number(row.subjectId)) + )); + markSubjectDirty(); + setSubjectAlert({ message: 'Дисциплина убрана. Сохраните привязки.', type: 'success' }); + } + + // --- Производные данные --- + + const sortedSubjects = useMemo( + () => [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')), + [subjects] + ); + + const profiles = profilesBySpecialty.get(String(calendarForm.specialtyId)) || []; + const profileEmptyLabel = !calendarForm.specialtyId + ? (specialties.length ? 'Нет профилей' : 'Выберите специальность') + : (profiles.length ? 'Выберите профиль' : 'Нет профилей'); + + const fillCourseOptions = useMemo(() => { + if (!gridRows) return []; + return Array.from(new Set(gridRows.map(row => String(row.courseNumber)))) + .sort((a, b) => Number(a) - Number(b)); + }, [gridRows]); + + const courseBlocks = useMemo(() => { + if (!gridRows) return []; + const grouped = groupBy(gridRows, row => row.courseNumber); + return Array.from(grouped.entries()).map(([course, courseRows]) => ({ + course, + courseRows, + blocks: semesterBlocksForCourse(editorCalendar, Number(course), courseRows, academicYears) + })); + }, [gridRows, editorCalendar, academicYears]); + + const totals = useMemo( + () => gridTotalsEntries(gridRows || [], activityTypes), + [gridRows, activityTypes] + ); + + const selectedCode = useMemo(() => { + if (!selectedDayKeys.length || !gridRows) return null; + const rows = selectedDayKeys + .map(key => gridRows.find(row => calendarRowKey(row) === key)) + .filter(Boolean); + if (!rows.length) return null; + const activity = activityById(commonActivityValue(rows), activityTypes); + return activity?.code || null; + }, [selectedDayKeys, gridRows, activityTypes]); + + const dayDialogRows = useMemo(() => { + if (!dayDialogKeys) return []; + const keys = new Set(dayDialogKeys); + return (gridRows || []).filter(row => keys.has(calendarRowKey(row))); + }, [dayDialogKeys, gridRows]); + + const subjectSemesterCount = subjectCalendar ? calendarSemesterCount(subjectCalendar) : 0; + + // --- Рендер сетки --- + + function renderCalendarDay(row) { + const key = calendarRowKey(row); + const activity = activityById(row.activityTypeId, activityTypes) || activityTypes[0] || {}; + const code = activity.code || row.activityCode || 'Т'; + const assignment = timeAssignmentForDate(timeSlotDateAssignments, row.date); + const isSelected = selectedDayKeys.includes(key); + return ( + startDayDragSelection(event, key)} + onMouseOver={() => addDayToDragSelection(key)} + onMouseMove={event => showDayTooltip(row, event)} + onClick={event => handleDayClick(event, key)} + onKeyDown={event => handleDayKeyDown(event, key)} + onFocus={() => showDayTooltip(row)} + > + {code} + {assignment ? вр. : null} + + ); + } + + function renderSemesterTable(rows, targetWeekColumns) { + if (!rows.length) { + return
Нет дат в этом семестре
; + } + const rowsByWeek = groupBy(rows, row => row.weekNumber); + const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); + const weekSlots = [...weeks]; + while (weekSlots.length < targetWeekColumns) { + weekSlots.push(null); + } + const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); + return ( +
+ + + + {weekSlots.map((week, index) => ( + + ))} + + + + + {weekSlots.map((week, index) => week === null + ? )} + + + + {DAY_OPTIONS.map(day => ( + + + {weekSlots.map((week, index) => { + if (week === null) { + return ( + + ))} + +
День{week}
{day.shortLabel}; + })} +
+
+ ); + } + + // --- Рендер --- + + const tooltipParts = tooltipState + ? dayTooltipParts(tooltipState.row, activityTypes, timeSlotScopes, timeSlotDateAssignments) + : null; + + return ( +
+
+ + + +
+ + + + + + + + {dayDialogKeys && dayDialogRows.length > 0 && ( + void clearTimeSlotDateAssignment()} + onClose={closeDayDialog} + alert={dayDialogTimeAlert} + /> + )} + + {yearDeleteDialog && ( + void confirmDeleteYear(yearDeleteDialog.id)} + onClose={() => setYearDeleteDialog(null)} + /> + )} + + {calendarDeleteDialog && ( + void confirmDeleteCalendar(calendarDeleteDialog.id)} + onClose={() => setCalendarDeleteDialog(null)} + /> + )} + + {tooltipState && tooltipParts && createPortal( +
+
+ {tooltipParts.code} +
+ {tooltipParts.longDate} + {tooltipParts.shortDay}, {tooltipParts.week} неделя +
+
+
+
+ Курс + {tooltipParts.course} +
+
+ Активность + {tooltipParts.activityName} +
+
+ Время + {tooltipParts.timeLabel} +
+
+
, + document.body + )} +
+ ); +} diff --git a/frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx b/frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx new file mode 100644 index 0000000..415ae90 --- /dev/null +++ b/frontend/react/admin/tabs/AuditoriumWorkloadTab.jsx @@ -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 ( +
+
+

Загруженность

+ +
+ +
+
+ + +
+ +
+ + +
+ + {showOverviewFilters && ( +
+ + +
+ )} + + {showOverviewFilters && ( +
+ + setMinCapacityText(event.target.value)} + /> +
+ )} + + {showOverviewFilters && ( +
+ + +
+ )} + +
+ + handleDateChange(event.target.value)} + /> +
+
+ + + + {!roomMode && ( +
+
{summaryText}
+ +
+ + + + + {overview.timeSlots.map(slot => ( + + ))} + + + + {overview.loading ? ( + + + + ) : overview.error ? ( + + + + ) : gridRows.length === 0 ? ( + + + + ) : gridRows.map(entity => ( + + ))} + +
+ Время + {targetConfig.rowLabel} + + {slot.orderNumber || ''} пара + {timeSlotLabel(slot)} + {slot.scopeApplyMode !== 'DEFAULT' && {scopeShort(slot)}} +
{overview.message}
{overview.error}
+ {targetConfig.emptyLabel} +
+
+
+ )} + + {roomMode && ( +
+
+ {detail.loading || detail.error || !detail.summary ? '' : ( + <> +
+ {detail.summary.name} + {detail.summary.details && {detail.summary.details}} +
+ {detail.summary.rangeLabel} + {detail.summary.occupancyLabel} + {detail.summary.warnings && ( + {detail.summary.warnings} + )} + + )} +
+
+ {detail.loading ? ( +
{detail.message}
+ ) : detail.error ? ( +
{detail.error}
+ ) : !detail.weekTable ? ( +
+ За выбранный период нет временных слотов +
+ ) : ( + + )} +
+
+ )} +
+ ); +} + +function WorkloadRow({ target, entity, timeSlots, lessonsByEntitySlot }) { + const axis = entityAxisParts(target, entity); + const axisClass = `axis-cell${target === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`; + + return ( + + + {axis.title} + {axis.subtitle} + {axis.note && {axis.note}} + + {timeSlots.map(slot => { + const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []; + return ( + + {lessons.length + ? lessons.map((lesson, index) => ) + : Свободно} + + ); + })} + + ); +} + +function LessonCard({ lesson }) { + const subgroupText = (lesson.subgroupNames || []).join(', '); + return ( +
+
{lesson.subjectName || 'Без дисциплины'}
+
{(lesson.groupNames || []).join(', ') || 'Группа не указана'}
+ {subgroupText &&
{subgroupText}
} +
{lesson.teacherName || 'Преподаватель не указан'}
+
+ {[lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · ')} +
+
+ ); +} + +function RoomWeekTable({ target, semesters, datesByDay, slotRows, slotsByDate, lessonsByDateSlot }) { + const config = WORKLOAD_TARGETS[target] || WORKLOAD_TARGETS[TARGET_CLASSROOMS]; + return ( +
+
+

{config.periodTitle}

+ {roomWeekCaption(datesByDay, semesters)} +
+
+ + + + + {DAY_ORDER.map(day => ( + + ))} + + + + {slotRows.map(row => ( + + + {DAY_ORDER.map(day => ( + + ))} + + ))} + +
Время + {dayShort(day)} + {DAY_FULL_LABELS[day] || ''} +
+ {CELL_PARITY_LAYOUT.map(({ parity, label }) => ( + + {label}: {datesByDay[day]?.[parity] + ? formatDateShort(datesByDay[day][parity]) + : '-'} + + ))} +
+
+ {row.orderLabel} + {row.timeLabel} +
+
+
+ ); +} + +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 ( + +
+ {state.kind === 'busy' + ? state.lessons.map((lesson, index) => ) + : state.kind === 'missing' ? Нет слота + : state.kind === 'empty' ? Нет даты + : Свободно} +
+ + ); + } + + return ( + + {states.map(({ parity, label, state }) => ( +
+ {label} + {state.kind === 'busy' + ? state.lessons.map((lesson, index) => ) + : state.kind === 'missing' ? Нет слота + : state.kind === 'empty' ? Нет даты + : Свободно} +
+ ))} + + ); +} + +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 ( +
+ +
+
+ {options.length === 0 ? ( +
Нет данных
+ ) : options.map(option => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/react/admin/tabs/ClassroomsTab.jsx b/frontend/react/admin/tabs/ClassroomsTab.jsx new file mode 100644 index 0000000..140a685 --- /dev/null +++ b/frontend/react/admin/tabs/ClassroomsTab.jsx @@ -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 ( + <> +
+
+

Новая аудитория

+ +
+
+
+
+ + setCreateName(event.target.value)} + /> +
+
+ + setCreateCapacity(event.target.value)} + /> +
+
+
+
+ + +
+
+ +
+
+
+ {createAlert.text} +
+
+
+ +
+

Список аудиторий

+
+ + + + + + + + + + + + + {loadError ? ( + + ) : classrooms === null ? ( + + ) : classrooms.length === 0 ? ( + + ) : classrooms.map(classroom => ( + + + + + + + + + ))} + +
IDНазваниеВместимостьОборудованиеСтатусДействия
Ошибка загрузки данных
Нет аудиторий
{classroom.id}{classroom.name}{classroom.capacity} чел.{equipmentNamesText(classroom) || '—'} +
+ + {classroomStatusLabel(classroom)} + + +
+
+
+ + {classroom.status === 'ARCHIVED' ? ( + + ) : ( + + )} +
+
+
+
+ + {editModal && ( +
{ + if (event.target === event.currentTarget) setEditModal(null); + }} + > +
+

Редактировать аудиторию

+ +
+ +
+
+ + setEditModal({ ...editModal, name: event.target.value })} + /> +
+
+ + setEditModal({ ...editModal, capacity: event.target.value })} + /> +
+
+
+
+ + +
+
+ +
+
+
+ {editModal.alert.text} +
+
+
+
+ )} + + {equipmentModal && ( +
{ + if (event.target === event.currentTarget) setEquipmentModal(null); + }} + > +
+
+

Справочник оборудования

+ +
+
+

Добавить оборудование

+
+
+
+ + setEquipmentModal({ ...equipmentModal, name: event.target.value })} + /> +
+ +
+
+ {equipmentModal.alert.text} +
+
+
+
+ + + + + + + + + + {equipmentModal.list === null ? ( + + ) : equipmentModal.list.length === 0 ? ( + + ) : equipmentModal.list.map(equipment => ( + + + + + + ))} + +
IDНазваниеДействия
Загрузка...
Нет оборудования
{equipment.id}{equipment.name} + +
+
+
+
+ )} + + {confirmState && ( + void runConfirm()} + onClose={() => setConfirmState(null)} + /> + )} + + {alertState && ( + 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 ( +
+ +
+
+ {equipments.length === 0 ? ( +

Оборудование отсутствует

+ ) : selection.map(item => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/react/admin/tabs/DashboardTab.jsx b/frontend/react/admin/tabs/DashboardTab.jsx new file mode 100644 index 0000000..59ec6f2 --- /dev/null +++ b/frontend/react/admin/tabs/DashboardTab.jsx @@ -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 ( +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+

Мониторинг пар (Идет сейчас)

+
+ {ready ? currentSlotLabel(currentSlot) : '—'} +
+
+
+ + + + + + + + + + + {!ready ? ( + + ) : !currentSlot ? ( + + ) : currentLessons.length === 0 ? ( + + ) : currentLessons.map(lesson => ( + + + + + + + ))} + +
АудиторияДисциплинаПреподавательГруппа
Определение текущего времени...
В данный момент учебные пары не проводятся
В текущий слот занятий не запланировано
{lesson.classroomName} + {lesson.subjectName}{' '} + {lesson.lessonTypeName} + {lesson.teacherName}{lesson.groupNames.join(', ')}
+
+
+ +
+
+

Свободные аудитории (ближайшие)

+
+ {!ready ? ( +
Загрузка...
+ ) : freeRooms.length === 0 ? ( + Все аудитории заняты + ) : freeRooms.map(classroom => ( + + {classroom.name} ({classroom.capacity} м.) + + ))} +
+
+ + +
+
+ +
+
+
+

+ + + + + + Центр предупреждений (Red Zone) +

+
Анализ накладок и коллизий расписания на текущую неделю
+
+ +
+ +
+ {conflictPhase === 'checking' ? ( +
Анализ расписания и выявление конфликтов...
+ ) : conflictPhase === 'error' ? ( + + ) : ( + <> + {conflicts.presentation && ( + + )} + {conflicts.items.map((conflict, index) => ( + + ))} + + )} +
+
+
+ ); +} + +function MetricCard({ tone, label, value, children }) { + return ( +
+
+ + {children} + +
+
+
{label}
+

{value}

+
+
+ ); +} + +function CheckStateCard({ tone, title, description, meta = '' }) { + const icon = tone === 'success' + ? + : tone === 'warning' + ? ( + <> + + + + ) + : ( + <> + + + + ); + return ( +
+ + + {title} + {description} + {meta ? {meta} : null} + +
+ ); +} + +function ConflictCard({ conflict }) { + const isError = conflict.severity === 'error'; + return ( +
+
+ {isError ? ( + + ) : ( + + )} +
+
+

{conflict.title}

+

+ {(conflict.segments || []).map((segment, index) => ( + typeof segment === 'string' + ? segment + : {segment.strong} + ))} +

+
+
+ ); +} diff --git a/frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx b/frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx new file mode 100644 index 0000000..6c922bb --- /dev/null +++ b/frontend/react/admin/tabs/DepartmentWorkspaceTab.jsx @@ -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 ( + <> +
+
+

Рабочая область кафедры

+ +
+ +
+ {!isDepartmentRole && ( +
+ + +
+ )} +
+ + handleStartDateChange(event.target.value)} + /> +
+
+ + handleEndDateChange(event.target.value)} + /> +
+
+ + +
+ +
+
+
+

Загрузка дисциплин

+
+
+
+
+ + +
+
+ {importRows.map((row, index) => ( +
+
+ + updateImportRow(index, { code: event.target.value })} + /> +
+
+ + updateImportRow(index, { name: event.target.value })} + /> +
+ +
+ ))} +
+
+ + +
+
+ +
+
+

Добавить преподавателя

+
+
+
+ + +
+
+ + setAssignComment(event.target.value)} + /> +
+ + +
+
+ +
+
+

Заявка на преподавателя

+
+
+
+ + setRequestForm(current => ( + { ...current, username: event.target.value } + ))} + /> +
+
+ + setRequestForm(current => ( + { ...current, fullName: event.target.value } + ))} + /> +
+
+ + setRequestForm(current => ( + { ...current, jobTitle: event.target.value } + ))} + /> +
+
+ + setRequestForm(current => ( + { ...current, comment: event.target.value } + ))} + /> +
+ + +
+
+ +
+
+

Преподаватели и нагрузка

+
+
+ {metricRows === null ? ( + 'Загрузка...' + ) : metricRows.length === 0 ? ( +
+ {teachers.error || 'Преподаватели не найдены'} +
+ ) : metricRows.map((row, index) => ( +
+ {row.name || '-'} + {row.meta || 'Должность не указана'} + {workloadLabel(row.workload, workload.error)} +
+ ))} +
+
+
+ +
+
+

Заявки кафедры

+ {requestsCountLabel} +
+
+ + + + + + + + + + + + + {requests.items === null && !requests.error ? ( + + + + ) : requests.error ? ( + + + + ) : requests.items.length === 0 ? ( + + + + ) : requests.items.map(request => ( + + + + + + + + + ))} + +
СтатусЛогинФИОДолжностьКомментарийРассмотрение
+ Загрузка... +
+ {requests.error} +
+ Заявок пока нет +
+ + {requestStatusLabel(request.status)} + + {request.username || '-'}{request.fullName || '-'}{request.jobTitle || '-'}{request.comment || '-'}{request.reviewComment || formatDateTime(request.reviewedAt) || '-'}
+
+
+ +
+
+

Дисциплины кафедры

+ {subjectsCountLabel} +
+
+ + + + + + + + + + + {subjects.loading ? ( + + + + ) : subjects.error ? ( + + + + ) : subjects.items.length === 0 ? ( + + + + ) : subjects.items.map(subject => ( + + + + + + + ))} + +
КодНазваниеОписаниеКомментарии
+ Загрузка... +
+ {subjects.error} +
+ Дисциплины не найдены +
{subject.code || '-'}{subject.name || '-'}{subject.description || '-'} + +
+
+
+ + {comments && ( +
+
+

+ Комментарии: {comments.subjectName || 'дисциплина'} +

+ +
+ +
+
+ + setCommentText(event.target.value)} + /> +
+ +
+ +
+ {comments.items === null ? ( + 'Загрузка...' + ) : comments.error ? ( +
{comments.error}
+ ) : comments.items.length === 0 ? ( +
Комментариев пока нет
+ ) : comments.items.map((comment, index) => ( +
+ {comment.authorName || 'Автор не указан'} + {formatDateTime(comment.createdAt)} +

{comment.comment}

+
+ ))} +
+
+ )} + + ); +} diff --git a/frontend/react/admin/tabs/GroupsTab.jsx b/frontend/react/admin/tabs/GroupsTab.jsx new file mode 100644 index 0000000..069a0f4 --- /dev/null +++ b/frontend/react/admin/tabs/GroupsTab.jsx @@ -0,0 +1,1279 @@ +import { useEffect, useRef, useState } from 'react'; + +import { readUrlState, readUrlValues, 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 { + GROUP_SORT_OPTIONS, + GROUP_TABLE_COLUMNS, + SUBGROUP_COUNT_OPTIONS, + archiveGroup, + assignmentCalendarTitle, + assignmentSubjectsGroups, + calendarOptionDescription, + calendarOptionLabel, + createGroup, + deleteCalendarAssignment, + deleteSubgroup, + departmentLabel, + departmentOptionLabel, + efFilterLabel, + fetchAcademicYears, + fetchCalendarAssignments, + fetchCalendarsOptions, + fetchDepartments, + fetchEducationForms, + fetchGroupsPage, + fetchProfilesForSpecialties, + fetchSpecialties, + fetchSubgroups, + groupsEmptyMessage, + groupsUrlState, + groupPayloadFromFields, + initialGroupsState, + restoreGroup, + saveCalendarAssignment, + sortSubgroups, + specialityLabel, + specialityOptionLabel, + statusBadgeClass, + statusLabel, + subjectLabel, + suggestedSubgroupCapacities, + syncSubgroups, + updateGroup, + validateSubgroupCapacities +} from '../groups-service.js'; + +const SEARCH_DEBOUNCE_MS = 320; + +export function GroupsTab() { + const [educationForms, setEducationForms] = useState([]); + const [departments, setDepartments] = useState([]); + const [specialties, setSpecialties] = useState([]); + const [academicYears, setAcademicYears] = useState([]); + const [profilesBySpecialty, setProfilesBySpecialty] = useState(() => new Map()); + const [referenceError, setReferenceError] = useState(''); + + const [state, setState] = useState(() => initialGroupsState( + typeof window === 'undefined' ? {} : { + ...readUrlState({ + groupQuery: '', + groupPage: '0', + groupSize: '25', + groupSort: 'name,asc' + }), + groupEducationForm: readUrlValues('groupEducationForm') + } + )); + const [searchInput, setSearchInput] = useState(() => state.query); + const [pageData, setPageData] = useState(null); + const [tableError, setTableError] = useState(''); + const [tableLoading, setTableLoading] = useState(true); + + const [createForm, setCreateForm] = useState({ + name: '', + groupSize: '', + educationFormId: '', + departmentId: '', + yearStartStudy: '', + specialtyId: '', + specialtyProfileId: '' + }); + const [createAlert, setCreateAlert] = useState({ text: '', isError: false }); + + const [editModal, setEditModal] = useState(null); + const [subgroupsModal, setSubgroupsModal] = useState(null); + const [calendarModal, setCalendarModal] = 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; + const groupsRef = useRef([]); + groupsRef.current = pageData?.items || []; + const capacityInputRefs = useRef(new Map()); + + useEffect(() => { + let active = true; + (async () => { + try { + const [forms, loadedDepartments, loadedSpecialties, years] = await Promise.all([ + fetchEducationForms(), + fetchDepartments(), + fetchSpecialties(), + fetchAcademicYears() + ]); + const profiles = await fetchProfilesForSpecialties(loadedSpecialties); + if (!active) return; + setEducationForms(forms || []); + setDepartments(loadedDepartments || []); + setSpecialties(loadedSpecialties || []); + setAcademicYears(years || []); + setProfilesBySpecialty(profiles); + setCreateForm(current => ( + current.educationFormId ? current : { ...current, educationFormId: String((forms || [])[0]?.id || '') } + )); + await loadGroups(stateRef.current); + } catch (error) { + if (active) setReferenceError(error.message || 'Не удалось загрузить справочники'); + } + })(); + return () => { + active = false; + requestNumber.current += 1; + }; + }, []); + + useEffect(() => () => { + window.clearTimeout(searchTimer.current); + }, []); + + async function loadGroups(nextState) { + const currentRequest = requestNumber.current + 1; + requestNumber.current = currentRequest; + setTableLoading(true); + setTableError(''); + try { + let page = await fetchGroupsPage(nextState); + if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) { + nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) }; + page = await fetchGroupsPage(nextState); + } + if (requestNumber.current !== currentRequest) return; + setState(nextState); + setPageData(page); + setTableLoading(false); + writeUrlState(groupsUrlState(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 loadGroups({ ...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); + } + + function resetFilters() { + setSearchInput(''); + void loadGroups({ + ...stateRef.current, + query: '', + page: 0, + sort: 'name,asc', + educationFormIds: [] + }); + } + + function updateCreateForm(patch) { + setCreateForm(current => ({ ...current, ...patch })); + } + + async function submitCreate(event) { + event.preventDefault(); + const { payload, error } = groupPayloadFromFields(createForm); + if (error) { + setCreateAlert({ text: error, isError: true }); + return; + } + try { + const created = await createGroup(payload); + setCreateAlert({ text: `Группа «${created.name || payload.name}» создана`, isError: false }); + setCreateForm({ + name: '', + groupSize: '', + educationFormId: String((educationForms || [])[0]?.id || ''), + departmentId: '', + yearStartStudy: '', + specialtyId: '', + specialtyProfileId: '' + }); + await loadGroups(stateRef.current); + } catch (err) { + setCreateAlert({ text: err.message || 'Ошибка создания', isError: true }); + } + } + + function openEditModal(group) { + setEditModal({ + group, + form: { + name: group.name || '', + groupSize: group.groupSize ? String(group.groupSize) : '', + educationFormId: group.educationFormId ? String(group.educationFormId) : '', + departmentId: group.departmentId ? String(group.departmentId) : '', + yearStartStudy: group.yearStartStudy ? String(group.yearStartStudy) : '', + specialtyId: String(group.specialtyId || group.specialityCode || ''), + specialtyProfileId: group.specialtyProfileId ? String(group.specialtyProfileId) : '' + }, + alert: { text: '', isError: false } + }); + } + + async function submitEdit(event) { + event.preventDefault(); + const modal = editModal; + const { payload, error } = groupPayloadFromFields(modal.form); + if (error) { + setEditModal({ ...modal, alert: { text: error, isError: true } }); + return; + } + try { + const updated = await updateGroup(modal.group.id, payload); + setEditModal(null); + setCreateAlert({ text: `Группа «${updated.name || payload.name}» обновлена`, isError: false }); + await loadGroups(stateRef.current); + } catch (err) { + setEditModal({ ...modal, alert: { text: err.message || 'Ошибка обновления', isError: true } }); + } + } + + function confirmArchiveGroup(group) { + setConfirmState({ + title: 'Архивировать группу?', + message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.', + confirmLabel: 'Архивировать', + danger: true, + action: async () => { + try { + await archiveGroup(group.id); + await loadGroups(stateRef.current); + } catch (error) { + setAlertState({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' }); + } + } + }); + } + + async function restore(group) { + try { + await restoreGroup(group.id); + await loadGroups(stateRef.current); + } catch (error) { + setAlertState({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' }); + } + } + + // --- Подгруппы --- + + async function openSubgroupsModal(group) { + setSubgroupsModal({ + group, + subgroups: null, + count: '0', + capacities: [], + alert: { text: '', isError: false } + }); + try { + const loaded = await fetchSubgroups(group.id); + setSubgroupsModal(current => (current ? { ...current, ...subgroupFormState(loaded || [], group) } : current)); + } catch (error) { + setSubgroupsModal(current => ( + current ? { ...current, alert: { text: error.message || 'Не удалось загрузить подгруппы', isError: true } } : current + )); + } + } + + function subgroupFormState(subgroups, group) { + const count = [2, 3].includes(subgroups.length) ? String(subgroups.length) : '0'; + return { + subgroups, + count, + capacities: capacityFields(Number(count), subgroups.map(item => item.studentCapacity), group), + alert: { text: '', isError: false } + }; + } + + function capacityFields(count, existingCapacities, group) { + if (!count) return []; + const suggested = suggestedSubgroupCapacities(Number(group?.groupSize || 0), count); + return Array.from({ length: count }, (_, index) => { + const value = existingCapacities[index] ?? suggested[index] ?? ''; + return value === '' || value === null ? '' : String(value); + }); + } + + function changeSubgroupCount(count) { + setSubgroupsModal(current => { + if (!current) return current; + return { + ...current, + count, + capacities: capacityFields(Number(count), [], current.group) + }; + }); + } + + function changeSubgroupCapacity(index, value) { + setSubgroupsModal(current => { + if (!current) return current; + const capacities = [...current.capacities]; + capacities[index] = value; + return { ...current, capacities }; + }); + } + + async function submitSubgroups(event) { + event.preventDefault(); + const modal = subgroupsModal; + const group = modal.group; + const existing = sortSubgroups(modal.subgroups || []); + const count = Number(modal.count); + + if (count === 0) { + try { + for (const subgroup of existing) { + await deleteSubgroup(group.id, subgroup.id); + } + const loaded = await fetchSubgroups(group.id); + setSubgroupsModal(current => ( + current ? { ...current, ...subgroupFormState(loaded || [], group), alert: { text: 'Деление на подгруппы отключено', isError: false } } : current + )); + } catch (error) { + setSubgroupsModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка отключения подгрупп', isError: true } } : current + )); + } + return; + } + + const capacities = modal.capacities.map(Number); + const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count); + if (validationError) { + setSubgroupsModal({ ...modal, alert: { text: validationError, isError: true } }); + return; + } + + try { + await syncSubgroups(group.id, existing, capacities); + const loaded = await fetchSubgroups(group.id); + setSubgroupsModal(current => ( + current ? { ...current, ...subgroupFormState(loaded || [], group), alert: { text: 'Подгруппы сохранены', isError: false } } : current + )); + } catch (error) { + setSubgroupsModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка сохранения подгруппы', isError: true } } : current + )); + } + } + + function focusSubgroupInput(subgroupId) { + const modal = subgroupsModal; + if (!modal) return; + const sorted = sortSubgroups(modal.subgroups || []); + const index = sorted.findIndex(item => item.id === subgroupId); + capacityInputRefs.current.get(index)?.focus(); + } + + function confirmDeleteSubgroup(subgroup) { + const modal = subgroupsModal; + if (!modal) return; + if (sortSubgroups(modal.subgroups || []).length !== 1) { + setSubgroupsModal({ + ...modal, + alert: { text: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', isError: true } + }); + return; + } + setConfirmState({ + title: 'Удалить подгруппу?', + message: 'Удаление возможно только если подгруппа не используется в расписании.', + confirmLabel: 'Удалить', + danger: true, + action: async () => { + try { + await deleteSubgroup(modal.group.id, subgroup.id); + const loaded = await fetchSubgroups(modal.group.id); + setSubgroupsModal(current => ( + current + ? { ...current, ...subgroupFormState(loaded || [], current.group), alert: { text: 'Подгруппа удалена', isError: false } } + : current + )); + } catch (error) { + setSubgroupsModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка удаления подгруппы', isError: true } } : current + )); + } + } + }); + } + + // --- Календарные графики --- + + async function openCalendarModal(group) { + setCalendarModal({ + group, + yearId: academicYears.length ? String(academicYears[0].id) : '', + selectedCalendar: null, + assignments: null, + alert: { text: '', isError: false } + }); + await reloadAssignments(group.id); + } + + async function reloadAssignments(groupId) { + try { + const assignments = await fetchCalendarAssignments(groupId); + setCalendarModal(current => (current ? { ...current, assignments: assignments || [] } : current)); + } catch (error) { + setCalendarModal(current => ( + current ? { ...current, assignments: [], alert: { text: error.message || 'Не удалось загрузить назначения графиков', isError: true } } : current + )); + } + } + + function changeCalendarYear(yearId) { + setCalendarModal(current => (current ? { ...current, yearId, selectedCalendar: null } : current)); + } + + async function searchCalendars(query, selectedId) { + const modal = calendarModal; + if (!modal?.group || !modal.yearId) return []; + const group = modal.group; + return fetchCalendarsOptions({ + query, + academicYearId: modal.yearId, + specialtyId: String(group.specialtyId || group.specialityCode), + profileId: String(group.specialtyProfileId), + studyFormId: String(group.educationFormId), + selectedId + }); + } + + async function submitAssignment(event) { + event.preventDefault(); + const modal = calendarModal; + if (!modal.yearId || !modal.selectedCalendar) { + setCalendarModal({ ...modal, alert: { text: 'Выберите учебный год и график', isError: true } }); + return; + } + try { + await saveCalendarAssignment(modal.group.id, { + academicYearId: modal.yearId, + calendarId: modal.selectedCalendar.id + }); + setCalendarModal(current => ( + current ? { ...current, alert: { text: 'Календарный график назначен', isError: false } } : current + )); + await reloadAssignments(modal.group.id); + } catch (error) { + setCalendarModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка назначения графика', isError: true } } : current + )); + } + } + + function confirmDeleteAssignment(assignment) { + const modal = calendarModal; + if (!modal) return; + setConfirmState({ + title: 'Удалить назначение графика?', + message: 'Группа потеряет связь с этим графиком на выбранный учебный год.', + confirmLabel: 'Удалить назначение', + danger: true, + action: async () => { + try { + await deleteCalendarAssignment(modal.group.id, assignment.id); + setCalendarModal(current => ( + current ? { ...current, alert: { text: 'Назначение удалено', isError: false } } : current + )); + await reloadAssignments(modal.group.id); + } catch (error) { + setCalendarModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка удаления назначения', isError: true } } : current + )); + } + } + }); + } + + async function runConfirm() { + const confirm = confirmState; + setConfirmState(null); + await confirm.action(); + } + + const groups = pageData?.items || []; + const createProfileOptions = profilesBySpecialty.get(String(createForm.specialtyId)) || []; + + return ( + <> +
+

Новая группа

+
+
+
+ + updateCreateForm({ name: event.target.value })} + /> +
+
+ + updateCreateForm({ groupSize: event.target.value })} + /> +
+
+ + +
+
+ + +
+
+ + updateCreateForm({ yearStartStudy: event.target.value })} + /> +
+
+ + +
+
+ + +
+ +
+
+ {createAlert.text} +
+
+
+ +
+
+

Все группы

+
+ + applyState({ educationFormIds: ids, page: 0 })} + /> +
+
+
+
+ + handleSearchInput(event.target.value)} + /> +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + {referenceError ? ( + + ) : tableError ? ( + + + + ) : tableLoading ? ( + + ) : groups.length === 0 ? ( + + ) : groups.map(group => ( + + + + + + + + + + + + + ))} + +
IDНазваниеЧисленность (чел.)Форма обученияКафедраКурсСпециальностьПрофильСтатусДействия
Ошибка загрузки данных: {referenceError}
+ {tableError} + +
{groupsEmptyMessage(state)}
{group.id}{group.name}{group.groupSize}{group.educationFormName}{departmentLabel(departments, group.departmentId)}{group.course || '-'}{specialityLabel(specialties, group.specialtyId || group.specialityCode)}{group.specialtyProfileName || '-'}{statusLabel(group)} +
+ + + + {group.status === 'ARCHIVED' ? ( + + ) : ( + + )} +
+
+
+ {pageData && ( + applyState({ page })} + onSize={size => applyState({ size, page: 0 })} + /> + )} +
+ + {editModal && ( +
{ + if (event.target === event.currentTarget) setEditModal(null); + }} + > +
+

Редактировать группу

+ +
+
+
+ + setEditModal(current => ( + current ? { ...current, form: { ...current.form, name: event.target.value } } : current + ))} + /> +
+
+ + setEditModal(current => ( + current ? { ...current, form: { ...current.form, groupSize: event.target.value } } : current + ))} + /> +
+
+ + +
+
+
+
+ + +
+
+ + setEditModal(current => ( + current ? { ...current, form: { ...current.form, yearStartStudy: event.target.value } } : current + ))} + /> +
+
+ + +
+
+ + +
+
+ +
+
+
+ {editModal.alert.text} +
+
+
+
+ )} + + {subgroupsModal && ( +
{ + if (event.target === event.currentTarget) setSubgroupsModal(null); + }} + > +
+
+
+

Подгруппы группы

+
+ Группа: {subgroupsModal.group.name} ({subgroupsModal.group.groupSize} чел.) +
+
+ +
+
+

Настройка подгрупп

+
+
+
+ + +
+
+ {subgroupsModal.capacities.map((value, index) => ( +
+ + { + if (element) capacityInputRefs.current.set(index, element); + else capacityInputRefs.current.delete(index); + }} + onChange={event => changeSubgroupCapacity(index, event.target.value)} + /> +
+ ))} +
+
+ +
+
+
+ {subgroupsModal.alert.text} +
+
+
+
+ + + + + + + + + + {subgroupsModal.subgroups === null ? ( + + ) : sortSubgroups(subgroupsModal.subgroups).length === 0 ? ( + + ) : (() => { + const list = sortSubgroups(subgroupsModal.subgroups); + const canDeleteSingleSubgroup = list.length === 1; + const deleteTitle = canDeleteSingleSubgroup + ? 'Удалить подгруппу' + : 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»'; + return list.map(sub => ( + + + + + + )); + })()} + +
ПодгруппаЧисленностьДействия
Загрузка подгрупп...
Подгруппы не созданы
{sub.name}{sub.studentCapacity ?? '-'} чел. + + +
+
+
+
+ )} + + {calendarModal && ( +
{ + if (event.target === event.currentTarget) setCalendarModal(null); + }} + > +
+
+
+

Календарные графики группы

+
+ Группа: {calendarModal.group.name} ({specialityLabel(specialties, calendarModal.group.specialtyId || calendarModal.group.specialityCode)}) +
+
+ +
+
+

Назначить график

+
+
+
+ + +
+
+ + searchCalendars(query, selectedId)} + optionLabel={calendarOptionLabel} + optionDescription={calendarOptionDescription} + onSelect={calendar => setCalendarModal(current => ( + current ? { ...current, selectedCalendar: calendar } : current + ))} + selectedLabel={calendarModal.selectedCalendar + ? calendarOptionLabel(calendarModal.selectedCalendar) + : ''} + selectedValue={calendarModal.selectedCalendar + ? String(calendarModal.selectedCalendar.id) + : ''} + /> +
+ +
+
+ {calendarModal.alert.text} +
+
+
+
+ + + + + + + + + + + {calendarModal.assignments === null ? ( + + ) : calendarModal.assignments.length === 0 ? ( + + ) : calendarModal.assignments.map(assignment => ( + + + + + + + ))} + +
Учебный годГрафикДисциплиныДействия
Загрузка...
Календарные графики не назначены
{assignment.academicYearTitle}{assignmentCalendarTitle(assignment)} + {!(assignment.subjects || []).length ? ( + Не привязаны + ) : ( +
+ {assignmentSubjectsGroups(assignment.subjects).map(({ semesterNumber, items }) => ( +
+ {semesterNumber} семестр + {items.map(subject => subjectLabel(subject)).join(', ')} +
+ ))} +
+ )} +
+ +
+
+
+
+ )} + + {confirmState && ( + void runConfirm()} + onClose={() => setConfirmState(null)} + /> + )} + + {alertState && ( + setAlertState(null)} + /> + )} + + ); +} + +function EducationFormFilter({ educationForms, selectedIds, onChange }) { + 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 selected = new Set(selectedIds || []); + const allChecked = selected.size === 0; + + function toggleAll() { + onChange([]); + } + + function toggleForm(id, checked) { + const next = new Set(selected); + if (checked) { + next.add(String(id)); + } else { + next.delete(String(id)); + } + onChange([...next]); + } + + return ( +
+ + {open && ( +
+
+ + {(educationForms || []).map(form => { + const id = String(form.id); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/react/admin/tabs/ScheduleQualityTab.jsx b/frontend/react/admin/tabs/ScheduleQualityTab.jsx new file mode 100644 index 0000000..b1bb860 --- /dev/null +++ b/frontend/react/admin/tabs/ScheduleQualityTab.jsx @@ -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 => ) + : ) + : ; + + const semesterOptions = semestersPhase === 'error' + ? + : (semestersPhase === 'ready' || semestersPhase === 'empty' + ? (yearSemesters.length + ? yearSemesters.map(semester => ( + + )) + : ) + : ); + + const versionPlaceholder = { + idle: 'Сначала выберите семестр', + loading: 'Загрузка версий…', + empty: 'Версии не созданы', + error: 'Ошибка загрузки' + }[versionPhase]; + const versionOptions = versionPhase === 'ready' + ? versions.map(version => ( + + )) + : ; + + const archived = analysis?.scheduleVersionStatus === 'ARCHIVED'; + + return ( +
+
+
+ Диагностика · публикация или черновик +

Качество видно до перестановок

+

+ Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения + пожеланий. Все проблемные занятия объединяются по правилу, а их штрафы + суммируются. Черновик можно оценить до публикации. +

+
+
+ + + + + + + +
+
+ +
+ {status.message} +
+ + +
+ ); +} + +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 ( + <> +
+ Правило #{problem.scheduleRuleId || '—'} +

{problem.subjectName || problem.title}

+

Затронуто {affectedLessonCount} {lessonWord(affectedLessonCount)}; суммарный штраф −{problem.penalty}.

+
+
+ {issueLabels.map((label, index) => {label})} +
+ {canEdit ? ( + + ) :

Архивное расписание нельзя изменить.

} + + ); +} diff --git a/frontend/react/admin/tabs/ScheduleTab.jsx b/frontend/react/admin/tabs/ScheduleTab.jsx new file mode 100644 index 0000000..ca62d49 --- /dev/null +++ b/frontend/react/admin/tabs/ScheduleTab.jsx @@ -0,0 +1,2368 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + academicYearsFromSemesters, + resolveAcademicPeriod, + semestersForAcademicYear +} from '../../../admin/js/academic-period.js'; +import { confirmAction } from '../../../admin/js/dialog.js'; +import { confirmAbandonDirtyChanges, createDirtyScope } from '../../../admin/js/dirty-state.js'; +import { trackUxEvent } from '../../../telemetry.js'; +import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; +import { FormAlert } from '../../shared/ui/FormAlert.jsx'; +import { Pagination } from '../../shared/ui/Pagination.jsx'; +import { + SCHEDULE_OPEN_RULE_STORAGE_KEY, + SCHEDULE_OPEN_VERSION_STORAGE_KEY, + SCHEDULE_SEMESTER_STORAGE_KEY, + SCHEDULE_VERSION_STORAGE_KEY, + SCHEDULE_YEAR_STORAGE_KEY, + createScheduleDraftVersion, + fetchScheduleVersions +} from '../schedule-versions-service.js'; +import { fetchClassroomOptions, fetchTeacherOptions } from '../schedule-view-service.js'; +import { + CELL_PARITY_LAYOUT, + DAY_OPTIONS, + LESSON_TYPE_LIMITS, + PARITY_LABELS, + WHOLE_GROUP_SUBGROUP_VALUE, + buildRulePayloadFromForm, + buildRulePayloadFromRule, + buildVisualLessonsByCell, + buildVisualRows, + calculateVisualContextMenuPosition, + chooseScheduleVersion, + collectVisualGroups, + collectVisualSlotsFromRules, + conflictFieldClass, + conflictReasonLabels, + conflictSlotCurrentText, + fetchAcademicYears, + fetchApprovedTeacherPreferences, + fetchLessonTypes, + fetchRuleGroupOptions, + fetchScheduleRule, + fetchScheduleRules, + fetchScheduleRulesPage, + fetchSubgroupsForGroups, + fetchSubjects, + fetchTimeSlots, + formatLocalDate, + formatSemester, + formSlotsToPayloads, + groupCountLabel, + isLaboratorySlot, + isScheduleRuleConflictError, + matchingSubgroupsForGroups, + mergeEntitiesById, + orderedLessonTypes, + preferenceHintPills, + ruleCountLabel, + ruleDraftMeta, + ruleDraftTitle, + ruleMeta, + ruleSlotToPayload, + ruleTitle, + rowCountLabel, + saveScheduleRule, + deleteScheduleRule, + scheduleSlotActionLabel, + scheduleVersionOptionGroups, + semesterLabel, + shortSemesterLabel, + slotSubgroupIds, + slotSubgroupNames, + slotSubgroupText, + subgroupLabel, + toTimeSlotOption, + uniqueVisualLessons, + versionContextTexts, + visualSlotFromTimeSlot +} from '../schedule-service.js'; + +// Волна 6 (этап 5): вкладка «Конструктор правил», перенесённая с legacy +// admin/js/views/schedule.js + admin/views/schedule.html без изменения +// поведения: те же контракты API, идентификаторы DOM и русские тексты. + +const PARITY_OPTIONS = Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })); +const LESSON_FORMAT_OPTIONS = [ + { value: 'Очно', label: 'Очно' }, + { value: 'Онлайн', label: 'Онлайн' } +]; + +function clonePlain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function emptySlot() { + return { + id: null, + dayOfWeek: '', + parity: 'BOTH', + timeSlotId: '', + teacherId: '', + teacherName: '', + classroomId: '', + classroomName: '', + lessonTypeId: '', + lessonFormat: 'Очно', + subgroupIds: [] + }; +} + +function slotFromRule(slot = {}) { + return { + id: slot.id || null, + dayOfWeek: slot.dayOfWeek ?? '', + parity: slot.parity || 'BOTH', + timeSlotId: slot.timeSlotId ?? '', + teacherId: slot.teacherId != null ? String(slot.teacherId) : '', + teacherName: slot.teacherName || '', + classroomId: slot.classroomId != null ? String(slot.classroomId) : '', + classroomName: slot.classroomName || '', + lessonTypeId: slot.lessonTypeId ?? '', + lessonFormat: slot.lessonFormat || 'Очно', + subgroupIds: slotSubgroupIds(slot) + }; +} + +function emptyTypeLimitsState() { + return LESSON_TYPE_LIMITS.reduce((state, config) => { + state[config.key] = { hours: '0', startWeek: '1' }; + return state; + }, {}); +} + +function typeLimitsStateFromRule(rule) { + return LESSON_TYPE_LIMITS.reduce((state, config) => { + state[config.key] = { + hours: String(rule?.[config.hoursField] ?? 0), + startWeek: String(rule?.[config.startWeekField] ?? 1) + }; + return state; + }, {}); +} + +function typeLimitsPayload(state) { + return LESSON_TYPE_LIMITS.reduce((payload, config) => { + payload[config.hoursField] = Number(state[config.key].hours || 0); + payload[config.startWeekField] = Number(state[config.key].startWeek || 1); + return payload; + }, {}); +} + +function findRuleSlotIndex(rule, target) { + if (!rule?.slots?.length) return -1; + if (target.slotId) { + const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId)); + if (byId >= 0) return byId; + } + const index = Number(target.slotIndex); + return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1; +} + +function effectiveVisualPeriodFrom({ academicYears, semesters, visualYearId, visualSemesterId }) { + const yearValue = academicYears.some(year => String(year.id) === String(visualYearId)) + ? String(visualYearId) + : (academicYears[0] ? String(academicYears[0].id) : ''); + const yearSemesters = semesters.filter(semester => String(semester.academicYearId) === yearValue); + const semesterValue = yearSemesters.some(semester => String(semester.id) === String(visualSemesterId)) + ? String(visualSemesterId) + : (yearSemesters[0] ? String(yearSemesters[0].id) : ''); + return { yearId: yearValue, semesterId: semesterValue, yearSemesters }; +} + +const teacherOptionLabel = teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`; +const classroomOptionLabel = classroom => classroom.name || `Аудитория ${classroom.id}`; +const teacherOptionDescription = teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '); +const classroomOptionDescription = classroom => [ + classroom.building, + classroom.floor != null ? `${classroom.floor} этаж` : '', + classroom.capacity ? `${classroom.capacity} мест` : '' +].filter(Boolean).join(' · '); + +export function ScheduleTab() { + const [initialPhase, setInitialPhase] = useState('loading'); + const [subjects, setSubjects] = useState([]); + const [lessonTypes, setLessonTypes] = useState([]); + const [timeSlots, setTimeSlots] = useState([]); + const [academicYears, setAcademicYears] = useState([]); + const [semesters, setSemesters] = useState([]); + const [approvedPreferences, setApprovedPreferences] = useState([]); + + const [groups, setGroups] = useState([]); + const [groupsPhase, setGroupsPhase] = useState('loading'); + const [groupsError, setGroupsError] = useState(''); + const [groupSearchQuery, setGroupSearchQuery] = useState(''); + const [groupsMenuOpen, setGroupsMenuOpen] = useState(false); + + const [subgroups, setSubgroups] = useState([]); + const [subgroupsLoading, setSubgroupsLoading] = useState(false); + + const [versionYearId, setVersionYearId] = useState(''); + const [versionSemesterId, setVersionSemesterId] = useState(''); + const [scheduleVersions, setScheduleVersions] = useState([]); + const [versionId, setVersionId] = useState(''); + + const [rules, setRules] = useState([]); + const [rulesPhase, setRulesPhase] = useState('loading'); + const [rulesMessage, setRulesMessage] = useState('Загрузка...'); + const [rulesPageData, setRulesPageData] = useState(null); + const [rulesPage, setRulesPage] = useState(0); + const [rulesSize, setRulesSize] = useState(25); + const [rulesSearch, setRulesSearch] = useState(''); + + const [ruleAlert, setRuleAlert] = useState(null); + const [ruleId, setRuleId] = useState(''); + const [ruleSubjectId, setRuleSubjectId] = useState(''); + const [typeLimits, setTypeLimits] = useState(emptyTypeLimitsState); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + const [slots, setSlots] = useState([emptySlot()]); + const [subgroupMenuOpenIndex, setSubgroupMenuOpenIndex] = useState(null); + + const [draftOpen, setDraftOpen] = useState(false); + const [draftName, setDraftName] = useState(''); + const [draftBusy, setDraftBusy] = useState(false); + const [draftError, setDraftError] = useState(''); + + const [conflict, setConflict] = useState(null); + const [conflictAlert, setConflictAlert] = useState(null); + const [conflictBusy, setConflictBusy] = useState(false); + + const [visualOpen, setVisualOpen] = useState(false); + const [visualYearId, setVisualYearId] = useState(''); + const [visualSemesterId, setVisualSemesterId] = useState(''); + const [visualSelectedGroupIds, setVisualSelectedGroupIds] = useState([]); + const [visualGroupFilterTouched, setVisualGroupFilterTouched] = useState(false); + const [visualPeriodTouched, setVisualPeriodTouched] = useState(false); + const [visualRules, setVisualRules] = useState([]); + const [visualStageMessage, setVisualStageMessage] = useState('Загрузка...'); + const [contextMenu, setContextMenu] = useState(null); + const [moveSlot, setMoveSlot] = useState(null); + const [moveSlotAlert, setMoveSlotAlert] = useState(null); + const [moveSlotBusy, setMoveSlotBusy] = useState(false); + + // Отказ от смены версии при несохранённых изменениях: контролируемые + // селекты возвращаются к состоянию после принудительного ре-рендера. + const [, bumpSelectSync] = useState(0); + + const ruleFormRef = useRef(null); + const groupsMenuRef = useRef(null); + const contextMenuRef = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuAnimationRef = useRef(null); + const draftNameInputRef = useRef(null); + const rulesSearchTimerRef = useRef(null); + const groupSearchTimerRef = useRef(null); + const rulesRequestRef = useRef(0); + const visualRequestRef = useRef(0); + const groupSearchRequestRef = useRef(0); + const subgroupRequestRef = useRef(0); + const rulesVersionIdRef = useRef(''); + + const dirtyScopeRef = useRef(null); + if (!dirtyScopeRef.current) { + dirtyScopeRef.current = createDirtyScope( + 'schedule-constructor', + 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?' + ); + } + + useEffect(() => () => { + dirtyScopeRef.current?.dispose(); + window.clearTimeout(rulesSearchTimerRef.current); + window.clearTimeout(groupSearchTimerRef.current); + }, []); + + const selectedVersion = useMemo( + () => scheduleVersions.find(version => String(version.id) === String(versionId)) || null, + [scheduleVersions, versionId] + ); + const versionContext = useMemo(() => versionContextTexts(selectedVersion), [selectedVersion]); + const editorDisabled = !selectedVersion; + const ruleSemesterId = selectedVersion ? String(selectedVersion.semesterId) : ''; + + const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]); + const yearSemesters = useMemo( + () => semestersForAcademicYear(semesters, versionYearId), + [semesters, versionYearId] + ); + const orderedLessonTypeOptions = useMemo( + () => orderedLessonTypes(lessonTypes).map(type => ({ value: type.id, label: type.name || type.lessonType || `ID ${type.id}` })), + [lessonTypes] + ); + const timeSlotOptions = useMemo(() => timeSlots.map(toTimeSlotOption), [timeSlots]); + + const effectiveVisualPeriod = useMemo( + () => effectiveVisualPeriodFrom({ academicYears, semesters, visualYearId, visualSemesterId }), + [academicYears, semesters, visualYearId, visualSemesterId] + ); + + const latestRef = useRef(null); + latestRef.current = { + subjects, lessonTypes, timeSlots, academicYears, semesters, approvedPreferences, + groups, subgroups, selectedGroupIds, slots, typeLimits, ruleId, ruleSubjectId, + scheduleVersions, versionId, versionYearId, versionSemesterId, selectedVersion, + rules, visualRules, rulesPage, rulesSize, rulesSearch, + visualOpen, visualPeriodTouched, visualYearId, visualSemesterId, + conflict, contextMenu, moveSlot, draftBusy + }; + + // --- Инициализация --- + + useEffect(() => { + let cancelled = false; + + async function initialize() { + try { + const [, , yearData] = await Promise.all([ + loadBaseLists(), + loadTimeSlotsList(), + loadYears(), + loadPreferences() + ]); + if (cancelled) return; + const versionState = await loadScheduleVersions({ + semesterId: yearData.semesterId, + yearId: yearData.academicYearId + }); + if (cancelled) return; + setSlots([emptySlot()]); + await loadRules({ version: versionState.selected, semesters: yearData.semesters }); + dirtyScopeRef.current?.clear(); + } catch (error) { + if (cancelled) return; + setRuleAlert({ message: error.message || 'Ошибка загрузки данных расписания', type: 'error' }); + } finally { + if (!cancelled) setInitialPhase('ready'); + } + } + + void initialize(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function loadBaseLists() { + const [subjectList, lessonTypeList] = await Promise.all([fetchSubjects(), fetchLessonTypes()]); + setSubjects(subjectList); + setLessonTypes(lessonTypeList); + setSubgroups([]); + await searchRuleGroups(''); + } + + async function loadTimeSlotsList() { + const slotList = await fetchTimeSlots(); + setTimeSlots(slotList); + } + + async function loadPreferences() { + const preferences = await fetchApprovedTeacherPreferences(); + setApprovedPreferences(preferences || []); + } + + async function loadYears() { + const yearList = await fetchAcademicYears(); + const flatSemesters = yearList.flatMap(year => year.semesters || []); + setAcademicYears(yearList); + setSemesters(flatSemesters); + const selected = resolveAcademicPeriod(flatSemesters, { + academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY), + semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY), + date: formatLocalDate(new Date()) + }); + setVersionYearId(selected.academicYearId); + setVersionSemesterId(selected.semesterId); + selectDefaultVisualPeriod({ semesters: flatSemesters, formSemesterId: '', rules: [] }); + return { + years: yearList, + semesters: flatSemesters, + academicYearId: selected.academicYearId, + semesterId: selected.semesterId + }; + } + + function defaultScheduleSemesterId(semesterList) { + return resolveAcademicPeriod(semesterList, { + academicYearId: window.localStorage.getItem(SCHEDULE_YEAR_STORAGE_KEY), + semesterId: window.localStorage.getItem(SCHEDULE_SEMESTER_STORAGE_KEY), + date: formatLocalDate(new Date()) + }).semesterId; + } + + async function loadScheduleVersions({ preferredVersionId = null, semesterId: semesterIdOverride, yearId: yearIdOverride } = {}) { + const latest = latestRef.current; + const semesterId = semesterIdOverride ?? latest.versionSemesterId ?? defaultScheduleSemesterId(latest.semesters); + if (!semesterId) { + setScheduleVersions([]); + setVersionId(''); + return { versions: [], selected: null }; + } + window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, yearIdOverride ?? latest.versionYearId ?? ''); + window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, semesterId); + const loaded = (await fetchScheduleVersions(semesterId)) + .filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status)); + const requestedVersionId = preferredVersionId + || window.localStorage.getItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY) + || window.localStorage.getItem(SCHEDULE_VERSION_STORAGE_KEY) + || ''; + window.localStorage.removeItem(SCHEDULE_OPEN_VERSION_STORAGE_KEY); + const selected = chooseScheduleVersion(loaded, semesterId, requestedVersionId); + setScheduleVersions(loaded); + setVersionId(selected ? String(selected.id) : ''); + if (selected) { + window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, String(selected.id)); + } + return { versions: loaded, selected }; + } + + async function loadRules(overrides = {}) { + const latest = { ...latestRef.current, ...overrides }; + const request = ++rulesRequestRef.current; + visualRequestRef.current += 1; + setVisualRules([]); + setRules([]); + setRulesPageData(null); + setRulesPhase('loading'); + setRulesMessage('Загрузка...'); + const version = overrides.version !== undefined + ? overrides.version + : (latest.scheduleVersions || []).find(item => String(item.id) === String(latest.versionId)) || null; + if (!version) { + setRulesPhase('empty'); + setRulesMessage('Нет расписания для редактирования'); + setVisualStageMessage('Нет выбранного расписания'); + return; + } + let page = overrides.page !== undefined ? overrides.page : latest.rulesPage; + if (rulesVersionIdRef.current !== String(version.id)) { + rulesVersionIdRef.current = String(version.id); + page = 0; + } + const size = overrides.size !== undefined ? overrides.size : latest.rulesSize; + const query = overrides.query !== undefined ? overrides.query : latest.rulesSearch; + setRulesPage(page); + try { + const pageData = await fetchScheduleRulesPage({ versionId: version.id, page, size, query }); + if (request !== rulesRequestRef.current) return; + if (page > 0 && page >= pageData.totalPages) { + const lastPage = Math.max(0, pageData.totalPages - 1); + setRulesPage(lastPage); + return loadRules({ ...overrides, page: lastPage }); + } + setRules(pageData.items || []); + setRulesPageData(pageData); + setRulesPhase('ready'); + if (!latest.visualPeriodTouched) { + selectDefaultVisualPeriod({ + semesters: overrides.semesters ?? latest.semesters, + formSemesterId: version.semesterId, + rules: pageData.items || [] + }); + } + if (latest.visualOpen) await loadVisualRules(version); + await openRequestedRule(version); + } catch (error) { + if (request !== rulesRequestRef.current) return; + setRulesPhase('error'); + setRulesMessage(`Ошибка загрузки: ${error.message}`); + setVisualStageMessage('Ошибка загрузки правил расписания'); + } + } + + async function openRequestedRule(version) { + const requestedRuleId = window.localStorage.getItem(SCHEDULE_OPEN_RULE_STORAGE_KEY); + if (!requestedRuleId) return; + window.localStorage.removeItem(SCHEDULE_OPEN_RULE_STORAGE_KEY); + const rule = await fetchScheduleRule(requestedRuleId); + if (!rule || String(rule.scheduleVersionId) !== String(version?.id)) { + setRuleAlert({ message: 'Не удалось найти выбранное правило в этом расписании.', type: 'error' }); + return; + } + await fillRuleForm(rule); + } + + // --- Версии расписания --- + + async function handleVersionYearChange(event) { + const yearId = event.target.value || ''; + if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) { + bumpSelectSync(value => value + 1); + return; + } + const nextYearSemesters = semestersForAcademicYear(latestRef.current.semesters, yearId); + const selected = resolveAcademicPeriod(nextYearSemesters, { date: formatLocalDate(new Date()) }); + await applyVersionPeriodChange(yearId, selected.semesterId); + } + + async function handleVersionSemesterChange(event) { + if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) { + bumpSelectSync(value => value + 1); + return; + } + await applyVersionPeriodChange(latestRef.current.versionYearId, event.target.value || ''); + } + + async function applyVersionPeriodChange(yearId, semesterId) { + setVersionYearId(yearId); + setVersionSemesterId(semesterId); + window.localStorage.setItem(SCHEDULE_YEAR_STORAGE_KEY, yearId); + window.localStorage.setItem(SCHEDULE_SEMESTER_STORAGE_KEY, semesterId); + try { + const versionState = await loadScheduleVersions({ semesterId, yearId }); + resetRuleForm(); + await loadRules({ version: versionState.selected }); + } catch (error) { + setRuleAlert({ message: error.message || 'Не удалось сменить учебный период', type: 'error' }); + } + } + + async function handleVersionChange(event) { + const requestedValue = event.target.value || ''; + if (dirtyScopeRef.current?.isDirty() && !(await confirmAbandonDirtyChanges())) { + bumpSelectSync(value => value + 1); + return; + } + if (requestedValue) { + window.localStorage.setItem(SCHEDULE_VERSION_STORAGE_KEY, requestedValue); + } + setVersionId(requestedValue); + resetRuleForm(); + const selected = latestRef.current.scheduleVersions + .find(version => String(version.id) === String(requestedValue)) || null; + await loadRules({ version: selected }); + } + + function openDraftModal() { + const base = latestRef.current.selectedVersion; + if (!base || latestRef.current.draftBusy) return; + setDraftName(''); + setDraftError(''); + setDraftOpen(true); + } + + useEffect(() => { + if (draftOpen) draftNameInputRef.current?.focus(); + }, [draftOpen]); + + function closeDraftModal() { + if (latestRef.current.draftBusy) return; + setDraftOpen(false); + } + + async function createDraft(event) { + event.preventDefault(); + const base = latestRef.current.selectedVersion; + const name = draftName.trim(); + if (!base || !name || latestRef.current.draftBusy) return; + setDraftBusy(true); + try { + const created = await createScheduleDraftVersion({ + semesterId: Number(base.semesterId), + name, + basedOnVersionId: Number(base.id) + }); + setDraftOpen(false); + const versionState = await loadScheduleVersions({ preferredVersionId: created.id }); + resetRuleForm(); + await loadRules({ version: versionState.selected }); + setRuleAlert({ message: `Черновик «${created.name}» создан и открыт.`, type: 'success' }); + } catch (error) { + setDraftError(error.message || 'Не удалось создать черновик.'); + } finally { + setDraftBusy(false); + } + } + + // --- Справочники формы --- + + async function searchRuleGroups(query = '', selectedIdsOverride = null) { + const request = ++groupSearchRequestRef.current; + const selectedBeforeSearch = selectedIdsOverride ?? latestRef.current.selectedGroupIds; + if (!latestRef.current.groups.length) { + setGroupsPhase('loading'); + } + try { + const loaded = await fetchRuleGroupOptions(query); + if (request !== groupSearchRequestRef.current) return; + const currentSelected = selectedIdsOverride ?? latestRef.current.selectedGroupIds; + const selectedIds = currentSelected.length ? currentSelected : selectedBeforeSearch; + setGroups(current => { + const selectedGroups = current.filter(group => selectedIds.map(String).includes(String(group.id))); + return mergeEntitiesById([...selectedGroups, ...(loaded || [])]); + }); + setGroupsPhase('ready'); + } catch (error) { + if (request !== groupSearchRequestRef.current) return; + setGroupsPhase('error'); + setGroupsError(error.message || 'Не удалось найти группы'); + } + } + + function handleGroupSearchInput(event) { + const value = event.target.value; + setGroupSearchQuery(value); + window.clearTimeout(groupSearchTimerRef.current); + groupSearchTimerRef.current = window.setTimeout(() => void searchRuleGroups(value), 320); + } + + function toggleRuleGroupsMenu() { + setGroupsMenuOpen(current => !current); + setSubgroupMenuOpenIndex(null); + } + + function handleRuleGroupsKeydown(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleRuleGroupsMenu(); + } + if (event.key === 'Escape') { + setGroupsMenuOpen(false); + } + } + + async function handleGroupCheckboxChange(event) { + const value = Number(event.target.value); + const previous = latestRef.current.selectedGroupIds; + const next = event.target.checked + ? [...previous, value] + : previous.filter(id => id !== value); + dirtyScopeRef.current?.mark(); + setSelectedGroupIds(next); + await loadSubgroupsForGroups(next); + } + + async function loadSubgroupsForGroups(groupIds) { + const request = ++subgroupRequestRef.current; + const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))]; + if (!normalizedIds.length) { + setSubgroups([]); + return true; + } + try { + const loaded = await fetchSubgroupsForGroups(normalizedIds); + if (request !== subgroupRequestRef.current) return false; + setSubgroups(loaded || []); + return true; + } catch (error) { + if (request !== subgroupRequestRef.current) return false; + setSubgroups([]); + setRuleAlert({ message: error.message || 'Не удалось загрузить подгруппы выбранных групп', type: 'error' }); + return true; + } + } + + function cacheRuleGroups(ids, names) { + const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` })); + setGroups(current => mergeEntitiesById([...current, ...additions])); + } + + // --- Форма правила --- + + function resetRuleForm() { + setRuleId(''); + setRuleSubjectId(''); + setTypeLimits(emptyTypeLimitsState()); + subgroupRequestRef.current += 1; + setSubgroups([]); + setSelectedGroupIds([]); + setSlots([emptySlot()]); + setGroupSearchQuery(''); + setSubgroupMenuOpenIndex(null); + setRuleAlert(null); + dirtyScopeRef.current?.clear(); + } + + async function fillRuleForm(rule) { + setRuleId(String(rule.id)); + setRuleSubjectId(rule.subjectId != null ? String(rule.subjectId) : ''); + setTypeLimits(typeLimitsStateFromRule(rule)); + cacheRuleGroups(rule.groupIds || [], rule.groupNames || []); + const ruleGroupIds = (rule.groupIds || []).map(Number); + setSelectedGroupIds(ruleGroupIds); + void searchRuleGroups(latestRef.current.groupSearchQuery, ruleGroupIds); + setSlots([]); + setSubgroupsLoading(true); + setSubgroupMenuOpenIndex(null); + if (!await loadSubgroupsForGroups(rule.groupIds || [])) return; + setSlots((rule.slots && rule.slots.length ? rule.slots : [{}]).map(slotFromRule)); + setSubgroupsLoading(false); + setRuleAlert(null); + dirtyScopeRef.current?.clear(); + ruleFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function handleSubjectChange(event) { + dirtyScopeRef.current?.mark(); + setRuleSubjectId(event.target.value); + } + + function handleTypeLimitChange(key, field, value) { + dirtyScopeRef.current?.mark(); + setTypeLimits(current => ({ + ...current, + [key]: { ...current[key], [field]: value } + })); + } + + function handleSlotChange(index, patch) { + dirtyScopeRef.current?.mark(); + setSlots(current => current.map((slot, slotIndex) => { + if (slotIndex !== index) return slot; + const next = { ...slot, ...patch }; + if (patch.lessonTypeId !== undefined && !isLaboratorySlot(next, latestRef.current.lessonTypes)) { + next.subgroupIds = []; + } + return next; + })); + } + + function handleSlotAdd() { + dirtyScopeRef.current?.mark(); + setSlots(current => [...current, emptySlot()]); + } + + function handleSlotRemove(index) { + dirtyScopeRef.current?.mark(); + setSlots(current => { + const next = current.filter((_, slotIndex) => slotIndex !== index); + return next.length ? next : [emptySlot()]; + }); + } + + function handleSlotSubgroupToggle(index) { + setSubgroupMenuOpenIndex(current => (current === index ? null : index)); + setGroupsMenuOpen(false); + } + + async function saveRule(event) { + event.preventDefault(); + setRuleAlert(null); + const latest = latestRef.current; + let payload = null; + const id = latest.ruleId; + try { + const slotsPayload = formSlotsToPayloads(latest.slots, { + lessonTypes: latest.lessonTypes, + subgroups: latest.subgroups, + selectedGroupIds: latest.selectedGroupIds + }); + payload = buildRulePayloadFromForm({ + ruleId: latest.ruleId, + subjectId: latest.ruleSubjectId, + semesterId: latest.selectedVersion?.semesterId, + versionId: latest.versionId, + typeLimits: typeLimitsPayload(latest.typeLimits), + groupIds: latest.selectedGroupIds, + slots: slotsPayload, + lessonTypes: latest.lessonTypes + }); + const saved = await saveScheduleRule(payload, id || null); + await finishSuccessfulRuleSave(saved, id); + } catch (error) { + if (payload && isScheduleRuleConflictError(error)) { + openConflictModal(error, payload, id); + return; + } + setRuleAlert({ message: error.message || 'Ошибка сохранения правила', type: 'error' }); + } + } + + async function finishSuccessfulRuleSave(saved, previousRuleId) { + const mode = previousRuleId ? 'update' : 'create'; + const savedVersionId = latestRef.current.versionId; + dirtyScopeRef.current?.clear(); + resetRuleForm(); + trackUxEvent('правило_расписания_сохранено', { mode, versionId: savedVersionId }); + setRuleAlert({ message: `Правило "${saved.subjectName || 'расписания'}" сохранено`, type: 'success' }); + await loadRules(); + } + + // --- Конфликты правил --- + + function openConflictModal(error, pendingPayload, pendingRuleId) { + const latest = latestRef.current; + const conflictRule = error.data.conflictRule; + const dictionaries = { groups: latest.groups, semesters: latest.semesters }; + setConflict({ + conflictRule: clonePlain(conflictRule), + conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [], + reasons: conflictReasonLabels(error.data.conflictFields, error.data.conflictReasons), + pendingPayload: clonePlain(pendingPayload), + pendingRuleId: pendingRuleId || '', + summary: error.message || 'Ранее созданное правило занимает выбранный слот.', + newTitle: ruleDraftTitle(pendingPayload, latest.subjects), + newMeta: ruleDraftMeta(pendingPayload, dictionaries), + existingTitle: ruleTitle(conflictRule, latest.subjects), + existingMeta: ruleMeta(conflictRule, dictionaries), + slots: (conflictRule.slots || []).map(slot => ({ ...slotFromRule(slot), original: slot })) + }); + setConflictAlert(null); + } + + function closeConflictModal() { + setConflict(null); + setConflictAlert(null); + } + + function handleConflictSlotChange(index, patch) { + setConflict(current => (current ? { + ...current, + slots: current.slots.map((slot, slotIndex) => ( + slotIndex === index ? { ...slot, ...patch } : slot + )) + } : current)); + } + + function readConflictSlots(validate) { + const resolution = latestRef.current.conflict; + return (resolution?.slots || []).map((slot, index) => { + const original = resolution.conflictRule?.slots?.[index] || {}; + const payload = { + id: original.id || null, + dayOfWeek: Number(slot.dayOfWeek), + parity: slot.parity, + timeSlotId: Number(slot.timeSlotId), + subgroupId: original.subgroupId || null, + subgroupIds: slotSubgroupIds(original), + teacherId: Number(slot.teacherId), + classroomId: Number(slot.classroomId), + lessonTypeId: Number(original.lessonTypeId), + lessonFormat: original.lessonFormat || 'Очно' + }; + if (validate && (!payload.dayOfWeek || !payload.parity || !payload.timeSlotId + || !payload.teacherId || !payload.classroomId || !payload.lessonTypeId || !payload.lessonFormat)) { + throw new Error(`Заполните слот ${index + 1} конфликтующего правила`); + } + return payload; + }); + } + + async function resolveConflict(event) { + event.preventDefault(); + const currentResolution = latestRef.current.conflict; + if (!currentResolution) return; + setConflictAlert(null); + setConflictBusy(true); + try { + const conflictPayload = buildRulePayloadFromRule( + currentResolution.conflictRule, + readConflictSlots(true), + latestRef.current.versionId + ); + await saveScheduleRule(conflictPayload, currentResolution.conflictRule.id); + + try { + const saved = await saveScheduleRule( + currentResolution.pendingPayload, + currentResolution.pendingRuleId || null + ); + closeConflictModal(); + await finishSuccessfulRuleSave(saved, currentResolution.pendingRuleId); + } catch (retryError) { + if (isScheduleRuleConflictError(retryError)) { + await loadRules(); + openConflictModal(retryError, currentResolution.pendingPayload, currentResolution.pendingRuleId); + setConflictAlert({ + message: 'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.', + type: 'error' + }); + return; + } + throw retryError; + } + } catch (error) { + if (isScheduleRuleConflictError(error)) { + setConflictAlert({ + message: `Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule, latestRef.current.subjects)}. Выберите другой день, пару, преподавателя или аудиторию.`, + type: 'error' + }); + return; + } + setConflictAlert({ message: error.message || 'Ошибка разрешения конфликта', type: 'error' }); + } finally { + setConflictBusy(false); + } + } + + // --- Реестр правил --- + + function handleRulesSearchInput(event) { + const value = event.target.value; + setRulesSearch(value); + window.clearTimeout(rulesSearchTimerRef.current); + rulesSearchTimerRef.current = window.setTimeout(() => { + setRulesPage(0); + void loadRules({ page: 0, query: value }); + }, 300); + } + + async function handleEditRule(id) { + const rule = [...latestRef.current.rules, ...latestRef.current.visualRules] + .find(item => String(item.id) === String(id)) + || await fetchScheduleRule(id); + void fillRuleForm(rule); + } + + async function handleDeleteRule(id) { + if (!await confirmAction({ + title: 'Удалить правило расписания?', + message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.', + confirmLabel: 'Удалить правило', + danger: true + })) return; + try { + await deleteScheduleRule(id); + setRuleAlert({ message: 'Правило расписания удалено', type: 'success' }); + await loadRules(); + } catch (error) { + setRuleAlert({ message: error.message || 'Ошибка удаления правила', type: 'error' }); + } + } + + // --- Визуальное расписание --- + + function toggleVisualDrawer() { + if (latestRef.current.visualOpen) { + closeVisualDrawer(); + } else { + openVisualDrawer(); + } + } + + function openVisualDrawer() { + setVisualOpen(true); + void loadVisualRules(); + } + + function closeVisualDrawer() { + closeVisualContextMenu(); + setVisualOpen(false); + } + + async function loadVisualRules(versionOverride) { + const version = versionOverride ?? latestRef.current.selectedVersion; + if (!version) return; + const request = ++visualRequestRef.current; + setVisualStageMessage('Загрузка визуального расписания...'); + try { + const loaded = await fetchScheduleRules(version.id); + if (request !== visualRequestRef.current) return; + setVisualRules(loaded || []); + setVisualStageMessage(null); + } catch (error) { + if (request === visualRequestRef.current) { + setVisualStageMessage(error.message || 'Ошибка загрузки визуального расписания'); + } + } + } + + function selectDefaultVisualPeriod({ semesters: semesterList, formSemesterId, rules: ruleList } = {}) { + const semList = semesterList ?? latestRef.current.semesters; + const listValue = ruleList ?? latestRef.current.rules; + const formSemester = semList.find(item => String(item.id) === String(formSemesterId)); + const ruleSemester = semList.find(item => + (listValue || []).some(rule => String(rule.semesterId) === String(item.id))); + const semester = formSemester || ruleSemester || semList[0]; + setVisualPeriodBySemesterId(semester?.id || '', semList); + } + + function setVisualPeriodBySemesterId(semesterId, semesterListOverride) { + const semesterList = semesterListOverride ?? latestRef.current.semesters; + const semester = semesterList.find(item => String(item.id) === String(semesterId)); + setVisualSemesterId(semester ? String(semester.id) : ''); + setVisualYearId(semester ? String(semester.academicYearId) : ''); + } + + function handleVisualYearChange(event) { + const yearId = event.target.value || ''; + const nextYearSemesters = latestRef.current.semesters + .filter(semester => String(semester.academicYearId) === String(yearId)); + setVisualYearId(yearId); + setVisualSemesterId(nextYearSemesters[0] ? String(nextYearSemesters[0].id) : ''); + setVisualPeriodTouched(true); + setVisualGroupFilterTouched(false); + } + + function handleVisualSemesterChange(event) { + setVisualSemesterId(event.target.value || ''); + setVisualPeriodTouched(true); + setVisualGroupFilterTouched(false); + } + + function handleVisualUseForm() { + const formSemesterId = latestRef.current.selectedVersion?.semesterId; + if (formSemesterId) { + setVisualPeriodTouched(true); + setVisualPeriodBySemesterId(formSemesterId); + } + setVisualGroupFilterTouched(false); + setVisualSelectedGroupIds([]); + } + + function handleVisualSelectAll() { + const latest = latestRef.current; + const period = effectiveVisualPeriodFrom(latest); + const periodRules = latest.visualRules + .filter(rule => String(rule.semesterId) === period.semesterId); + setVisualSelectedGroupIds(collectVisualGroups(periodRules, latest.groups).map(group => String(group.id))); + setVisualGroupFilterTouched(true); + } + + function handleVisualGroupFilterChange(event) { + const value = String(event.target.value); + setVisualSelectedGroupIds(current => ( + event.target.checked + ? [...current, value] + : current.filter(id => id !== value) + )); + setVisualGroupFilterTouched(true); + } + + function openVisualContextMenu(button) { + const rule = [...latestRef.current.rules, ...latestRef.current.visualRules] + .find(item => String(item.id) === String(button.dataset.ruleId)); + if (!rule) { + setRuleAlert({ message: 'Правило не найдено. Обновите список правил.', type: 'error' }); + return; + } + contextMenuAnchorRef.current = button; + setContextMenu({ + target: { + ruleId: button.dataset.ruleId, + slotId: button.dataset.slotId || '', + slotIndex: button.dataset.slotIndex || '' + }, + position: null + }); + } + + useEffect(() => { + if (!contextMenu || contextMenu.position) return undefined; + const menu = contextMenuRef.current; + const anchor = contextMenuAnchorRef.current; + if (!menu || !anchor) { + setContextMenu(null); + return undefined; + } + const position = calculateVisualContextMenuPosition({ + anchorRect: anchor.getBoundingClientRect(), + menuWidth: menu.offsetWidth, + menuHeight: menu.offsetHeight, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight + }); + setContextMenu(current => (current ? { ...current, position } : null)); + contextMenuAnimationRef.current?.cancel(); + const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`; + contextMenuAnimationRef.current = menu.animate( + [ + { transform: positionedTransform }, + { transform: positionedTransform } + ], + { duration: 1, fill: 'forwards' } + ); + menu.querySelector('button')?.focus(); + return undefined; + }, [contextMenu]); + + function closeVisualContextMenu() { + contextMenuAnimationRef.current?.cancel(); + contextMenuAnimationRef.current = null; + setContextMenu(null); + } + + useEffect(() => { + if (!contextMenu) return undefined; + function handleDocumentClick(event) { + if (event.target.closest('#schedule-visual-context-menu')) return; + if (event.target.closest('.schedule-visual-edit-rule')) return; + closeVisualContextMenu(); + } + document.addEventListener('click', handleDocumentClick); + return () => document.removeEventListener('click', handleDocumentClick); + }, [contextMenu]); + + async function handleVisualContextMenuClick(event) { + const actionButton = event.target.closest('button[data-action]'); + const current = latestRef.current.contextMenu; + if (!actionButton || !current?.target) return; + const action = actionButton.dataset.action; + const target = { ...current.target }; + const rule = [...latestRef.current.rules, ...latestRef.current.visualRules] + .find(item => String(item.id) === String(target.ruleId)); + if (!rule) { + closeVisualContextMenu(); + setRuleAlert({ message: 'Правило не найдено. Обновите список правил.', type: 'error' }); + return; + } + + if (action === 'edit-rule') { + closeVisualContextMenu(); + void fillRuleForm(rule); + closeVisualDrawer(); + setRuleAlert({ message: 'Правило открыто для редактирования', type: 'success' }); + return; + } + + if (action === 'move-slot') { + openMoveSlotModal(target); + return; + } + + if (action === 'delete-rule') { + await deleteRuleFromVisual(rule); + } + } + + async function deleteRuleFromVisual(rule) { + closeVisualContextMenu(); + if (!await confirmAction({ + title: 'Удалить правило расписания?', + message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`, + confirmLabel: 'Удалить правило', + danger: true + })) return; + try { + await deleteScheduleRule(rule.id); + setRuleAlert({ message: 'Правило расписания удалено', type: 'success' }); + await loadRules(); + } catch (error) { + setRuleAlert({ message: error.message || 'Ошибка удаления правила', type: 'error' }); + } + } + + function openMoveSlotModal(target) { + const rule = [...latestRef.current.rules, ...latestRef.current.visualRules] + .find(item => String(item.id) === String(target.ruleId)); + const slotIndex = findRuleSlotIndex(rule, target); + const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null; + if (!rule || !slot) { + closeVisualContextMenu(); + setRuleAlert({ message: 'Слот правила не найден. Обновите список правил.', type: 'error' }); + return; + } + setMoveSlot({ + ruleId: String(rule.id), + slotIndex, + dayOfWeek: slot.dayOfWeek ?? '', + timeSlotId: slot.timeSlotId ?? '', + title: rule.subjectName || 'Изменить день и пару', + summary: `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}` + }); + setMoveSlotAlert(null); + closeVisualContextMenu(); + } + + function closeMoveSlotModal() { + setMoveSlot(null); + setMoveSlotAlert(null); + } + + async function saveVisualSlotMove(event) { + event.preventDefault(); + const pending = latestRef.current.moveSlot; + if (!pending) return; + setMoveSlotAlert(null); + const rule = [...latestRef.current.rules, ...latestRef.current.visualRules] + .find(item => String(item.id) === pending.ruleId); + const slot = rule?.slots?.[pending.slotIndex]; + if (!rule || !slot) { + setMoveSlotAlert({ message: 'Слот правила не найден. Обновите список правил.', type: 'error' }); + return; + } + const dayOfWeek = Number(pending.dayOfWeek); + const timeSlotId = Number(pending.timeSlotId); + if (!dayOfWeek || !timeSlotId) { + setMoveSlotAlert({ message: 'Выберите день и пару', type: 'error' }); + return; + } + setMoveSlotBusy(true); + try { + const slotsPayload = (rule.slots || []).map(ruleSlotToPayload); + slotsPayload[pending.slotIndex] = { + ...slotsPayload[pending.slotIndex], + dayOfWeek, + timeSlotId + }; + const payload = buildRulePayloadFromRule(rule, slotsPayload, latestRef.current.versionId); + const saved = await saveScheduleRule(payload, rule.id); + closeMoveSlotModal(); + setRuleAlert({ message: `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, type: 'success' }); + await loadRules(); + } catch (error) { + if (isScheduleRuleConflictError(error)) { + setMoveSlotAlert({ + message: `Новое место занято правилом "${ruleTitle(error.data.conflictRule, latestRef.current.subjects)}". Выберите другой день или пару.`, + type: 'error' + }); + return; + } + setMoveSlotAlert({ message: error.message || 'Ошибка переноса слота', type: 'error' }); + } finally { + setMoveSlotBusy(false); + } + } + + // --- Глобальные обработчики --- + + useEffect(() => { + function handleKeydown(event) { + if (event.key !== 'Escape') return; + if (draftOpen) { + closeDraftModal(); + return; + } + if (latestRef.current.moveSlot) { + closeMoveSlotModal(); + return; + } + if (latestRef.current.conflict) { + closeConflictModal(); + return; + } + if (latestRef.current.contextMenu) { + closeVisualContextMenu(); + return; + } + closeVisualDrawer(); + } + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); + }, [draftOpen]); + + useEffect(() => { + if (!groupsMenuOpen && subgroupMenuOpenIndex === null) return undefined; + function handleDocumentClick(event) { + if (groupsMenuOpen && !groupsMenuRef.current?.contains(event.target)) { + setGroupsMenuOpen(false); + } + if (subgroupMenuOpenIndex !== null && !event.target.closest('.slot-subgroup-multi')) { + setSubgroupMenuOpenIndex(null); + } + } + document.addEventListener('click', handleDocumentClick); + return () => document.removeEventListener('click', handleDocumentClick); + }, [groupsMenuOpen, subgroupMenuOpenIndex]); + + // --- Производные данные визуального расписания --- + + const visualView = useMemo(() => { + if (!visualOpen) return null; + if (!effectiveVisualPeriod.semesterId) { + return { + summary: 'Выберите учебный год и семестр', + state: 'Выберите период, чтобы увидеть правила конкретного семестра', + groups: [] + }; + } + if (!visualRules.length) { + return { + summary: 'Нет сохранённых правил', + state: 'Сохраните правила расписания, чтобы увидеть матрицу по группам', + groups: [] + }; + } + const periodRules = visualRules.filter(rule => String(rule.semesterId) === effectiveVisualPeriod.semesterId); + const periodSemester = semesters.find(semester => String(semester.id) === effectiveVisualPeriod.semesterId); + const periodLabel = periodSemester ? shortSemesterLabel(periodSemester) : 'Семестр не выбран'; + if (!periodRules.length) { + return { + summary: `${periodLabel} · правил нет`, + state: 'В выбранном семестре нет сохранённых правил расписания', + groups: [] + }; + } + const allGroups = collectVisualGroups(periodRules, groups); + const availableIds = new Set(allGroups.map(group => String(group.id))); + let selected = visualSelectedGroupIds.filter(id => availableIds.has(id)); + if (!visualGroupFilterTouched) { + const formIds = selectedGroupIds.map(String).filter(id => availableIds.has(id)); + if (formIds.length) { + selected = formIds; + } else if (!selected.length && allGroups.length) { + selected = [String(allGroups[0].id)]; + } + } + const slotList = timeSlots.length + ? timeSlots.map(visualSlotFromTimeSlot) + : collectVisualSlotsFromRules(periodRules); + if (!allGroups.length || !slotList.length) { + return { + summary: 'Недостаточно данных для построения', + state: 'У правил должны быть группы и слоты занятий', + groups: allGroups, + selected + }; + } + if (!selected.length) { + return { + summary: 'Выберите группы для просмотра', + state: 'Отметьте одну или несколько групп в фильтре', + groups: allGroups, + selected + }; + } + const lessonsByCell = buildVisualLessonsByCell(new Set(selected), periodRules, { + lessonTypes, + semesters, + groups + }); + const columns = allGroups.filter(group => selected.includes(String(group.id))); + const rows = buildVisualRows(columns, slotList, lessonsByCell); + if (!rows.length) { + return { + summary: `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`, + state: 'Для выбранных групп нет активных пар в правилах выбранного семестра', + groups: allGroups, + selected + }; + } + return { + summary: `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`, + state: null, + groups: allGroups, + selected, + columns, + rows + }; + }, [visualOpen, effectiveVisualPeriod.semesterId, visualRules, visualGroupFilterTouched, + visualSelectedGroupIds, selectedGroupIds, timeSlots, lessonTypes, semesters, groups]); + + const groupsText = !groups.length + ? 'Нет групп' + : !selectedGroupIds.length + ? 'Выберите группы...' + : selectedGroupIds.length === 1 + ? (groups.find(group => String(group.id) === String(selectedGroupIds[0]))?.name || 'Выбрана 1 группа') + : `Выбрано групп: ${selectedGroupIds.length}`; + + const draftCopyText = selectedVersion + ? `За основу будет взято «${selectedVersion.name}» — все его правила и слоты будут скопированы.` + : 'Все правила выбранного расписания будут скопированы.'; + const draftSummaryText = selectedVersion + ? `${selectedVersion.semesterLabel} · версия ${selectedVersion.versionNumber}` + : ''; + + return ( + <> + + +
+
+
+ {versionContext.eyebrow} +

{versionContext.title}

+

{versionContext.note}

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {draftOpen && ( +
{ + if (event.target === event.currentTarget) closeDraftModal(); + }} + > +
+ + Новая рабочая копия +

Создать черновик

+

{draftCopyText}

+
+ + setDraftName(event.target.value)} + /> +
{draftError || draftSummaryText}
+
+ + +
+
+
+
+ )} + +
+
+

{ruleId ? 'Редактирование правила расписания' : 'Новое правило расписания'}

+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ {LESSON_TYPE_LIMITS.map(config => ( +
+
{config.label}
+
+ + handleTypeLimitChange(config.key, 'hours', event.target.value)} + /> +
+
+ + handleTypeLimitChange(config.key, 'startWeek', event.target.value)} + /> +
+
+ ))} +
+
+ +
+ +
+
{ + if (editorDisabled) return; + event.stopPropagation(); + toggleRuleGroupsMenu(); + }} + onKeyDown={handleRuleGroupsKeydown} + > + {groupsText} + + + +
+ {groupsMenuOpen && ( +
event.stopPropagation()}> + +
+ {groupsPhase === 'loading' ? ( +
+ ) : groupsPhase === 'error' ? ( +
{groupsError}
+ ) : !groups.length ? ( +
Нет групп
+ ) : groups.map(group => ( + + ))} +
+
+ )} +
+
+ +
+
+

Слоты правила

+ +
+
+ {subgroupsLoading ? ( +
+
+ ) : slots.map((slot, index) => ( + + ))} +
+
+ +
+ +
+ + +
+ + {conflict && ( + + )} + + + + {moveSlot && ( + + )} + +
+
+

Правила динамического расписания

+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + {rulesPhase === 'loading' ? ( + + ) : rulesPhase === 'error' || rulesPhase === 'empty' ? ( + + ) : !rules.length ? ( + + ) : rules.map(rule => ( + + ))} + +
IDДисциплинаСеместрГруппыНагрузкаСлотыДействия
Загрузка...
{rulesMessage}
Правила не найдены
+
+ {rulesPhase === 'ready' && rulesPageData ? ( + void loadRules({ page })} + onSize={size => void loadRules({ size, page: 0 })} + /> + ) : ( +
+ )} +
+ + + + + + ); +} + +// --- Строка слота формы --- + +function RuleSlotRow({ + slot, index, slotCount, disabled, + timeSlotOptions, lessonTypeOptions, lessonTypes, subgroups, selectedGroupIds, + semesterId, preferences, + subgroupMenuOpen, onToggleSubgroupMenu, onChange, onRemove +}) { + const isSingle = slotCount === 1; + const actionLabel = scheduleSlotActionLabel(slotCount); + const actionClass = isSingle ? 'btn-ghost' : 'btn-danger'; + const laboratory = isLaboratorySlot(slot, lessonTypes); + const pills = preferenceHintPills({ + semesterId, + teacherId: slot.teacherId, + dayOfWeek: slot.dayOfWeek, + timeSlotId: slot.timeSlotId, + preferences + }); + const matchingSubgroups = laboratory + ? matchingSubgroupsForGroups(subgroups, selectedGroupIds) + : []; + + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + fetchTeacherOptions(query, selectedId)} + selectedValue={slot.teacherId} + selectedLabel={slot.teacherName} + onSelect={teacher => onChange(index, teacher + ? { teacherId: String(teacher.id), teacherName: teacherOptionLabel(teacher) } + : { teacherId: '', teacherName: '' })} + /> +
+
+ + fetchClassroomOptions(query, selectedId)} + selectedValue={slot.classroomId} + selectedLabel={slot.classroomName} + onSelect={classroom => onChange(index, classroom + ? { classroomId: String(classroom.id), classroomName: classroomOptionLabel(classroom) } + : { classroomId: '', classroomName: '' })} + /> +
+
+ + +
+ {laboratory && (selectedGroupIds.length > 1 ? ( +
+ +
+
{ + if (disabled) return; + event.stopPropagation(); + onToggleSubgroupMenu(index); + }} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleSubgroupMenu(index); + } + if (event.key === 'Escape') { + onToggleSubgroupMenu(null); + } + }} + > + {slotSubgroupText(slot, subgroups)} + + + +
+ {subgroupMenuOpen && ( +
event.stopPropagation()}> +
+ {matchingSubgroups.length ? matchingSubgroups.map(subgroup => ( + + )) : ( +
Нет подгрупп выбранных групп
+ )} +
+
+ )} +
+
+ ) : ( +
+ + +
+ ))} +
+ + +
+ +
+ {pills ? ( + pills.length ? ( + <> + Пожелания преподавателя + {pills.map(pill => ( + {pill.text} + ))} + + ) : ( + Согласованных пожеланий для слота нет + ) + ) : null} +
+
+ ); +} + +// --- Строка слота конфликтующего правила --- + +function ConflictSlotRow({ slot, index, conflictFields, timeSlotOptions, onSlotChange }) { + const original = slot.original || {}; + const fixedParts = [ + original.lessonTypeName, + original.lessonFormat, + slotSubgroupNames(original).join(', ') + ].filter(Boolean); + + return ( +
+
+ Слот {index + 1} + {fixedParts.join(' · ') || 'Занятие'} + {conflictSlotCurrentText(original)} +
+
+ + +
+
+ + +
+
+ + +
+
+ + fetchTeacherOptions(query, selectedId)} + selectedValue={slot.teacherId} + selectedLabel={slot.teacherName} + onSelect={teacher => onSlotChange(index, teacher + ? { teacherId: String(teacher.id), teacherName: teacherOptionLabel(teacher) } + : { teacherId: '', teacherName: '' })} + /> +
+
+ + fetchClassroomOptions(query, selectedId)} + selectedValue={slot.classroomId} + selectedLabel={slot.classroomName} + onSelect={classroom => onSlotChange(index, classroom + ? { classroomId: String(classroom.id), classroomName: classroomOptionLabel(classroom) } + : { classroomId: '', classroomName: '' })} + /> +
+
+ ); +} + +// --- Строка реестра правил --- + +function RuleRow({ rule, onEdit, onDelete }) { + return ( + + {rule.id} + {rule.subjectName || '-'} + {formatSemester(rule)} + {(rule.groupNames || []).join(', ') || '-'} + + + +
+ + +
+ + + ); +} + +function RuleLoadSummary({ rule }) { + const rows = LESSON_TYPE_LIMITS + .map(config => ({ + label: config.label, + hours: Number(rule[config.hoursField] || 0), + startWeek: Number(rule[config.startWeekField] || 1) + })) + .filter(item => item.hours > 0); + if (!rows.length) { + return Часы не указаны; + } + return ( + <> + {rows.map(item => ( +
+ {`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`} +
+ ))} + + ); +} + +function RuleSlotsSummary({ slots }) { + if (!slots || !slots.length) { + return Нет слотов; + } + return ( + <> + {slots.map((slot, index) => { + const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; + const parts = [ + slot.dayName, + slot.timeSlotLabel, + parity, + slotSubgroupNames(slot).join(', '), + slot.teacherName, + slot.classroomName, + slot.lessonTypeName, + slot.lessonFormat + ].filter(Boolean); + return
{parts.join(' · ')}
; + })} + + ); +} + +// --- Визуальная матрица --- + +function VisualTable({ rows, columns, onEditLesson }) { + return ( +
+ + + + + + {columns.map(group => )} + + + + {rows.map((row, index) => ( + + {row.dayRowspan ? ( + + ) : null} + + {row.cells.map(cell => ( + + ))} + + ))} + +
ДеньВремя{group.name}
{row.day.label} + {row.slot.orderLabel} + {row.slot.timeLabel} + + +
+
+ ); +} + +function VisualCellContent({ lessons, onEditLesson }) { + if (!lessons.length) return null; + const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD'); + const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN'); + const shouldSplit = lessons.some(lesson => lesson.parity !== 'BOTH'); + + if (!shouldSplit) { + return ( +
+ {uniqueVisualLessons(lessons).map(lesson => ( + + ))} +
+ ); + } + + return CELL_PARITY_LAYOUT.map(({ parity, label }) => { + const parityLessons = parity === 'ODD' ? oddLessons : evenLessons; + return ( +
+ {label} + {parityLessons.map(lesson => ( + + ))} +
+ ); + }); +} + +function VisualLesson({ lesson, onEditLesson }) { + const meta = [ + lesson.lessonTypeName, + lesson.lessonFormat, + lesson.teacherName, + lesson.classroomName + ].filter(Boolean).join(' · '); + const subgroupText = lesson.subgroupNames.join(', '); + + return ( +
+ + {lesson.subjectName} + {lesson.weekText ? <> {lesson.weekText} : null} + + {subgroupText ? {subgroupText} : null} + {meta ? {meta} : null} + +
+ ); +} diff --git a/frontend/react/admin/tabs/ScheduleVersionsTab.jsx b/frontend/react/admin/tabs/ScheduleVersionsTab.jsx new file mode 100644 index 0000000..48da111 --- /dev/null +++ b/frontend/react/admin/tabs/ScheduleVersionsTab.jsx @@ -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 + ? + : (years.length + ? years.map(year => ) + : ); + + const semesterOptions = !baseLoaded + ? + : (yearSemesters.length + ? yearSemesters.map(semester => ( + + )) + : ); + + return ( +
+
+
+ Контур публикации +

Расписание меняется за кулисами

+

Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {status.message} +
+ +
+ {loaded ? (published ? ( + <> +
LIVE
+
+ Сейчас видят пользователи +

{published.name} · версия {published.versionNumber}

+

{published.changeReason || 'Причина первой публикации не указана'}

+
+ {published.ruleCount} правил + {published.slotCount} слотов + {formatVersionDateTime(published.publishedAt)} +
+
+
+ + +
+ + ) : ( + <> +
ПУСТО
+
+

Опубликованной версии нет

+

Создайте первый черновик и опубликуйте его после проверки.

+
+ + )) : null} +
+ +
+
+
+
+ Подготовка +

Черновики

+
+ {drafts.length} +
+
+ {loaded ? (drafts.length ? drafts.map(version => ( +
+
+
+ Черновик · v{version.versionNumber} +

{version.name}

+
+
+

{draftBasisText(version)}

+ + {version.ruleCount} правил · {version.slotCount} слотов · {formatVersionDateTime(version.createdAt)} + +
+ + + + +
+
+ )) : ( +
+ + +

Нет активных черновиков

+

Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.

+
+ )) : null} +
+
+ + +
+ +
+
+
+ Возврат без паники +

Архив публикаций

+
+
+
+ {loaded ? (archive.length ? archive.map(version => ( +
+ Архив · v{version.versionNumber} +

{version.name}

+

{version.changeReason || 'Причина не указана'}

+ {version.ruleCount} правил · публикация {formatVersionDateTime(version.publishedAt)} +
+ + + +
+
+ )) :

Предыдущих публикаций пока нет.

) : null} +
+
+ +
+
+
+ Неизменяемый след +

Журнал действий

+
+
+
+ {loaded ? (history.length ? history.map((item, index) => ( +
+ +
+ + {historyActionLabel(item.action)}: v{item.versionNumber} · {item.versionName} + +

{item.reason || 'Без комментария'}

+
+ {item.actorName || 'Система'} +
+ )) :

Действия ещё не зафиксированы.

) : null} +
+
+ + {modal ? ( +
{ + if (event.target === event.currentTarget) closeModal(); + }} + > +
+ + {modal.kicker} +

{modal.title}

+

{modal.copy}

+
+ + + +
+ + +
+
+
+
+ ) : null} +
+ ); +} + +function VersionDiff({ diff }) { + return ( + <> + {diff.baseVersionLabel} → {diff.targetVersionLabel} +

Что изменится после публикации

+
+
+{diff.addedLessonCount}занятий
+
{diff.changedLessonCount}изменено
+
−{diff.removedLessonCount}удалено
+
+

+ Правила: +{diff.addedRuleCount} · ~{diff.changedRuleCount} · −{diff.removedRuleCount} · без изменений {diff.unchangedRuleCount} +

+
+ {(diff.ruleChanges || []).length + ? diff.ruleChanges.map((change, index) => ( +
+ {ruleChangeTypeLabel(change.type)} · {change.subjectName} + {(change.groupNames || []).join(', ') || 'Группы не указаны'} + {change.beforeSummary ? Было: {change.beforeSummary} : null} + {change.afterSummary ? Стало: {change.afterSummary} : null} +
+ )) + : ( +
+

Правила совпадают

+

Структурных изменений между версиями нет.

+
+ )} +
+ + ); +} diff --git a/frontend/react/admin/tabs/ScheduleViewTab.jsx b/frontend/react/admin/tabs/ScheduleViewTab.jsx new file mode 100644 index 0000000..b40c7a8 --- /dev/null +++ b/frontend/react/admin/tabs/ScheduleViewTab.jsx @@ -0,0 +1,1771 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; + +import { getSession } from '../../../admin/js/api.js'; +import { + academicYearsFromSemesters, + resolveAcademicPeriod, + semestersForAcademicYear +} from '../../../admin/js/academic-period.js'; +import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js'; +import { + OVERRIDE_DAY_LABELS, + buildCancelOverridePayload, + buildOverridePayload, + canEditScheduleOverrides, + classroomOptionLabel, + deleteScheduleOverride, + fetchEffectiveTimeSlots, + fetchOverrideAvailability, + initialTimePreference, + lessonFromOverride, + overrideAfterMeta, + overrideBeforeMeta, + overrideClassroomName, + overrideRegistryItem, + overrideSourceFrom, + overrideTeacherName, + overrideTimeSlotLabel, + pickMatchingTimeSlot, + saveScheduleOverride, + semesterCalendarMonths, + weekDates +} from '../schedule-override-service.js'; +import { trackUxEvent } from '../../../telemetry.js'; +import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; +import { FormAlert } from '../../shared/ui/FormAlert.jsx'; +import { + DAY_FULL_LABELS, + DAY_ORDER, + MOBILE_QUERY, + PARITY_LABELS, + ROLE_DEPARTMENT, + SCHEDULE_REFRESH_PROMPT, + TARGET_CLASSROOMS, + TARGET_DEPARTMENTS, + TARGET_GROUPS, + TARGET_TEACHERS, + buildCombinedWeekBlocks, + buildScheduleSections, + buildSectionContext, + buildSemesterLessonMatrix, + collectSlots, + combinedWeekCaption, + dateWithinSemester, + dayShort, + entityValueForTarget, + fetchClassroomOptions, + fetchGroupOptions, + fetchScheduleLessons, + fetchScheduleOverrides, + fetchScheduleViewDictionaries, + fetchTeacherOptions, + formatDate, + formatDateShort, + getLessonWeeksText, + groupLessonsByDateSlot, + groupOptionLabel, + hasScheduleSearchTarget, + initialScheduleViewState, + isoDay, + lessonCountLabel, + mergeDictionaryItems, + mobileDayCaption, + periodCaption, + roomLabel, + scheduleRangeForSemester, + scheduleViewUrlState, + sectionCountLabel, + semesterOptionLabel, + semesterScheduleCaption, + slotStatesEqual, + slotStatesForDates, + slotStatesForSemester, + splitTypeForTarget, + teacherOptionLabel, + toIsoDate +} from '../schedule-view-service.js'; + +const ENTITY_KEYS = [TARGET_GROUPS, TARGET_TEACHERS, TARGET_CLASSROOMS]; +const EMPTY_SELECTION = Object.freeze({ id: '', label: '' }); + +function restoredSelection(value) { + return value ? { id: String(value), label: 'Загрузка...' } : EMPTY_SELECTION; +} + +// Боковая панель разовых правок перенесена на React-состояние (волна 6): +// вкладка владеет реестром правок, а панель открывает занятие или реестр +// через императивный handle и работает со справочниками через общий ref. +export function ScheduleViewTab() { + const [session] = useState(() => getSession() || {}); + const role = session?.role; + const userDepartmentId = normalizeId(session?.departmentId); + const isDepartment = role === ROLE_DEPARTMENT; + const canEditOverrides = canEditScheduleOverrides(role); + + const restoredRef = useRef(null); + if (!restoredRef.current) { + restoredRef.current = initialScheduleViewState( + typeof window === 'undefined' + ? {} + : readUrlState({ + scheduleTarget: '', + scheduleEntity: '', + scheduleDate: '', + scheduleYear: '', + scheduleSemester: '', + scheduleSubject: '', + scheduleLessonType: '', + scheduleParity: '' + }) + ); + } + const restored = restoredRef.current; + + // Фильтры + const [target, setTarget] = useState(() => ( + isDepartment ? TARGET_DEPARTMENTS : (restored.target || TARGET_GROUPS) + )); + const [entitySelections, setEntitySelections] = useState(() => ({ + [TARGET_GROUPS]: restoredSelection(restored.target === TARGET_GROUPS ? restored.entity : ''), + [TARGET_TEACHERS]: restoredSelection(restored.target === TARGET_TEACHERS ? restored.entity : ''), + [TARGET_CLASSROOMS]: restoredSelection(restored.target === TARGET_CLASSROOMS ? restored.entity : '') + })); + const [departmentId, setDepartmentId] = useState(''); + const [dateValue, setDateValue] = useState(() => restored.date || toIsoDate(new Date())); + const [yearId, setYearId] = useState(''); + const [semesterId, setSemesterId] = useState(''); + const [subjectId, setSubjectId] = useState(''); + const [lessonTypeId, setLessonTypeId] = useState(''); + const [parity, setParity] = useState(''); + + // Справочники + const dictionariesRef = useRef({ groups: [], teachers: [], classrooms: [], departments: [], semesters: [] }); + const [departments, setDepartments] = useState([]); + const [subjects, setSubjects] = useState([]); + const [lessonTypes, setLessonTypes] = useState([]); + const [semesters, setSemesters] = useState([]); + const [baseLoaded, setBaseLoaded] = useState(false); + + // Результаты + const [alert, setAlert] = useState(null); + const [sections, setSections] = useState([]); + const [activeSectionIndex, setActiveSectionIndex] = useState(0); + const [mobileDay, setMobileDay] = useState(1); + const [range, setRange] = useState(null); + const [totalLessons, setTotalLessons] = useState(0); + const [loading, setLoading] = useState(false); + const [tableState, setTableState] = useState({ message: 'Загрузка...', actionable: false }); + const [captionText, setCaptionText] = useState('Выберите параметры и нажмите «Показать»'); + const [isMobile, setIsMobile] = useState(false); + + // Реестр разовых правок за показанный период + const [overrides, setOverrides] = useState([]); + const [overrideRegistryRange, setOverrideRegistryRange] = useState(null); + + const overrideDrawerRef = useRef(null); + const rangeRef = useRef(null); + + // Загрузка и обработчики читают актуальные значения через этот ref: + // боковая панель правок захватывает колбэки один раз при создании. + const latestRef = useRef(null); + latestRef.current = { + target, + entitySelections, + departmentId, + dateValue, + yearId, + semesterId, + subjectId, + lessonTypeId, + parity, + sections, + activeSectionIndex, + semesters + }; + + const selectedSemester = useMemo( + () => semesters.find(semester => String(semester.id) === String(semesterId)) || null, + [semesters, semesterId] + ); + + const activeTargetValue = isDepartment ? TARGET_DEPARTMENTS : (target || TARGET_GROUPS); + const activeSection = sections[activeSectionIndex] || null; + const canEditOverridesRef = useRef(canEditOverrides); + canEditOverridesRef.current = canEditOverrides; + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined; + const media = window.matchMedia(MOBILE_QUERY); + const apply = () => setIsMobile(media.matches); + apply(); + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); + } + if (typeof media.addListener === 'function') { + media.addListener(apply); + return () => media.removeListener(apply); + } + return undefined; + }, []); + + // --- Загрузка данных --- + + useEffect(() => { + let cancelled = false; + + async function initialize() { + try { + const dictionaries = await fetchScheduleViewDictionaries(); + if (cancelled) return; + dictionariesRef.current = { + ...dictionariesRef.current, + departments: dictionaries.departments, + semesters: dictionaries.semesters + }; + setDepartments(dictionaries.departments); + setSemesters(dictionaries.semesters); + setSubjects(dictionaries.subjects); + setLessonTypes(dictionaries.lessonTypes); + + const resolved = resolveAcademicPeriod(dictionaries.semesters, { + academicYearId: restored.yearId, + semesterId: restored.semesterId, + date: latestRef.current.dateValue + }); + const nextSemesterId = resolved.semesterId; + const nextDate = synchronizedDate(nextSemesterId, dictionaries.semesters, latestRef.current.dateValue); + setYearId(resolved.academicYearId); + setSemesterId(nextSemesterId); + setDateValue(nextDate); + if (isDepartment) { + setDepartmentId(userDepartmentId || ''); + } + restoreSimpleFilter('subjectId', restored.subjectId, dictionaries.subjects, setSubjectId); + restoreSimpleFilter('lessonTypeId', restored.lessonTypeId, dictionaries.lessonTypes, setLessonTypeId); + if (restored.parity && ['BOTH', 'ODD', 'EVEN'].includes(restored.parity)) { + setParity(restored.parity); + } + + setBaseLoaded(true); + // Аналог combo.preload('') из legacy: заранее наполняем справочники + // сущностей и обновляем подпись восстановленного значения. + await Promise.all(ENTITY_KEYS.map(key => + loadEntityOptions(key, '', latestRef.current.entitySelections[key].id) + .catch(() => []) + )); + await loadData(nextDate); + } catch (error) { + if (cancelled) return; + const message = error.message || 'Ошибка загрузки справочников'; + setAlert({ message, type: 'error' }); + resetResults(message); + } + } + + void initialize(); + return () => { + cancelled = true; + }; + }, []); + + function restoreSimpleFilter(key, value, items, setter) { + if (!value) return; + if ((items || []).some(item => String(item.id) === String(value))) { + setter(String(value)); + } + } + + function synchronizedDate(nextSemesterId, semestersList, currentDate) { + const semester = (semestersList || []).find(item => String(item.id) === String(nextSemesterId)) || null; + if (!semester) return toIsoDate(new Date()); + if (!dateWithinSemester(currentDate, semester)) return semester.startDate; + return currentDate; + } + + async function loadData(overrideDate) { + const current = latestRef.current; + const effectiveDate = overrideDate || current.dateValue; + setAlert(null); + + if (!effectiveDate) { + const message = 'Выберите дату в периоде'; + setAlert({ message, type: 'error' }); + resetResults(message); + return; + } + + if (isDepartment && !userDepartmentId) { + const message = 'Не удалось определить кафедру пользователя'; + setAlert({ message, type: 'error' }); + resetResults(message); + return; + } + + // Без выбранной цели (группа/преподаватель/аудитория/кафедра) backend + // отвергает широкий поиск — показываем нейтральную подсказку вместо + // заведомо неудачного запроса и красной ошибки. + if (!hasScheduleSearchTarget({ + role, + userDepartmentId, + target: current.target || TARGET_GROUPS, + groupId: current.entitySelections[TARGET_GROUPS].id, + teacherId: current.entitySelections[TARGET_TEACHERS].id, + classroomId: current.entitySelections[TARGET_CLASSROOMS].id, + departmentId: current.departmentId + })) { + resetResults('Выберите параметры и нажмите «Показать»'); + return; + } + + const loadTarget = isDepartment ? TARGET_DEPARTMENTS : (current.target || TARGET_GROUPS); + const semester = (current.semesters || []).find(item => String(item.id) === String(current.semesterId)) || null; + const nextRange = scheduleRangeForSemester(effectiveDate, semester); + const previousKey = current.sections[current.activeSectionIndex]?.key; + rangeRef.current = nextRange; + setRange(nextRange); + setLoading(true); + setTableState({ message: 'Загрузка...', actionable: false }); + + try { + const [lessons, overrideList] = await Promise.all([ + fetchScheduleLessons({ + startDate: nextRange.startDate, + endDate: nextRange.endDate, + target: loadTarget, + role, + userDepartmentId, + groupId: current.entitySelections[TARGET_GROUPS].id, + teacherId: current.entitySelections[TARGET_TEACHERS].id, + classroomId: current.entitySelections[TARGET_CLASSROOMS].id, + departmentId: current.departmentId, + subjectId: current.subjectId, + lessonTypeId: current.lessonTypeId, + parity: current.parity + }), + canEditOverridesRef.current + ? fetchScheduleOverrides(nextRange) + : Promise.resolve([]) + ]); + setOverrides(overrideList || []); + setOverrideRegistryRange(nextRange); + renderLessons(lessons, nextRange, loadTarget, previousKey, effectiveDate); + persistFilters({ date: effectiveDate }); + trackUxEvent('расписание_найдено', { target: loadTarget, lessons: lessons.length }); + } catch (error) { + const message = error.data?.message || error.message || 'Ошибка загрузки расписания'; + setAlert({ message, type: 'error' }); + resetResults(message); + } finally { + setLoading(false); + } + } + + function renderLessons(lessons, nextRange, loadTarget, previousKey, effectiveDate) { + setTotalLessons(lessons.length); + if (!lessons.length) { + setSections([]); + setActiveSectionIndex(0); + setCaptionText(periodCaption(nextRange)); + setTableState({ message: 'Занятия не найдены', actionable: false }); + return; + } + + const selectedGroupId = loadTarget === TARGET_GROUPS + ? latestRef.current.entitySelections[TARGET_GROUPS].id + : ''; + const context = buildSectionContext({ + target: loadTarget, + selectedGroupId, + groups: dictionariesRef.current.groups + }); + const nextSections = buildScheduleSections(lessons, splitTypeForTarget(loadTarget), context); + if (!nextSections.length) { + setSections([]); + setActiveSectionIndex(0); + setCaptionText(periodCaption(nextRange)); + setTableState({ message: 'Нет данных для выбранного режима просмотра', actionable: false }); + return; + } + + const previousIndex = nextSections.findIndex(section => section.key === previousKey); + setSections(nextSections); + setActiveSectionIndex(Math.max(0, previousIndex)); + setMobileDay(isoDay(effectiveDate || nextRange.startDate)); + } + + function resetResults(message) { + setSections([]); + setActiveSectionIndex(0); + setMobileDay(1); + setTotalLessons(0); + setCaptionText(message); + setTableState({ message, actionable: message === SCHEDULE_REFRESH_PROMPT }); + } + + function persistFilters(overrides = {}) { + if (typeof window === 'undefined') return; + const current = { ...latestRef.current, ...overrides }; + const persistTarget = isDepartment ? TARGET_DEPARTMENTS : (current.target || TARGET_GROUPS); + writeUrlState(scheduleViewUrlState({ + role, + target: persistTarget, + entityValue: entityValueForTarget(persistTarget, { + groupId: current.entitySelections[TARGET_GROUPS].id, + teacherId: current.entitySelections[TARGET_TEACHERS].id, + classroomId: current.entitySelections[TARGET_CLASSROOMS].id, + departmentId: current.departmentId + }), + date: current.dateValue, + yearId: current.yearId, + semesterId: current.semesterId, + subjectId: current.subjectId, + lessonTypeId: current.lessonTypeId, + parity: current.parity + })); + } + + // --- Обработчики фильтров --- + + function handleTargetChange(event) { + const nextTarget = event.target.value; + setTarget(nextTarget); + setEntitySelections(current => { + const next = { ...current }; + if (nextTarget !== TARGET_GROUPS) next[TARGET_GROUPS] = EMPTY_SELECTION; + if (nextTarget !== TARGET_TEACHERS) next[TARGET_TEACHERS] = EMPTY_SELECTION; + if (nextTarget !== TARGET_CLASSROOMS) next[TARGET_CLASSROOMS] = EMPTY_SELECTION; + return next; + }); + if (!isDepartment && nextTarget !== TARGET_DEPARTMENTS) { + setDepartmentId(''); + } + resetResults(SCHEDULE_REFRESH_PROMPT); + const clearedDepartments = !isDepartment && nextTarget !== TARGET_DEPARTMENTS; + persistFilters({ + target: nextTarget, + departmentId: clearedDepartments ? '' : latestRef.current.departmentId + }); + } + + function handleDateChange(event) { + const nextDate = event.target.value; + setDateValue(nextDate); + resetResults(SCHEDULE_REFRESH_PROMPT); + persistFilters({ date: nextDate }); + } + + function handleYearChange(event) { + const nextYearId = event.target.value; + const current = latestRef.current; + const yearSemesters = semestersForAcademicYear(current.semesters, nextYearId); + const selected = resolveAcademicPeriod(yearSemesters, { date: current.dateValue }); + const nextDate = synchronizedDate(selected.semesterId, current.semesters, current.dateValue); + setYearId(nextYearId); + setSemesterId(selected.semesterId); + setDateValue(nextDate); + resetResults(SCHEDULE_REFRESH_PROMPT); + persistFilters({ yearId: nextYearId, semesterId: selected.semesterId, date: nextDate }); + } + + function handleSemesterChange(event) { + const nextSemesterId = event.target.value; + const current = latestRef.current; + const nextDate = synchronizedDate(nextSemesterId, current.semesters, current.dateValue); + setSemesterId(nextSemesterId); + setDateValue(nextDate); + resetResults(SCHEDULE_REFRESH_PROMPT); + persistFilters({ semesterId: nextSemesterId, date: nextDate }); + } + + function handleDepartmentChange(event) { + const nextDepartmentId = event.target.value; + setDepartmentId(nextDepartmentId); + persistFilters({ departmentId: nextDepartmentId }); + } + + function handleSimpleFilterChange(key, setter) { + return event => { + const value = event.target.value; + setter(value); + persistFilters({ [key]: value }); + }; + } + + function makeEntityLoader(entityKey) { + return (query, selectedId) => loadEntityOptions(entityKey, query, selectedId); + } + + async function loadEntityOptions(entityKey, query, selectedId) { + let items; + if (entityKey === TARGET_GROUPS) { + items = await fetchGroupOptions(query, selectedId, isDepartment ? userDepartmentId : ''); + } else if (entityKey === TARGET_TEACHERS) { + items = await fetchTeacherOptions(query, selectedId); + } else { + items = await fetchClassroomOptions(query, selectedId); + } + mergeDictionaryItems(dictionariesRef.current, entityKey, items || []); + const selected = (items || []).find(item => String(item.id) === String(selectedId)); + if (selected) { + setEntitySelections(current => ( + String(current[entityKey].id) === String(selectedId) + ? { ...current, [entityKey]: { id: String(selectedId), label: entityLabel(entityKey, selected) } } + : current + )); + } + return items; + } + + function entityLabel(entityKey, item) { + if (entityKey === TARGET_GROUPS) return groupOptionLabel(item); + if (entityKey === TARGET_TEACHERS) return teacherOptionLabel(item); + return roomLabel(item); + } + + function handleEntitySelect(entityKey, item) { + const selection = item ? { id: String(item.id), label: entityLabel(entityKey, item) } : EMPTY_SELECTION; + setEntitySelections(current => ({ ...current, [entityKey]: selection })); + persistFilters({ + entitySelections: { + ...latestRef.current.entitySelections, + [entityKey]: selection + } + }); + } + + // --- Навигация по результатам --- + + function selectSection(index) { + setActiveSectionIndex(index); + setMobileDay(isoDay(latestRef.current.dateValue || (rangeRef.current?.startDate || '')) || 1); + } + + function moveResult(direction) { + const total = latestRef.current.sections.length; + if (total < 2) return; + setActiveSectionIndex(current => (current + direction + total) % total); + setMobileDay(isoDay(latestRef.current.dateValue || (rangeRef.current?.startDate || '')) || 1); + } + + function openOverrideLesson(lesson) { + void overrideDrawerRef.current?.openLesson(lesson); + } + + // --- Производные значения --- + + const years = useMemo(() => academicYearsFromSemesters(semesters), [semesters]); + const yearSemesters = useMemo( + () => semestersForAcademicYear(semesters, yearId), + [semesters, yearId] + ); + + const periodLabelText = !dateValue + ? 'Период не выбран' + : periodCaption(range || scheduleRangeForSemester(dateValue, selectedSemester), selectedSemester); + + const countLabelText = loading + ? 'Загрузка...' + : (!totalLessons + ? '0 занятий' + : (sections.length + ? `${lessonCountLabel(totalLessons)}, ${sectionCountLabel(sections.length)}` + : lessonCountLabel(totalLessons))); + + const activeLabelText = loading + ? 'Загрузка расписаний...' + : (activeSection + ? `${activeSection.title} · ${lessonCountLabel(activeSection.lessons.length)} · ${activeSectionIndex + 1} из ${sections.length}` + : 'Нет выбранного расписания'); + + const tableCaptionText = activeSection && range + ? `${lessonCountLabel(activeSection.lessons.length)} · ${periodCaption(range)}` + : captionText; + + const departmentOptions = useMemo(() => { + const options = departments.map(department => ( + + )); + if (isDepartment + && userDepartmentId + && !departments.some(department => String(department.id) === String(userDepartmentId))) { + options.unshift(); + } + return options; + }, [departments, isDepartment, userDepartmentId]); + + // --- Рендер сцены расписания --- + + function renderStateRow(message, actionable = false) { + return ( +
+ {message} + {actionable ? ( + + ) : null} +
+ ); + } + + function renderStageContent() { + if (loading) { + return
Загрузка...
; + } + const section = activeSection; + if (!section || !range) { + return renderStateRow(tableState.message, tableState.actionable); + } + + const slots = collectSlots(section.lessons); + const semester = selectedSemester; + if (semester) { + if (!slots.length) { + return renderStateRow('Занятия не найдены'); + } + if (isMobile) { + return ( + + ); + } + return ( + + ); + } + + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + if (!blocks.length || !slots.length) { + return renderStateRow('Занятия не найдены'); + } + if (isMobile) { + return blocks.map(block => ( + + )); + } + return blocks.map(block => ( + + )); + } + + return ( +
+
+
+
+

Расписания

+ {periodLabelText} +
+ +
+ +
+
+ + +
+ + + + + + + + + + +
+ +
+ Дополнительные фильтры +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+

Найденные расписания

+ {countLabelText} +
+
+ + + {activeLabelText} + +
+
+
+ {!loading && (activeSection ? ( + sections.map((item, index) => ( + + )) + ) : ( + Ничего не найдено + ))} +
+
+ +
+
+
+

{activeSection ? activeSection.title : 'Расписание'}

+ {tableCaptionText} +
+ +
+
+ {renderStageContent()} +
+
+ + { + void loadData(); + }} + /> +
+ ); +} + +function normalizeId(value) { + if (!value || value === 'null' || value === 'undefined') return ''; + return String(value); +} + +// --- Карточка занятия --- + +function LessonCard({ lesson, canEditOverrides, onEditLesson }) { + const subgroupText = (lesson.subgroupNames || []).join(', '); + const meta = [ + lesson.lessonTypeName, + lesson.lessonFormat, + PARITY_LABELS[lesson.parity] || lesson.parity + ].filter(Boolean).join(' · '); + const weeksText = getLessonWeeksText(lesson); + const sourceDate = lesson.originalLessonDate || lesson.date; + + return ( +
+
+ {lesson.subjectName || 'Без дисциплины'} + {weeksText ? {weeksText} : null} +
+ {lesson.scheduleOverrideId ? Разовая правка : null} +
{(lesson.groupNames || []).join(', ') || 'Группа не указана'}
+ {subgroupText ?
{subgroupText}
: null} +
{lesson.teacherName || 'Преподаватель не указан'}
+
{lesson.classroomName || 'Аудитория не указана'}
+ {meta ?
{meta}
: null} + {lesson.scheduleRuleSlotId ? ( +
ID слота: {lesson.scheduleRuleSlotId}
+ ) : null} + {canEditOverrides && lesson.scheduleRuleSlotId && sourceDate ? ( + + ) : null} +
+ ); +} + +// --- Ячейки слотов --- + +function DayHeaderCells() { + return DAY_ORDER.map(day => ( + + {dayShort(day)} + {DAY_FULL_LABELS[day] || ''} + + )); +} + +function SlotTimeCell({ slot }) { + return ( + + {slot.orderLabel} + {slot.timeLabel} + {slot.id ? ID {slot.id} : null} + + ); +} + +function SlotContent({ states, canEditOverrides, onEditLesson }) { + if (slotStatesEqual(states[0].state, states[1].state)) { + const state = states[0].state; + if (state.kind === 'busy') { + return ( +
+ {state.lessons.map((lesson, index) => ( + + ))} +
+ ); + } + if (state.kind === 'empty') { + return ( +
+ Нет даты +
+ ); + } + return ( +
+ Нет занятий +
+ ); + } + return states.map(({ parity, label, state }) => ( +
+ {label} + {state.kind === 'busy' ? ( + state.lessons.map((lesson, index) => ( + + )) + ) : ( + + {state.kind === 'empty' ? 'Нет даты' : 'Нет занятий'} + + )} +
+ )); +} + +function CombinedSlotCell({ states, canEditOverrides, onEditLesson }) { + const single = slotStatesEqual(states[0].state, states[1].state); + return ( + + + + ); +} + +// --- Таблицы --- + +function SemesterScheduleTable({ section, slots, semester, canEditOverrides, onEditLesson }) { + const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]); + return ( +
+
+

{section.title}

+ {semesterScheduleCaption(semester)} +
+
+ + + + + + + + + {slots.map(slot => ( + + + {DAY_ORDER.map(day => ( + + ))} + + ))} + +
Время
+
+
+ ); +} + +function CombinedWeekTable({ block, slots, lessonsByDateSlot, section, canEditOverrides, onEditLesson }) { + return ( +
+
+

{section.title}

+ {combinedWeekCaption(block)} +
+
+ + + + + + + + + {slots.map(slot => ( + + + {DAY_ORDER.map(day => ( + + ))} + + ))} + +
Время
+
+
+ ); +} + +// --- Мобильная версия --- + +function MobileSlotArticle({ slot, states, canEditOverrides, onEditLesson }) { + return ( +
+
+ {slot.orderLabel} + {slot.timeLabel} +
+
+ +
+
+ ); +} + +function MobileSemesterSchedule({ section, slots, semester, mobileDay, canEditOverrides, onEditLesson }) { + const lessonMatrix = useMemo(() => buildSemesterLessonMatrix(section.lessons), [section]); + return ( +
+
+ {DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)} + {semesterScheduleCaption(semester)} +
+ {slots.map(slot => ( + + ))} +
+ ); +} + +function MobileDayBlock({ block, slots, lessonsByDateSlot, mobileDay, canEditOverrides, onEditLesson }) { + const dayDates = block.datesByDay[mobileDay] || {}; + return ( +
+
+ {DAY_FULL_LABELS[mobileDay] || dayShort(mobileDay)} + {mobileDayCaption(dayDates)} +
+ {slots.map(slot => ( + + ))} +
+ ); +} + +// --- Боковая панель разовых правок (переходный общий модуль, волна 6) --- + +// --- Боковая панель разовых правок --- + +const OVERRIDE_EMPTY_PROMPT = 'Выберите занятие в расписании или откройте его из реестра изменений.'; + +const OverrideDrawer = forwardRef(function OverrideDrawer({ + overrides, + registryRange, + dictionariesRef, + onChanged +}, ref) { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState('edit'); + const [editorPhase, setEditorPhase] = useState('idle'); + const [editorMessage, setEditorMessage] = useState(OVERRIDE_EMPTY_PROMPT); + const [source, setSource] = useState(null); + const [selectedOverride, setSelectedOverride] = useState(null); + const [availability, setAvailability] = useState(null); + const [targetDate, setTargetDate] = useState(null); + const [timeSlots, setTimeSlots] = useState([]); + const [timeSlotsLoading, setTimeSlotsLoading] = useState(false); + const [timeSlotId, setTimeSlotId] = useState(''); + const [teacherId, setTeacherId] = useState(''); + const [teacherLabel, setTeacherLabel] = useState(''); + const [classroomId, setClassroomId] = useState(''); + const [classroomLabel, setClassroomLabel] = useState(''); + const [lessonFormat, setLessonFormat] = useState('Очно'); + const [comment, setComment] = useState(''); + const [calendarOpen, setCalendarOpen] = useState(false); + const [alert, setAlert] = useState(null); + const [busy, setBusy] = useState(false); + + // Асинхронные обработчики читают актуальные значения через этот ref. + const latestRef = useRef(null); + latestRef.current = { + busy, source, selectedOverride, targetDate, timeSlotId, + teacherId, classroomId, lessonFormat, comment, overrides + }; + + useImperativeHandle(ref, () => ({ + openLesson, + openRegistry + })); + + useEffect(() => { + if (!open) return undefined; + document.body.classList.add('schedule-override-open'); + function handleKeydown(event) { + if (event.key === 'Escape') closeDrawer(); + } + document.addEventListener('keydown', handleKeydown); + return () => { + document.body.classList.remove('schedule-override-open'); + document.removeEventListener('keydown', handleKeydown); + }; + }, [open]); + + function closeDrawer() { + setOpen(false); + } + + function openRegistry() { + setOpen(true); + setTab('registry'); + } + + async function openLesson(lesson) { + if (!lesson?.scheduleRuleSlotId) return; + const originalDate = lesson.originalLessonDate || lesson.date; + const existing = latestRef.current.overrides.find(override => + String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId) + && override.lessonDate === originalDate + ) || null; + await prepareEditor(lesson, existing); + } + + async function openOverrideById(overrideId) { + const override = latestRef.current.overrides + .find(item => String(item.id) === String(overrideId)); + if (override) { + await prepareEditor(lessonFromOverride(override, dictionariesRef.current), override); + } + } + + async function prepareEditor(lesson, existing) { + setOpen(true); + setTab('edit'); + setEditorPhase('loading'); + setEditorMessage('Загрузка доступных дат и времени...'); + setAlert(null); + const nextSource = overrideSourceFrom(lesson, existing); + const nextTargetDate = existing?.targetLessonDate || nextSource.lessonDate; + setSource(nextSource); + setSelectedOverride(existing); + setTargetDate(nextTargetDate); + setAvailability(null); + setTimeSlots([]); + setTimeSlotId(''); + try { + const availabilityData = await fetchOverrideAvailability( + nextSource.baseRuleSlotId, + nextSource.lessonDate + ); + setAvailability(availabilityData); + await loadTimeSlotsFor(nextTargetDate, initialTimePreference(existing, nextSource)); + const dictionaries = dictionariesRef.current; + const effectiveTeacherId = existing?.newTeacherId || nextSource.teacherId || ''; + const effectiveClassroomId = existing?.newClassroomId || nextSource.classroomId || ''; + setTeacherId(String(effectiveTeacherId)); + setTeacherLabel( + overrideTeacherName(dictionaries, effectiveTeacherId) + || nextSource.teacherName + || `Преподаватель ${effectiveTeacherId}` + ); + setClassroomId(String(effectiveClassroomId)); + setClassroomLabel( + overrideClassroomName(dictionaries, effectiveClassroomId) + || nextSource.classroomName + || `Аудитория ${effectiveClassroomId}` + ); + setLessonFormat(existing?.newLessonFormat || nextSource.lessonFormat || 'Очно'); + setComment(existing?.comment || ''); + setEditorPhase('ready'); + } catch (error) { + setEditorPhase('error'); + setEditorMessage(error.message || 'Не удалось загрузить данные занятия'); + } + } + + async function loadTimeSlotsFor(date, preference) { + setTimeSlotsLoading(true); + setTimeSlotId(''); + try { + const slots = await fetchEffectiveTimeSlots(date); + setTimeSlots(slots); + const selected = pickMatchingTimeSlot(slots, preference?.id, preference?.startTime, preference?.endTime); + setTimeSlotId(selected ? String(selected.id) : ''); + } finally { + setTimeSlotsLoading(false); + } + } + + async function selectDate(date) { + const current = latestRef.current; + if (!date || !current.source || date === current.targetDate) return; + setTargetDate(date); + try { + await loadTimeSlotsFor(date, { + id: current.source.timeSlotId, + startTime: current.source.startTime, + endTime: current.source.endTime + }); + } catch (error) { + setAlert({ message: error.message || 'Не удалось загрузить время занятий', type: 'error' }); + } + } + + async function save(event) { + event.preventDefault(); + if (latestRef.current.busy) return; + setAlert(null); + const current = latestRef.current; + const { error, payload } = buildOverridePayload({ + source: current.source, + selectedOverride: current.selectedOverride, + targetDate: current.targetDate, + timeSlotId: current.timeSlotId, + teacherId: current.teacherId, + classroomId: current.classroomId, + lessonFormat: current.lessonFormat, + comment: current.comment + }); + if (error) { + setAlert({ message: error, type: 'error' }); + return; + } + await mutate(() => saveScheduleOverride(payload, current.selectedOverride?.id || null)); + } + + async function cancelLesson() { + const current = latestRef.current; + if (current.busy || !current.source) return; + await mutate(() => saveScheduleOverride( + buildCancelOverridePayload(current.source, current.comment), + current.selectedOverride?.id || null + )); + } + + async function restoreRule() { + const current = latestRef.current; + if (current.busy || !current.selectedOverride) return; + await mutate(() => deleteScheduleOverride(current.selectedOverride.id)); + } + + async function mutate(operation) { + setBusy(true); + setAlert(null); + try { + await operation(); + await onChanged?.(); + setSource(null); + setSelectedOverride(null); + setEditorPhase('idle'); + setEditorMessage(OVERRIDE_EMPTY_PROMPT); + openRegistry(); + } catch (error) { + setAlert({ message: error.message || 'Не удалось сохранить разовое изменение', type: 'error' }); + } finally { + setBusy(false); + } + } + + async function loadTeacherOptions(query, selectedId) { + const items = await fetchTeacherOptions(query, selectedId); + mergeDictionaryItems(dictionariesRef.current, 'teachers', items || []); + return items; + } + + async function loadClassroomOptions(query, selectedId) { + const items = await fetchClassroomOptions(query, selectedId); + mergeDictionaryItems(dictionariesRef.current, 'classrooms', items || []); + return items; + } + + const availableDates = useMemo( + () => new Set(availability?.availableDates || []), + [availability] + ); + const weekDayList = source ? weekDates(source.lessonDate) : []; + const calendarMonths = useMemo( + () => (availability + ? semesterCalendarMonths( + availability.semesterStartDate, + availability.semesterEndDate, + availableDates, + targetDate + ) + : []), + [availability, availableDates, targetDate] + ); + const selectedTime = timeSlots.find(slot => String(slot.id) === String(timeSlotId)) || null; + const registryItems = overrides.map(override => overrideRegistryItem(override, dictionariesRef.current)); + const comparisonTitle = source ? `${source.subjectName} · ${source.lessonTypeName}` : '—'; + const beforeMeta = source ? overrideBeforeMeta(source) : '—'; + const afterMeta = source + ? overrideAfterMeta({ + source, + targetDate, + selectedTime, + teacherName: overrideTeacherName(dictionariesRef.current, teacherId), + classroomName: overrideClassroomName(dictionariesRef.current, classroomId), + lessonFormat + }) + : '—'; + const registryPeriodText = registryRange + ? `${formatDate(registryRange.startDate)} — ${formatDate(registryRange.endDate)}` + : '—'; + + return ( + + ); +}); diff --git a/frontend/react/admin/tabs/SubjectsTab.jsx b/frontend/react/admin/tabs/SubjectsTab.jsx new file mode 100644 index 0000000..beccd3c --- /dev/null +++ b/frontend/react/admin/tabs/SubjectsTab.jsx @@ -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 ( + <> +
+

Новая дисциплина

+
+
+
+ + setCreateName(event.target.value)} + /> +
+
+ + setCreateCode(event.target.value)} + /> +
+
+ + +
+ +
+
+ {createAlert.text} +
+
+
+ +
+

Все дисциплины

+
+
+ + handleSearchInput(event.target.value)} + /> +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + {referenceError ? ( + + ) : tableError ? ( + + + + ) : tableLoading ? ( + + ) : subjects.length === 0 ? ( + + ) : subjects.map(subject => { + const teachers = teachersBySubject.get(String(subject.id)) || []; + return ( + + + + + + + + + ); + })} + +
IDНазваниеКод предметаКафедраПреподавателиДействия
Ошибка загрузки справочников: {referenceError}
+ {tableError} + +
{subjectsEmptyMessage(state)}
{subject.id}{subject.name}{subject.code || '-'}{departmentLabel(departments || [], subject.departmentId)} +
+ {teachers.length === 0 ? ( + Нет преподавателей + ) : teachers.map(teacher => ( + + {teacher.fullName || teacher.username} + + ))} +
+
+ + +
+
+ {pageData && ( + applyState({ page })} + onSize={size => applyState({ size, page: 0 })} + /> + )} +
+ + {teachersModal && ( +
{ + if (event.target === event.currentTarget) setTeachersModal(null); + }} + > +
+
+
+

Преподаватели дисциплины

+
+ {teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name} +
+
+ +
+
+

Добавить привязку

+
+
+
+ + 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) + : ''} + /> +
+ +
+
+ {teachersModal.alert.text} +
+
+
+
+ + + + + + + + + {(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? ( + + ) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => ( + + + + + ))} + +
ПреподавательДействия
Преподаватели не привязаны
+ {assignment.fullName || assignment.username}{' '} + ({assignment.username}) + + +
+
+
+
+ )} + + {confirmState && ( + void runConfirm()} + onClose={() => setConfirmState(null)} + /> + )} + + {alertState && ( + setAlertState(null)} + /> + )} + + ); +} diff --git a/frontend/react/admin/tabs/TeacherAbsencesTab.jsx b/frontend/react/admin/tabs/TeacherAbsencesTab.jsx new file mode 100644 index 0000000..8ae45ec --- /dev/null +++ b/frontend/react/admin/tabs/TeacherAbsencesTab.jsx @@ -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' + ?
Загрузка...
+ : absencesPhase === 'error' + ?
{absencesError}
+ : (absences.length ? absences.map(item => ( +
+
+ {dayOfMonth(item.startDate)} + {monthShort(item.startDate)} + + {dayOfMonth(item.endDate)} + {monthShort(item.endDate)} +
+
+
+
+

{item.teacherName || 'Преподаватель не указан'}

+

{item.reason || 'Причина не указана'}

+
+ {statusLabel(item.status)} +
+
+ {formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)} + {decisionCountLabel(item.decisionCount || 0)} + {item.reviewComment ? {item.reviewComment} : null} +
+
+
+ {item.status === 'PENDING' ? ( + <> + + + + ) : null} + {['APPROVED', 'RESOLVED'].includes(item.status) ? ( + + ) : null} + {['PENDING', 'APPROVED'].includes(item.status) ? ( + + ) : null} +
+
+ )) : ( +
+ Инцидентов не найдено + Измените фильтр или зарегистрируйте отсутствие. +
+ )); + + const preferenceContent = preferencePhase === 'idle' + ? null + : preferencePhase === 'meta-loading' + ?
Загрузка семестров...
+ : preferencePhase === 'loading' + ?
Загрузка пожеланий...
+ : preferencePhase === 'error' + ?
{preferenceError}
+ : (preferenceItems.length ? preferenceItems.map(item => ( +
+ +
+ + {item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'} + +

{item.teacherName}

+

{preferenceDescription(item)}

+ {item.comment ?
{item.comment}
: null} + {item.reviewComment + ? Решение: {item.reviewComment} + : null} +
+
+ {genericStatusLabel(item.status)} + {item.status === 'PENDING' ? ( + <> + setPreferenceComments(previous => ({ + ...previous, + [item.id]: event.target.value + }))} + /> +
+ + +
+ + ) : null} +
+
+ )) : ( +
+ Пожеланий не найдено + Измените фильтры или дождитесь новых отметок преподавателей. +
+ )); + + const changeContent = changePhase === 'idle' + ? null + : changePhase === 'loading' + ?
Загрузка заявок...
+ : changePhase === 'error' + ?
{changeError}
+ : (changeItems.length ? changeItems.map(item => ( +
+ +
+ + {formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName} + +

{item.teacherName} · {item.subjectName}

+

{changeTypeLabel(item.requestType)} → {changeReviewTarget(item)}

+
{item.reason}
+ {item.reviewComment + ? Решение: {item.reviewComment} + : null} +
+ История · {(item.history || []).length} +
    + {(item.history || []).map((entry, index) => ( +
  1. + {genericStatusLabel(entry.status)} · {entry.comment || 'Без комментария'} +
  2. + ))} +
+
+
+
+ {genericStatusLabel(item.status)} + {item.status === 'PENDING' && canApplyChanges ? ( + <> + setChangeComments(previous => ({ + ...previous, + [item.id]: event.target.value + }))} + /> +
+ + +
+ + ) : item.status === 'PENDING' + ? Применение доступно учебному отделу + : null} +
+
+ )) : ( +
+ Заявок не найдено + Очередь изменений сейчас пуста. +
+ )); + + return ( + <> +
+
+ Оперативный контур расписания +

От регистрации инцидента до готовой замены

+

Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.

+
+
+
{stats.active}требуют внимания
+
{stats.pending}ждут подтверждения
+
{stats.resolved}разрешены
+
+
+ +
+
+
+ 01 +

Зарегистрировать отсутствие

+
+ Ответственный сотрудник создаёт согласованную запись сразу +
+
+
+
+ + [ + 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: '' })} + /> +
+
+ + setStartDate(event.target.value)} + /> +
+
+ + setEndDate(event.target.value)} + /> +
+
+ + setReason(event.target.value)} + /> +
+ +
+ + +
+ +
+
+
+ 02 +

Реестр инцидентов

+
+
+ + +
+
+
+ {registryContent} +
+
+ +
+
+
+
+ 03 +

Пожелания на семестр

+

Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.

+
+
+ + {preferenceItems.filter(item => item.status === 'PENDING').length} + + ожидают +
+
+
+ + + +
+
+ {preferenceContent} +
+
+ +
+
+
+ 04 +

Заявки на изменение

+

Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.

+
+
+ + {changeItems.filter(item => item.status === 'PENDING').length} + + в очереди +
+
+
+ + +
+
+ {changeContent} +
+
+
+ + {wizardOpen ? ( +
{ + if (event.target === event.currentTarget) closeWizard(); + }} + > +
+
+
+ Мастер замены +

+ {wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'} +

+

+ {wizardData + ? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}` + : 'Загрузка...'} +

+
+ +
+ + +
+ {wizardLoading + ?
Подбираем свободные ресурсы...
+ : (wizardData + ? (wizardData.lessons.length + ? wizardData.lessons.map((lesson, index) => ( + setDecisions(previous => ({ ...previous, [index]: value }))} + /> + )) + : ( +
+ Затронутых занятий нет + Инцидент можно завершить без точечных изменений. +
+ )) + : null)} +
+
+
+

Журнал решений

+ + {wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'} + +
+
+ {wizardData ? ( + (wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => ( +
+ +
+ {resolutionLabel(item.resolution)} + {formatAbsenceDate(item.lessonDate)} +
+ + {decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''} + +
+ )) :
Решений пока нет
+ ) : null} +
+
+
+ Пустые строки останутся без изменений +
+ + +
+
+
+
+ ) : null} + + {alert ? ( +
{ + if (event.target === event.currentTarget) setAlert(null); + }} + > +
+
+

{alert.title}

+ +
+

{alert.message}

+
+ +
+
+
+ ) : null} + + ); +} + +function LessonDecision({ lesson, index, locked, value, onChange }) { + const options = decisionOptions(lesson); + + return ( +
+
+ {trimTime(lesson.startTime)} + {trimTime(lesson.endTime)} + {formatAbsenceDate(lesson.lessonDate)} +
+
+ {lesson.lessonTypeName || 'Занятие'} +

{lesson.subjectName || 'Дисциплина не указана'}

+

+ {(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'} +

+ {lesson.existingOverrideId + ? У пары уже есть точечное изменение — доступна только запись отклонения. + : null} +
+
+ + + {lesson.decisionStatus + ? {decisionStatusLabel(lesson.decisionStatus)} + : null} +
+
+ ); +} diff --git a/frontend/react/admin/tabs/TeacherRequestsTab.jsx b/frontend/react/admin/tabs/TeacherRequestsTab.jsx new file mode 100644 index 0000000..341c246 --- /dev/null +++ b/frontend/react/admin/tabs/TeacherRequestsTab.jsx @@ -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 ( +
+
+

Заявки на преподавателей

+
+ {countLabel} + +
+
+
+ {alert.text} +
+
+
+ + handleSearchInput(event.target.value)} + /> +
+
+ + +
+
+
+ + + + + + + + + + + + + + {departmentsError ? ( + + + + ) : tableError ? ( + + + + ) : tableLoading ? ( + + + + ) : requests.length === 0 ? ( + + + + ) : requests.map(request => ( + setRejectState({ requestId })} + /> + ))} + +
КафедраЛогинФИОДолжностьПарольКомментарийДействия
+ Не удалось загрузить заявки преподавателей +
+ {tableError} + +
+
+ {teacherRequestsEmptyMessage(state)} +
+
+ {pageData && ( + applyState({ page })} + onSize={size => applyState({ size, page: 0 })} + /> + )} + + {rejectState && ( + void confirmReject(value)} + onClose={() => setRejectState(null)} + /> + )} +
+ ); +} + +// Строка заявки хранит черновик полей локально: значения инпутов строки +// отправляются при одобрении — как в 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 ( + + + + + + setUsername(event.target.value)} + /> + + + setFullName(event.target.value)} + /> + + + setJobTitle(event.target.value)} + /> + + + setPassword(event.target.value)} + /> + + {request.comment || '-'} + +
+ {request.status === 'PENDING' ? ( + <> + + + + ) : ( + {reviewedStatusLabel(request.status)} + )} +
+ + + ); +} diff --git a/frontend/react/admin/tabs/UniversityStructureTab.jsx b/frontend/react/admin/tabs/UniversityStructureTab.jsx new file mode 100644 index 0000000..f960f20 --- /dev/null +++ b/frontend/react/admin/tabs/UniversityStructureTab.jsx @@ -0,0 +1,1094 @@ +import { useEffect, useRef, useState } from 'react'; + +import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx'; +import { Pagination } from '../../shared/ui/Pagination.jsx'; +import { + DEPARTMENT_TABLE_COLUMNS, + PROFILE_TABLE_COLUMNS, + SPECIALTY_TABLE_COLUMNS, + clampPage, + createDepartment, + createProfile, + createSpecialty, + deleteDepartment, + deleteProfile, + deleteSpecialty, + departmentCode, + departmentFields, + departmentName, + fetchAllProfiles, + fetchDepartments, + fetchProfiles, + fetchSpecialties, + filterListItems, + pageSlice, + profileFields, + profileSpecialtyLabel, + specialtyCode, + specialtyFields, + specialtyName, + specialtyOptionLabel, + updateDepartment, + updateProfile, + updateSpecialty +} from '../university-structure-service.js'; + +const SEARCH_DEBOUNCE_MS = 250; + +const SECTIONS = ['departments', 'specialties', 'profiles']; + +const SECTION_TITLES = { + departments: 'Кафедры', + specialties: 'Специальности', + profiles: 'Профили' +}; + +function initialListStates() { + return { + departments: { query: '', page: 0, size: 25 }, + specialties: { query: '', page: 0, size: 25 }, + profiles: { query: '', page: 0, size: 25 } + }; +} + +export function UniversityStructureTab() { + const [activeSection, setActiveSection] = useState('departments'); + + const [departments, setDepartments] = useState(null); + const [specialties, setSpecialties] = useState(null); + const [allProfiles, setAllProfiles] = useState(null); + const [loadErrors, setLoadErrors] = useState({}); + + const [listStates, setListStates] = useState(initialListStates); + const [searchInputs, setSearchInputs] = useState({ departments: '', specialties: '', profiles: '' }); + + const [createDept, setCreateDept] = useState({ name: '', code: '' }); + const [createDeptAlert, setCreateDeptAlert] = useState({ text: '', isError: false }); + const [createSpec, setCreateSpec] = useState({ name: '', code: '' }); + const [createSpecAlert, setCreateSpecAlert] = useState({ text: '', isError: false }); + + const [profileForm, setProfileForm] = useState({ + profileId: '', + specId: '', + name: '', + description: '', + editing: false + }); + const [profileAlert, setProfileAlert] = useState({ text: '', isError: false }); + + const [editDeptModal, setEditDeptModal] = useState(null); + const [editSpecModal, setEditSpecModal] = useState(null); + const [profilesModal, setProfilesModal] = useState(null); + const [confirmState, setConfirmState] = useState(null); + const [alertState, setAlertState] = useState(null); + + const searchTimers = useRef({}); + const profileFormRef = useRef(null); + + useEffect(() => { + void loadData(); + return () => { + Object.values(searchTimers.current).forEach(timer => window.clearTimeout(timer)); + }; + }, []); + + async function loadData() { + const [deptResult, specResult, profilesResult] = await Promise.allSettled([ + fetchDepartments(), + fetchSpecialties(), + fetchAllProfiles() + ]); + setDepartments(deptResult.status === 'fulfilled' ? deptResult.value || [] : null); + setSpecialties(specResult.status === 'fulfilled' ? specResult.value || [] : null); + setAllProfiles(profilesResult.status === 'fulfilled' ? profilesResult.value || [] : null); + setLoadErrors({ + departments: deptResult.status === 'rejected', + specialties: specResult.status === 'rejected', + profiles: profilesResult.status === 'rejected' + }); + } + + function handleSearchInput(section, value) { + setSearchInputs(current => ({ ...current, [section]: value })); + window.clearTimeout(searchTimers.current[section]); + searchTimers.current[section] = window.setTimeout(() => { + setListStates(current => ({ ...current, [section]: { ...current[section], query: value.trim(), page: 0 } })); + }, SEARCH_DEBOUNCE_MS); + } + + function applyListState(section, patch) { + setListStates(current => ({ ...current, [section]: { ...current[section], ...patch } })); + } + + function visibleItems(section, items, fields) { + const listState = listStates[section]; + const filtered = filterListItems(items, listState.query, fields); + const totalPages = Math.ceil(filtered.length / listState.size); + const page = clampPage(listState.page, totalPages); + return { + filtered, + page, + size: listState.size, + totalPages, + items: pageSlice(filtered, page, listState.size) + }; + } + + // --- Кафедры --- + + async function submitCreateDepartment(event) { + event.preventDefault(); + const name = createDept.name.trim(); + const code = createDept.code.trim(); + if (!name || !code) { + setCreateDeptAlert({ text: 'Заполните все поля', isError: true }); + return; + } + try { + await createDepartment({ name, code }); + setCreateDeptAlert({ text: `Кафедра «${name}» создана`, isError: false }); + setCreateDept({ name: '', code: '' }); + await loadData(); + } catch (error) { + setCreateDeptAlert({ text: error.message || 'Ошибка создания кафедры', isError: true }); + } + } + + function openEditDepartmentModal(department) { + setEditDeptModal({ + department, + name: departmentName(department) || '', + code: departmentCode(department) === null || departmentCode(department) === undefined + ? '' + : String(departmentCode(department)), + alert: { text: '', isError: false } + }); + } + + async function submitEditDepartment(event) { + event.preventDefault(); + const modal = editDeptModal; + const name = modal.name.trim(); + const code = modal.code.trim(); + if (!name || !code) { + setEditDeptModal({ ...modal, alert: { text: 'Заполните все поля', isError: true } }); + return; + } + try { + await updateDepartment(modal.department.id, { name, code }); + setEditDeptModal(null); + setCreateDeptAlert({ text: `Кафедра «${name}» обновлена`, isError: false }); + await loadData(); + } catch (error) { + setEditDeptModal({ ...modal, alert: { text: error.message || 'Ошибка обновления кафедры', isError: true } }); + } + } + + function confirmDeleteDepartment(department) { + setConfirmState({ + title: 'Удаление кафедры', + message: 'Удалить кафедру?', + confirmLabel: 'Удалить', + danger: true, + action: async () => { + try { + await deleteDepartment(department.id); + setCreateDeptAlert({ text: 'Кафедра удалена', isError: false }); + await loadData(); + } catch (error) { + setCreateDeptAlert({ text: error.message || 'Ошибка удаления кафедры', isError: true }); + } + } + }); + } + + // --- Специальности --- + + async function submitCreateSpecialty(event) { + event.preventDefault(); + const name = createSpec.name.trim(); + const code = createSpec.code.trim(); + if (!name || !code) { + setCreateSpecAlert({ text: 'Заполните все поля', isError: true }); + return; + } + try { + await createSpecialty({ name, code }); + setCreateSpecAlert({ text: `Специальность «${name}» создана`, isError: false }); + setCreateSpec({ name: '', code: '' }); + await loadData(); + } catch (error) { + setCreateSpecAlert({ text: error.message || 'Ошибка создания специальности', isError: true }); + } + } + + function openEditSpecialtyModal(speciality) { + setEditSpecModal({ + speciality, + name: specialtyName(speciality) || '', + code: specialtyCode(speciality) || '', + alert: { text: '', isError: false } + }); + } + + async function submitEditSpecialty(event) { + event.preventDefault(); + const modal = editSpecModal; + const name = modal.name.trim(); + const code = modal.code.trim(); + if (!name || !code) { + setEditSpecModal({ ...modal, alert: { text: 'Заполните все поля', isError: true } }); + return; + } + try { + await updateSpecialty(modal.speciality.id, { name, code }); + setEditSpecModal(null); + setCreateSpecAlert({ text: `Специальность «${name}» обновлена`, isError: false }); + await loadData(); + } catch (error) { + setEditSpecModal({ ...modal, alert: { text: error.message || 'Ошибка обновления специальности', isError: true } }); + } + } + + function confirmDeleteSpecialty(speciality) { + setConfirmState({ + title: 'Удаление специальности', + message: 'Удалить специальность?', + confirmLabel: 'Удалить', + danger: true, + action: async () => { + try { + await deleteSpecialty(speciality.id); + setCreateSpecAlert({ text: 'Специальность удалена', isError: false }); + await loadData(); + } catch (error) { + setCreateSpecAlert({ text: error.message || 'Ошибка удаления специальности', isError: true }); + } + } + }); + } + + // --- Профили: вкладка --- + + function resetProfileForm() { + setProfileForm({ profileId: '', specId: '', name: '', description: '', editing: false }); + } + + async function submitProfileForm(event) { + event.preventDefault(); + const { profileId, specId, name, description } = profileForm; + const trimmedName = name.trim(); + const trimmedDescription = description.trim(); + if (!specId) { + setProfileAlert({ text: 'Выберите специальность', isError: true }); + return; + } + if (!trimmedName) { + setProfileAlert({ text: 'Введите название профиля', isError: true }); + return; + } + try { + if (profileId) { + await updateProfile(specId, profileId, { name: trimmedName, description: trimmedDescription }); + setProfileAlert({ text: `Профиль «${trimmedName}» обновлен`, isError: false }); + } else { + await createProfile(specId, { name: trimmedName, description: trimmedDescription }); + setProfileAlert({ text: `Профиль «${trimmedName}» создан`, isError: false }); + } + resetProfileForm(); + await loadData(); + } catch (error) { + setProfileAlert({ text: error.message || 'Ошибка сохранения профиля', isError: true }); + } + } + + function editTabProfile(profile) { + setProfileForm({ + profileId: String(profile.id), + specId: String(profile.specialityId), + name: profile.name || '', + description: profile.description || '', + editing: true + }); + profileFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + function confirmDeleteTabProfile(profile) { + setConfirmState({ + title: 'Удаление профиля', + message: 'Удалить профиль обучения?', + confirmLabel: 'Удалить', + danger: true, + action: async () => { + try { + await deleteProfile(profile.specialityId, profile.id); + setProfileAlert({ text: 'Профиль удален', isError: false }); + await loadData(); + } catch (error) { + setProfileAlert({ text: error.message || 'Ошибка удаления профиля', isError: true }); + } + } + }); + } + + // --- Профили: модальное окно специальности --- + + async function openProfilesModal(speciality) { + setProfilesModal({ + speciality, + profiles: null, + form: { profileId: '', name: '', description: '', editing: false }, + alert: { text: '', isError: false } + }); + try { + const profiles = await fetchProfiles(speciality.id); + setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current)); + } catch (error) { + setProfilesModal(current => ( + current ? { ...current, profiles: [], alert: { text: error.message || 'Ошибка загрузки', isError: true } } : current + )); + } + } + + function resetModalProfileForm() { + setProfilesModal(current => ( + current ? { ...current, form: { profileId: '', name: '', description: '', editing: false } } : current + )); + } + + async function submitModalProfile(event) { + event.preventDefault(); + const modal = profilesModal; + const { profileId, name, description } = modal.form; + const trimmedName = name.trim(); + const trimmedDescription = description.trim(); + if (!trimmedName) { + setProfilesModal({ ...modal, alert: { text: 'Введите название профиля', isError: true } }); + return; + } + try { + if (profileId) { + await updateProfile(modal.speciality.id, profileId, { name: trimmedName, description: trimmedDescription }); + setProfilesModal(current => ( + current ? { ...current, alert: { text: `Профиль «${trimmedName}» обновлен`, isError: false } } : current + )); + } else { + await createProfile(modal.speciality.id, { name: trimmedName, description: trimmedDescription }); + setProfilesModal(current => ( + current ? { ...current, alert: { text: `Профиль «${trimmedName}» создан`, isError: false } } : current + )); + } + resetModalProfileForm(); + const profiles = await fetchProfiles(modal.speciality.id); + setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current)); + } catch (error) { + setProfilesModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка сохранения профиля', isError: true } } : current + )); + } + } + + function editModalProfile(profile) { + setProfilesModal(current => ( + current + ? { + ...current, + form: { + profileId: String(profile.id), + name: profile.name || '', + description: profile.description || '', + editing: true + } + } + : current + )); + } + + function confirmDeleteModalProfile(profile) { + const modal = profilesModal; + if (!modal) return; + setConfirmState({ + title: 'Удаление профиля', + message: 'Удалить профиль обучения?', + confirmLabel: 'Удалить', + danger: true, + action: async () => { + try { + await deleteProfile(modal.speciality.id, profile.id); + setProfilesModal(current => ( + current ? { ...current, alert: { text: 'Профиль удален', isError: false } } : current + )); + const profiles = await fetchProfiles(modal.speciality.id); + setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current)); + } catch (error) { + setProfilesModal(current => ( + current ? { ...current, alert: { text: error.message || 'Ошибка удаления профиля', isError: true } } : current + )); + } + } + }); + } + + async function runConfirm() { + const confirm = confirmState; + setConfirmState(null); + await confirm.action(); + } + + const departmentsView = visibleItems('departments', departments || [], departmentFields); + const specialtiesView = visibleItems('specialties', specialties || [], specialtyFields); + const profilesView = visibleItems('profiles', allProfiles || [], profileFields); + + return ( + <> +
+
+ {SECTIONS.map(section => ( + + ))} +
+ + {/* Раздел Кафедр */} + + + {/* Раздел Специальностей */} + + + {/* Раздел Профилей */} + +
+ + {editDeptModal && ( +
{ + if (event.target === event.currentTarget) setEditDeptModal(null); + }} + > +
+

Редактирование кафедры

+ +
+
+
+ + setEditDeptModal(current => ( + current ? { ...current, name: event.target.value } : current + ))} + /> +
+
+ + setEditDeptModal(current => ( + current ? { ...current, code: event.target.value } : current + ))} + /> +
+ +
+
+ {editDeptModal.alert.text} +
+
+
+
+ )} + + {editSpecModal && ( +
{ + if (event.target === event.currentTarget) setEditSpecModal(null); + }} + > +
+

Редактирование специальности

+ +
+
+
+ + setEditSpecModal(current => ( + current ? { ...current, name: event.target.value } : current + ))} + /> +
+
+ + setEditSpecModal(current => ( + current ? { ...current, code: event.target.value } : current + ))} + /> +
+ +
+
+ {editSpecModal.alert.text} +
+
+
+
+ )} + + {profilesModal && ( +
{ + if (event.target === event.currentTarget) setProfilesModal(null); + }} + > +
+
+
+

Профили обучения

+
+ {specialtyCode(profilesModal.speciality)} · {specialtyName(profilesModal.speciality)} +
+
+ +
+
+

{profilesModal.form.editing ? 'Редактирование профиля' : 'Создание профиля'}

+
+
+
+ + setProfilesModal(current => ( + current + ? { ...current, form: { ...current.form, name: event.target.value } } + : current + ))} + /> +
+
+ + setProfilesModal(current => ( + current + ? { ...current, form: { ...current.form, description: event.target.value } } + : current + ))} + /> +
+
+ + {profilesModal.form.editing && ( + + )} +
+
+
+ {profilesModal.alert.text} +
+
+
+
+ + + + + + + + + + + {profilesModal.profiles === null ? ( + + ) : profilesModal.profiles.length === 0 ? ( + + ) : profilesModal.profiles.map(profile => ( + + + + + + + ))} + +
IDНазваниеОписаниеДействия
Загрузка...
Профили не созданы
{profile.id}{profile.name}{profile.description || '-'} + + +
+
+
+
+ )} + + {confirmState && ( + void runConfirm()} + onClose={() => setConfirmState(null)} + /> + )} + + {alertState && ( + setAlertState(null)} + /> + )} + + ); +} diff --git a/frontend/react/admin/tabs/UsersTab.jsx b/frontend/react/admin/tabs/UsersTab.jsx new file mode 100644 index 0000000..666dc56 --- /dev/null +++ b/frontend/react/admin/tabs/UsersTab.jsx @@ -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 ( + <> +
+

Новый пользователь

+
+
+
+ + setCreateUsername(event.target.value)} + /> +
+
+ + setCreatePassword(event.target.value)} + /> +
+
+ + +
+
+ + setCreateFullName(event.target.value)} + /> +
+
+ + setCreateJobTitle(event.target.value)} + /> +
+
+ + setCreateDepartmentId(event.target.value)} + /> +
+ +
+
+ {createAlert.text} +
+
+
+ +
+

Все пользователи

+
+
+ + handleSearchInput(event.target.value)} + /> +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + + {tableError ? ( + + + + ) : tableLoading ? ( + + ) : users.length === 0 ? ( + + ) : users.map(user => ( + + + + + + + + + + ))} + +
IDИмя пользователяФИОДолжностьКафедраРольДействия
+ {tableError} + +
{usersEmptyMessage(state)}
{user.id}{user.username}{user.fullName || '-'}{user.jobTitle || '-'}{user.departmentName || '-'}{roleLabel(user.role)} + {user.status === 'ARCHIVED' ? ( + + ) : ( + + )} +
+
+ {pageData && ( + applyState({ page })} + onSize={size => applyState({ size, page: 0 })} + /> + )} +
+ + {confirmState && ( + void runConfirm()} + onClose={() => setConfirmState(null)} + /> + )} + + {alertState && ( + setAlertState(null)} + /> + )} + + ); +} diff --git a/frontend/react/admin/teacher-absences-service.js b/frontend/react/admin/teacher-absences-service.js new file mode 100644 index 0000000..bbd5f4a --- /dev/null +++ b/frontend/react/admin/teacher-absences-service.js @@ -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 }; diff --git a/frontend/react/admin/teacher-requests-service.js b/frontend/react/admin/teacher-requests-service.js new file mode 100644 index 0000000..7e793ea --- /dev/null +++ b/frontend/react/admin/teacher-requests-service.js @@ -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: 'Ошибка отклонения заявки' } + ); +} diff --git a/frontend/react/admin/testing/render-admin.jsx b/frontend/react/admin/testing/render-admin.jsx new file mode 100644 index 0000000..0d5c683 --- /dev/null +++ b/frontend/react/admin/testing/render-admin.jsx @@ -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)); +} diff --git a/frontend/react/admin/university-structure-service.js b/frontend/react/admin/university-structure-service.js new file mode 100644 index 0000000..88b513b --- /dev/null +++ b/frontend/react/admin/university-structure-service.js @@ -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}`; +} diff --git a/frontend/react/admin/users-service.js b/frontend/react/admin/users-service.js new file mode 100644 index 0000000..c72efee --- /dev/null +++ b/frontend/react/admin/users-service.js @@ -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 ? 'Пользователи не найдены' : 'Пользователей пока нет'; +} diff --git a/frontend/react/login/LoginApp.jsx b/frontend/react/login/LoginApp.jsx new file mode 100644 index 0000000..35d7076 --- /dev/null +++ b/frontend/react/login/LoginApp.jsx @@ -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 ( + + ); +} + +function UserIcon() { + return ( + + ); +} + +function LockIcon() { + return ( + + ); +} + +function EyeIcon() { + return ( + + ); +} + +function Spinner() { + return ( + + ); +} + +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 ( + <> +