Merge pull request 'Redesign' (#13) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 14s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 13s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m13s

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-06-25 16:43:54 +00:00
73 changed files with 4730 additions and 1222 deletions

278
DEVOPS.md Normal file
View File

@@ -0,0 +1,278 @@
# РАЗРАБОТКА И ВНЕДРЕНИЕ ОТКАЗОУСТОЙЧИВОЙ МУЛЬТИТЕНАНТНОЙ ИНФРАСТРУКТУРЫ ДЛЯ СИСТЕМЫ УПРАВЛЕНИЯ УНИВЕРСИТЕТСКИМ РАСПИСАНИЕМ «МАГИСТР»
**Диссертационное исследование и отчет о проделанной инженерной работе в качестве DevOps-архитектора проекта**
---
## ВВЕДЕНИЕ
Современные информационные системы для образовательных учреждений требуют строгого соблюдения требований к безопасности, изоляции данных различных организаций (университетов), гибкости масштабирования и высокой наблюдаемости (observability). В рамках разработки проекта **«Магистр»** — системы управления расписанием учебных занятий — передо мной встала задача проектирования и построения инфраструктуры, способной обслуживать десятки университетов в рамках единого прикладного контура при условии жесткой изоляции их баз данных.
В качестве DevOps-инженера проекта я спроектировал и реализовал архитектурное решение, объединяющее технологии аппаратной виртуализации, оркестрации контейнеров, автоматизации конфигурации, распределенного мониторинга и непрерывной интеграции. В данном документе подробно описаны теоретические предпосылки, практическая реализация и архитектурные решения, внедренные мной в продакшн-окружение проекта.
---
## РАЗДЕЛ 1. СИСТЕМНАЯ АРХИТЕКТУРА И КОНЦЕПЦИЯ МУЛЬТИТЕНАНТНОСТИ
### 1.1 Выбор паттерна изоляции данных
При проектировании мультитенантных (multi-tenant) систем классически выделяют три подхода к организации баз данных:
1. **Shared Database & Shared Schema**: Все клиенты используют общие таблицы, записи разделяются по полю `tenant_id`. Подход дешев в обслуживании, но несет колоссальные риски утечки данных из-за ошибок в SQL-запросах приложения и не позволяет разграничивать физический доступ к базам.
2. **Shared Database & Separate Schemas**: Одна СУБД, но разные логические схемы для каждого клиента. Обеспечивает умеренную изоляцию, но сохраняет единую точку отказа и общие аппаратные ресурсы СУБД.
3. **Database-per-Tenant (Выбранный подход)**: Каждый клиент (университет) владеет собственной физически изолированной базой данных, расположенной на выделенном сервере или виртуальной машине.
Для проекта «Магистр» мной был выбран и реализован паттерн **Database-per-Tenant**. Это обусловлено спецификой клиентов (высшие учебные заведения), которые согласно законодательству (например, ФЗ-152 «О персональных данных») обязаны хранить свои данные локально или требовать полной изоляции конфиденциальной информации. Кроме того, данный подход позволяет:
* Исключить влияние высокой нагрузки одного университета (например, в период составления сессий) на доступность системы для других вузов («синдром шумного соседа»).
* Выполнять индивидуальное резервное копирование и обслуживание БД каждого университета без остановки всей платформы.
* Переносить базы данных вузов на их собственные физические серверы в локальных сетях (on-premise), сохраняя при этом работу приложения в центральном облаке.
### 1.2 Архитектурная схема движения трафика
Ниже представлена разработанная мной схема прохождения сетевых запросов и агрегации телеметрии в системе:
```mermaid
graph TD
Client[Пользовательский браузер] -->|HTTPS: tenant.zuev.company| Caddy[Caddy Reverse Proxy]
Caddy -->|HTTP Round-Robin| Ingress[Traefik Ingress Controller]
subgraph K3s Cluster
Ingress -->|Route /*| Frontend[Frontend Pods x2 Apache]
Ingress -->|Route /api/*| Backend[Backend Pods x1-x2 Spring Boot]
Backend -->|K8s API: Get/Patch CM| K8sAPI[Kubernetes API Server]
BackendConfig[ConfigMap: tenants-config] -.->|Mounted JSON| Backend
end
subgraph Proxmox VE Infrastructure
Backend -->|JDBC Connection| VM1[(VM 1: DBMS MSU<br>PostgreSQL 16)]
Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU<br>PostgreSQL 16)]
VM1 -.->|Metrics OTLP/gRPC| Collector1[OTel Collector VM1]
VM2 -.->|Metrics OTLP/gRPC| Collector2[OTel Collector VM2]
end
subgraph Observability Hub
Backend -->|Logs & Traces OTLP| SigNoz[SigNoz APM Server]
Collector1 -->|Postgres Metrics| SigNoz
Collector2 -->|Postgres Metrics| SigNoz
end
classDef cluster fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef vm fill:#efebe9,stroke:#4e342e,stroke-width:2px;
classDef monitor fill:#efe8ff,stroke:#512da8,stroke-width:2px;
class K3s Cluster cluster;
class Proxmox VE Infrastructure vm;
class Observability Hub monitor;
```
---
## РАЗДЕЛ 2. АППАРАТНАЯ ВИРТУАЛИЗАЦИЯ НА БАЗЕ PROMVOX VE
Для размещения баз данных клиентов и вспомогательных инфраструктурных служб мной был развернут гипервизор **Proxmox Virtual Environment (PVE)** на выделенных серверных мощностях.
### 2.1 Конфигурация виртуализации и изоляции ресурсов
Каждая база данных университета разворачивается в выделенной виртуальной машине (KVM), что гарантирует жесткую изоляцию на уровне ядра ОС и гипервизора.
* **Сетевой уровень**: В Proxmox настроена виртуальная коммутация (Linux Bridge `vmbr0`). Сетевые интерфейсы виртуальных машин баз данных вынесены в приватную подсеть, изолированную от внешней сети. Доступ к ним имеет только подсеть кластера Kubernetes и управляющая машина администратора.
* **Дисковая подсистема**: Использовано локальное хранилище на базе ZFS с зеркалированием (RAID-10 на SSD-накопителях). Это обеспечивает:
* Высокую скорость операций ввода-вывода (IOPS), критически важную для СУБД при транзакционных нагрузках.
* Механизм мгновенных снимков (snapshots) для безопасного проведения обновлений.
* Аппаратное сжатие данных (LZ4) без потери производительности, снижающее износ накопителей.
* **Резервное копирование**: Интегрирован **Proxmox Backup Server (PBS)**. Каждую ночь выполняются инкрементальные бэкапы виртуальных машин с дедупликацией на уровне блоков данных, что минимизирует нагрузку на сеть и диски, позволяя восстановить любую БД на любой момент времени за последние 30 дней.
---
## РАЗДЕЛ 3. ОРКЕСТРАЦИЯ КОНТЕЙНЕРНОЙ ИНФРАСТРУКТУРЫ В KUBERNETES (K3s)
В качестве платформы оркестрации приложений был выбран **K3s** — сертифицированный дистрибутив Kubernetes от Rancher, оптимизированный под средние и малые нагрузки, обладающий минимальными накладными расходами на RAM/CPU для Control Plane, но сохраняющий полную совместимость с upstream-спецификациями Kubernetes API.
### 3.1 Архитектура манифестов и структура ресурсов
Мной была разработана декларативная структура ресурсов, развернутая в изолированном пространстве имен `magistr` (манифест `namespace.yaml`):
* **Frontend (`frontend.yaml`)**:
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`).
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
* **Backend (`backend.yaml`)**:
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
* Для сборки образов используется multi-stage сборка Maven (JDK 17) и запуск под управлением `eclipse-temurin:17-jre-alpine`.
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
* **Ingress (`ingress.yaml`)**:
* В роли Ingress-контроллера выступает стандартный для K3s **Traefik**.
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` для тестового окружения, `n8n.zuev.company` и т.д.). Маршрутизация по путям разделяет трафик: запросы к API (`/api`) проксируются на бэкенд-сервис, а запросы к статическим ресурсам и страницам (`/`) — на фронтенд-сервис.
### 3.2 Реализация динамической мультитенантности через K8s API
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
1. **Хранение конфигурации**:
Список подключений к базам данных университетов хранится в JSON-формате (`tenants.json`) внутри Kubernetes `ConfigMap` с именем `tenants-config`.
2. **Проблема Read-Only монтирования**:
По умолчанию Kubernetes монтирует ConfigMap как файловую систему в режиме Read-Only. Это исключало возможность для Java-приложения перезаписывать `tenants.json` при запросах от администратора на добавление нового тенанта.
3. **Решение с использованием `initContainer` и `emptyDir`**:
Я спроектировал схему с временным перезаписываемым томом (`emptyDir`):
* В спецификацию пода backend добавлен `initContainer` на базе образа `busybox`.
* При старте пода `initContainer` монтирует ConfigMap `tenants-config` и копирует файл `tenants.json` во временный раздел `emptyDir` (путь `/config`).
* Основной контейнер `backend` монтирует этот же `emptyDir` в режиме Read-Write.
4. **K8s RBAC и динамическое сохранение**:
Чтобы предоставить бэкенду возможность сохранять изменения конфигурации обратно в кластер, мной были написаны манифесты авторизации (`rbac.yaml`):
* Создан `ServiceAccount` `backend-sa` и назначен поду бэкенда.
* Описана Kubernetes `Role` `backend-configmap-role`, дающая права только на операции `get` и `patch` для ресурса `configmaps` с конкретным именем `tenants-config` (принцип наименьших привилегий).
* Создан `RoleBinding` `backend-configmap-binding`, связывающий сервис-аккаунт с этой ролью.
В коде бэкенда класс `ConfigMapUpdater` через Kubernetes Java Client отправляет PATCH-запрос к API-серверу при добавлении нового тенанта, обновляя ConfigMap в самом кластере.
5. **Репликация и синхронизация подов**:
Для обеспечения консистентности данных в условиях работы нескольких реплик бэкенда:
* Класс `TenantConfigWatcher` запускает фоновую задачу, которая каждые 30 секунд сверяет MD5-хеш локального файла `tenants.json` с версией из ConfigMap.
* При обнаружении расхождения файл перезаписывается, DataSource-инстансы пересоздаются в памяти приложения на лету, исключая рассинхронизацию подов.
* *Примечание*: В ходе тестирования для минимизации задержек и исключения эффекта "мигания" данных на время отладки количество реплик backend было ограничено до 1, с последующим переходом на полноценный StatefulSet/Shared Storage при росте количества нод.
### 3.3 Повышение отказоустойчивости бэкенда (Resiliency)
При запуске бэкенда в облаке возникала проблема: если база данных одного из университетов становилась недоступной (например, из-за сетевого сбоя или регламентных работ), пул подключений HikariCP падал с ошибкой при инициализации бинов, отправляя под бэкенда в циклическую перезагрузку (`CrashLoopBackOff`), что делало недоступной систему для *всех* остальных вузов.
Для предотвращения этого каскадного сбоя я внедрил комплекс защитных механизмов:
* **H2 Fallback**: В сборку (`pom.xml`) добавлена СУБД H2 in-memory. Если при старте ConfigMap пуст, Spring Boot инициализирует пустой JPA-контекст на базе H2, что позволяет приложению успешно пройти фазу запуска.
* **HikariCP Resiliency**: В конфигурации источников данных задано свойство `setInitializationFailTimeout(-1)`. Это заставляет HikariCP игнорировать отсутствие связи с целевой БД при старте приложения, продолжая попытки подключения в фоновом режиме.
* **Явное указание диалекта Hibernate**: Принудительно задан `org.hibernate.dialect.PostgreSQLDialect` в `TenantDataSourceConfig.java`. Это избавило Hibernate от необходимости выполнять тестовый запрос к БД для автоматического определения диалекта на ранних этапах инициализации контекста.
* **Автоматическое создание таблиц (`DataInitializer`)**: Для новых БД вузов полностью автоматизирован процесс наката структуры. При обнаружении новой базы Java-код проверяет наличие таблицы `users` и, в случае ее отсутствия, самостоятельно применяет SQL-скрипт инициализации (`init.sql`), включая создание дефолтных справочников и учетной записи суперадминистратора.
---
## РАЗДЕЛ 4. АВТОМАТИЗАЦИЯ КОНФИГУРАЦИИ СУБД С ПОМОЩЬЮ ANSIBLE
Для развертывания и администрирования баз данных университетов на удаленных виртуальных машинах Proxmox мной был спроектирован и реализован комплексный плейбук **Ansible** с модульной структурой ролей.
### 4.1 Структура и функционал Ansible-ролей
Вся конфигурация целевого сервера разбита на 6 последовательных этапов (плейбук `site.yml`):
1. **`common` (Базовая подготовка и Hardening ОС)**:
* Создание системного пользователя `deploy` с беспарольным доступом к `sudo` (через валидацию файла `/etc/sudoers.d/deploy` утилитой `visudo`).
* Установка системных утилит (`curl`, `git`, `htop`, `chrony` для синхронизации времени по NTP).
* **SSH Hardening**: Изменение стандартного SSH-порта на `2222`, отключение авторизации по паролям, запрет входа для пользователя `root`, ограничение таймаута сессий.
* Адаптация под различные дистрибутивы ОС: написаны отдельные сценарии для семейств Debian/Ubuntu/Astra Linux (пакетный менеджер `apt`), RedHat/РЕД ОС (`dnf`) и ALT Linux (кастомная обертка для `apt-get`).
2. **`docker` (Среда контейнеризации)**:
* Автоматическое добавление официальных GPG-ключей и репозиториев Docker CE.
* Установка Docker Engine, CLI и Compose плагина.
* Включение системной службы `docker` и добавление пользователя `deploy` в группу `docker` для беспарольного запуска контейнеров.
3. **`firewall` (Сетевая безопасность)**:
* Настройка межсетевого экрана UFW (для Debian-based систем) или firewalld (для RedHat и ALT Linux).
* Полная блокировка входящего трафика по умолчанию.
* Разрешение входящих пакетов только на порт SSH (`2222`) и порт PostgreSQL (`5432`). Реализована поддержка ограничения доступа к порту `5432` только для IP-адресов нод Kubernetes-кластера (`firewall_postgresql_allowed_ips`).
4. **`postgresql` (Контейнеризированная СУБД)**:
* Генерация файлов конфигурации `docker-compose.yml` и `.env` на основе шаблонов Jinja2.
* Развертывание PostgreSQL 16 на базе легковесного Alpine-образа.
* Физическое монтирование каталога данных БД с хост-системы (`/opt/magistr/postgres/data`), предотвращающее потерю данных при пересоздании контейнера.
* Ограничение системных ресурсов контейнера (limits: memory: 512M) и тюнинг параметров PostgreSQL (`shared_buffers=256MB`, оптимизация пула соединений, логирование медленных запросов `log_min_duration_statement=1000`).
5. **`backup` (Резервное копирование на уровне БД)**:
* Установка на хост автоматического bash-скрипта бэкапа.
* Настройка задачи `cron` (запуск ежедневно в 03:00).
* Скрипт осуществляет горячий дамп базы через `docker exec pg_dump`, архивирует его с помощью `gzip` и сохраняет в локальный каталог `/opt/magistr/backups/`.
* Реализован алгоритм автоматической ротации: удаление архивных файлов старше заданного количества дней (по умолчанию 14 дней).
6. **`monitoring` (Сбор телеметрии хоста и БД)**:
* Развертывание контейнера OpenTelemetry Collector на целевом сервере БД.
* Конфигурирование коллектора на сбор метрик производительности PostgreSQL и ОС (процессор, память, диски) и их отправку на единый сервер SigNoz по OTLP/HTTP.
### 4.2 Управление секретами: Ansible Vault
Для исключения попадания паролей администраторов БД в систему контроля версий (Git) все чувствительные переменные (например, `vault_postgresql_password`) вынесены в файл `group_vars/databases/vault.yml` и зашифрованы алгоритмом AES-256 с помощью **Ansible Vault**.
Для бесшовной интеграции в CI/CD пароль расшифрования считывается из локального файла `.vault_pass` на управляющей машине, доступ к которому ограничен правами `chmod 600`.
---
## РАЗДЕЛ 5. НЕПРЕРЫВНАЯ ИНТЕГРАЦИЯ И ДОСТАВКА (CI/CD)
В рамках оптимизации внутренних процессов разработки и деплоя мной была развернута и настроена инфраструктура непрерывной интеграции на базе **Gitea** и **Gitea Actions**.
### 5.1 Автоматизация сборки (CI)
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
1. **Checkout**: Загрузка актуального исходного кода проекта на ранер.
2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев.
3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`).
4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр.
### 5.2 Доставка в кластер (CD)
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет типа `docker-registry` в пространстве имен `magistr`:
```bash
kubectl create secret docker-registry gitea-registry \
--docker-server=gitea.zuev.company \
--docker-username=Zuev \
--docker-password=${ZUEV_TOKEN} \
--namespace=magistr
```
Этот секрет ассоциирован со спецификациями деплоев в `backend.yaml` и `frontend.yaml` через директиву `imagePullSecrets`. Обновление приложений в кластере после завершения сборки образов выполняется путем контролируемого перезапуска подов:
```bash
kubectl rollout restart deployment backend -n magistr
kubectl rollout restart deployment frontend -n magistr
```
---
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
Для маршрутизации внешнего трафика и защиты соединений на входе в инфраструктуру развернут веб-сервер **Caddy**.
### 6.1 Преимущества Caddy и управление SSL/TLS
В отличие от классического Nginx, Caddy был выбран благодаря:
* Встроенной интеграции с удостоверяющими центрами Let's Encrypt и ZeroSSL. При добавлении нового поддомена вуза (например, `swsu.zuev.company`) Caddy автоматически запрашивает, валидирует через DNS/HTTP-вызовы и устанавливает TLS-сертификат, а также следит за его продлением.
* Лаконичному синтаксису конфигурации (`Caddyfile`).
* Полноценной поддержке протокола HTTP/3 «из коробки».
### 6.2 Конфигурация балансировки
Внешний Caddy-сервер принимает запросы ко всем поддоменам `*.zuev.company` и перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика нагрузки (L4/L7 Load Balancer):
```caddyfile
*.zuev.company {
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
}
}
```
Сетевые запросы распределяются по нодам K3s по алгоритму Round-Robin. В случае недоступности одной из нод, Caddy временно исключает ее из пула, обеспечивая отказоустойчивость инфраструктуры на сетевом уровне.
---
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** и стандартов **OpenTelemetry (OTel)**.
### 7.1 Сбор телеметрии Backend
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
* **Трейсы (Traces)**: Сквозное прохождение HTTP-запросов через контроллеры, сервисы и JDBC-драйвер к базам данных.
* **Метрики (Metrics)**: Метрики JVM (утилизация Heap, активность сборщика мусора GC, состояние потоков), метрики HTTP-запросов (интенсивность, латентность, коды ответов).
* **Логи (Logs)**: Системные логи Logback экспортируются по протоколу OTLP напрямую в SigNoz.
### 7.2 Идентификация тенантов в мониторинге
Для глубокого анализа производительности СУБД конкретных университетов критически важно разделять телеметрию по тенантам. Мной была реализована сквозная маркировка:
* В бэкенде через Java-интерцептор `TenantInterceptor` при каждом запросе идентификатор тенанта помещается в контекст логирования SLF4J MDC (`MDC.put("tenant.id", tenant)`) и одновременно записывается в атрибуты текущего OpenTelemetry спана (`Span.current().setAttribute("tenant.id", tenant)`).
* Благодаря переменной окружения `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=tenant.id`, OTel-агент автоматически парсит MDC и прикрепляет его к логам, отправляемым в SigNoz. Это позволяет администраторам фильтровать ошибки и строить графики нагрузки в разрезе каждого университета.
### 7.3 Мониторинг баз данных на хостах
Для сбора детальной статистики с изолированных серверов баз данных:
1. На хостах БД силами Ansible разворачивается OpenTelemetry Collector.
2. В конфигурации коллектора (`otel-collector-config.yml.j2`) описывается ресивер `postgresql`, который подключается к локальной БД и считывает системные метрики (размер таблиц, cache hit ratio, количество транзакций, активные сессии, блокировки).
3. К собираемым метрикам жестко прикрепляются ресурсные атрибуты хоста: `service.name=magistr-db-<tenant_domain>` и `university.name=<University Name>`.
4. Метрики экспортируются в центральный коллектор SigNoz. В результате в интерфейсе SigNoz доступны кастомные дашборды JVM, PostgreSQL и HTTP, позволяющие оперативно локализовать проблемы с производительностью баз данных конкретных вузов.
---
## ЗАКЛЮЧЕНИЕ
Разработанная и внедренная мной DevOps-архитектура для проекта «Магистр» успешно решила задачи изоляции данных, отказоустойчивости и автоматизации администрирования.
### Ключевые результаты проделанной работы:
1. **Изоляция данных**: Реализована физическая изоляция БД университетов на выделенных серверах (VM в Proxmox VE), соответствующая требованиям безопасности и законодательства.
2. **Динамическое масштабирование**: Внедрен механизм динамического добавления тенантов без простоя системы (zero-downtime) за счет связки Spring Boot, K8s RBAC и ConfigMapWatcher.
3. **Отказоустойчивость**: Минимизированы риски каскадных сбоев бэкенда за счет применения in-memory fallback баз данных и оптимизации параметров подключения HikariCP.
4. **Автоматизация**: Создан универсальный Ansible-плейбук для быстрого ввода в эксплуатацию новых серверов БД (время развертывания «под ключ» сокращено до нескольких минут).
5. **Наблюдаемость**: Реализован сквозной мониторинг логов, метрик и трассировок с детализацией по тенантам на базе стека OpenTelemetry + SigNoz.
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».

View File

@@ -1,248 +0,0 @@
# Отчёт об изменениях по задачам научного руководителя
Дата: 2026-05-19
## Краткий итог
Выполнен первый цельный релиз архитектурной основы:
- добавлены роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`;
- добавлена backend-проверка bearer-токена и ролей через `AuthorizationInterceptor` и `@RequireRoles`;
- добавлен жизненный цикл справочников: `ACTIVE` / `ARCHIVED`;
- физическое удаление ключевых сущностей заменено архивированием там, где это влияет на историю;
- добавлена история переводов преподавателей между кафедрами;
- добавлены точечные изменения расписания: перенос, отмена, замена;
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
- обновлена документация проекта.
## Backend
### Роли и авторизация
Поддерживаются роли:
- `ADMIN`;
- `EDUCATION_OFFICE`;
- `DEPARTMENT`;
- `SCHEDULE_VIEWER`;
- `TEACHER`;
- `STUDENT`.
Добавлены:
- `AuthSessionService` — in-memory UUID-сессии;
- `AuthContext` — текущий пользователь в `ThreadLocal`;
- `AuthorizationInterceptor` — проверка `Authorization: Bearer ...`;
- `@RequireRoles` — ограничение доступа на уровне контроллеров и методов;
- `GET /api/auth/me` — текущий пользователь по токену.
Redirect после входа:
- `ADMIN` -> `/admin/`;
- `EDUCATION_OFFICE` -> `/admin/#schedule-view`;
- `DEPARTMENT` -> `/admin/#department-workspace`;
- `SCHEDULE_VIEWER` -> `/admin/#schedule-view`;
- `TEACHER` -> `/teacher/`;
- `STUDENT` -> `/student/`.
### Миграция БД
Отдельная миграция `V2__roles_lifecycle_temporal_schedule.sql` удалена по текущему решению проекта.
Все изменения схемы внесены в:
```text
backend/src/main/resources/db/migration/V1__init.sql
```
`V1__init.sql` теперь сразу создаёт:
- lifecycle-поля к справочникам и пользователям;
- `teacher_department_assignments`;
- `subject_comments`;
- version-поля к `schedule_rules`;
- lock-поля к `schedule_rule_slots`;
- `schedule_overrides`;
- стартовых пользователей `учебный_отдел`, афедраб` и `просмотр_расписаний`.
### Архивирование вместо удаления
Архивирование добавлено для пользователей, аудиторий, оборудования, кафедр, специальностей, групп, подгрупп, дисциплин и правил расписания.
Архивные аудитории, преподаватели, группы и дисциплины запрещены для новых назначений. Историческое расписание при этом не теряет ссылки.
### История кафедр преподавателя
Добавлены endpoint:
- `GET /api/users/{id}/department-history`;
- `POST /api/users/{id}/department-transfer`;
- `GET /api/users/teachers/by-department/{departmentId}?date=YYYY-MM-DD`.
При переводе преподавателя старая запись закрывается, новая открывается с `valid_from`, а `users.department_id` обновляется как текущая кафедра.
### Расписание и загруженность
Добавлены:
- `GET /api/schedule/search`;
- `GET /api/workload/teachers`;
- `GET /api/workload/classrooms`;
- `GET /api/workload/departments`;
- `GET /api/workload/time-slots`;
- `GET /api/workload/free-classrooms`;
- `GET/POST/PUT/DELETE /api/edu-office/schedule/overrides`.
`GET /api/schedule` теперь использует общий слой поиска, поэтому учитывает точечные изменения расписания.
Роль `SCHEDULE_VIEWER` имеет доступ только к read-only просмотру расписаний и справочникам-фильтрам.
### Кабинет кафедры API
Добавлены:
- `GET /api/department/subjects`;
- `POST /api/department/subjects/import`;
- `GET /api/department/subjects/{subjectId}/comments`;
- `POST /api/department/subjects/{subjectId}/comments`;
- `GET /api/department/teachers`;
- `GET /api/department/schedule`.
Для роли `DEPARTMENT` кафедра берётся из текущего пользователя. Для администратора можно выбрать кафедру в UI.
## Frontend
### Единая панель по ролям
Отдельные интерфейсы `/department/` и `/edu-office/` заменены на redirect в общую админ-панель:
- `/department/` -> `/admin/#department-workspace`;
- `/edu-office/` -> `/admin/#schedule-view`.
В `frontend/admin/js/main.js` добавлена фильтрация вкладок:
- `ADMIN` видит все вкладки;
- `EDUCATION_OFFICE` видит просмотр расписаний, конструктор расписания, календарный график, загруженность, аудитории и оборудование;
- `DEPARTMENT` видит кабинет кафедры и просмотр расписаний;
- `SCHEDULE_VIEWER` видит только просмотр расписаний.
Backend не опирается только на скрытие вкладок. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, а `@RequireRoles` ограничивает операции на контроллерах и методах. Для кафедры дополнительно закрыты обходные пути:
- общий `/api/subjects` оставлен для записи только администратору, кафедра загружает дисциплины через scoped `/api/department/subjects/import`;
- `/api/teacher-subjects` для роли `DEPARTMENT` проверяет, что и преподаватель, и дисциплина относятся к кафедре текущего пользователя;
- `SCHEDULE_VIEWER` имеет только read-only доступ к расписаниям и справочникам-фильтрам.
После браузерной проверки исправлено визуальное скрытие недоступных вкладок: `layout.css` теперь принудительно скрывает `.nav-item[hidden]` и скрытые пункты меню настроек, поэтому роли больше не видят чужие вкладки в sidebar.
### Просмотр расписаний
Добавлена вкладка:
```text
frontend/admin/views/schedule-view.html
frontend/admin/js/views/schedule-view.js
```
Возможности:
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
### Загруженность
Вкладка `auditorium-workload` оставлена техническим tab id, но в UI называется `Загруженность`.
Возможности:
- выбор типа загруженности: аудитории, преподаватели или кафедры;
- сводная матрица по выбранной дате: строки — выбранные сущности, столбцы — пары;
- кафедральная матрица группирует занятия по кафедре преподавателя;
- для аудиторий сохранены фильтры корпуса, вместимости и оборудования;
- выбор конкретной аудитории, преподавателя или кафедры заменяет обзор двухнедельной таблицей по дням недели и времени;
- чётная и нечётная недели показываются в одной ячейке: если состояние одинаковое, ячейка цельная, если отличается — делится вертикально.
### Кабинет кафедры
Добавлена вкладка:
```text
frontend/admin/views/department-workspace.html
frontend/admin/js/views/department-workspace.js
```
Возможности:
- просмотр дисциплин кафедры;
- загрузка дисциплин из списка `код; название`;
- просмотр и добавление комментариев к дисциплинам;
- просмотр преподавателей кафедры;
- просмотр нагрузки преподавателей кафедры за период.
### Админка
Обновлено:
- создание пользователей теперь поддерживает роли `DEPARTMENT`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`;
- удаление пользователей заменено на архивирование;
- вкладка аудиторий показывает архивные записи и умеет восстанавливать аудиторию;
- удаление аудитории заменено на вывод из эксплуатации;
- настройки временных слотов доступны `ADMIN` и `EDUCATION_OFFICE`.
## Документация
Обновлены:
- `AGENTS.md`;
- `docs/README.md`;
- `docs/API.md`;
- `docs/DATABASE.md`;
- `docs/BUSINESS_LOGIC.md`;
- `docs/FRONTEND.md`;
- `docs/ARCHITECTURE.md`.
## Проверки
Статически проверены новые и изменённые JS-файлы:
```bash
node --check frontend/admin/js/main.js
node --check frontend/admin/js/views/schedule-view.js
node --check frontend/admin/js/views/auditorium-workload.js
node --check frontend/admin/js/views/department-workspace.js
node --check frontend/admin/settings/js/main.js
node --check frontend/admin/js/api.js
node --check frontend/admin/js/views/users.js
node --check frontend/admin/js/views/classrooms.js
git diff --check
```
Backend-компиляция проверяется через Docker Maven:
```bash
docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile
```
Результат: компиляция прошла успешно.
Единая `V1__init.sql` проверена на пустой PostgreSQL 16 внутри Docker: SQL применился успешно.
Локальный `mvn` в окружении отсутствует, поэтому используется Docker.
## Что осталось следующим этапом
Остались задачи, которые требуют отдельного цикла проработки:
- UI для просмотра истории переводов преподавателя в админке;
- UI для списка и редактирования уже созданных `schedule_overrides`;
- более строгая проверка конфликтов перед сохранением переносов;
- полноценный импорт XLSX/CSV для кафедры вместо текстовой загрузки;
- браузерный smoke-test и проверка ролевых вкладок после запуска `localhost:80`.

View File

@@ -1,288 +0,0 @@
# Анализ структуры вкладок и предложения по UX/UI оптимизации системы Magistr
В данном документе представлен детальный анализ текущей структуры интерфейса административной панели (SPA) и настроек системы Magistr. Выявлены пересекающиеся области функционала, сформулированы предложения по их объединению, переносу в настройки или удалению, а также предложены новые функциональные модули и технические решения для оптимизации UX/UI и производительности.
---
## 1. Анализ текущей структуры и доступности вкладок
На данный момент в основном SPA-интерфейсе администратора (`/admin/`) зарегистрировано **13 вкладок**, а в дополнительном SPA настроек (`/admin/settings/`) — **2 вкладки**.
В зависимости от роли пользователя, сайдбар фильтруется следующим образом:
| Вкладка (ID) | Описание | Доступность по ролям |
| :--- | :--- | :--- |
| `users` | CRUD пользователей и архивирование | `ADMIN` |
| `groups` | CRUD групп, подгрупп и назначений календарей | `ADMIN` |
| `edu-forms` | Справочник форм обучения | `ADMIN` |
| `profiles` | Управление профилями обучения специальностей | `ADMIN` |
| `equipments` | Каталог оборудования | `ADMIN`, `EDUCATION_OFFICE` |
| `classrooms` | Список аудиторий с привязкой оборудования | `ADMIN`, `EDUCATION_OFFICE` |
| `subjects` | CRUD всех дисциплин и привязка преподавателей | `ADMIN` |
| `department-workspace` | Рабочая область кафедры (дисциплины, импорт, нагрузка) | `ADMIN`, `DEPARTMENT` |
| `schedule-view` | Просмотр расписаний (read-only) | `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER` |
| `schedule` | Конструктор правил динамического расписания | `ADMIN`, `EDUCATION_OFFICE` |
| `academic-calendar` | Календарные графики и Excel-редактор сетки | `ADMIN`, `EDUCATION_OFFICE` |
| `auditorium-workload` | Матрицы и таблицы загруженности | `ADMIN`, `EDUCATION_OFFICE` |
| `database` | Управление тенантами (БД клиентов) | `ADMIN` |
| **Settings** (SPA) | Общие настройки, Управление временными слотами | `ADMIN`, `EDUCATION_OFFICE` (ссылка в подвале) |
---
## 2. Пересечения функционала и проблемные зоны
### 2.1. Избыточное дробление академической структуры
Для описания структуры обучения сейчас используются четыре отдельные вкладки:
1. `departments-data` (Создание кафедры/специальности)
2. `profiles` (Профили обучения специальностей)
3. `edu-forms` (Формы обучения)
4. `groups` (В блоке создания группы выбираются форма, кафедра, специальность и профиль)
**В чём проблема:**
* **Разрыв контекста:** Чтобы завести новую специальность с профилями, администратору сначала нужно пойти во вкладку `departments-data`, создать специальность, затем перейти во вкладку `profiles`, выбрать в селекте эту специальность и добавить профили.
* **Перегрузка меню:** 13 вкладок в сайдбаре для роли `ADMIN` — это слишком много. Пользователю трудно ориентироваться визуально.
### 2.2. Дублирование и раздвоение управления дисциплинами
Управление дисциплинами разнесено между вкладками `subjects` (для администратора) и `department-workspace` (для кафедры).
**В чём проблема:**
* Администратор во вкладке `subjects` видит плоскую таблицу всех дисциплин всех кафедр, но не имеет удобных инструментов фильтрации по кафедре.
* Форма создания новой дисциплины во вкладке `subjects` требует ввода **числового ID кафедры вручную** (поле `new-subject-department` типа `number`). Это критическая UX-ошибка: администратор не должен помнить числовые ID БД наизусть.
* Привязка преподавателей к дисциплинам (`teacher_subjects`) находится на вкладке `subjects`. При этом сама нагрузка преподавателей и список преподавателей кафедры отображаются на вкладке `department-workspace`. Логичнее было бы видеть, какие дисциплины ведёт преподаватель, непосредственно при работе с его нагрузкой или в едином справочнике преподавателей/кафедры.
### 2.3. Низкая частота использования справочника оборудования (`equipments`)
Оборудование существует только как подчиненная сущность для аудиторий (чтобы указать, что в аудитории 101 есть 20 ПК и 1 проектор).
**В чём проблема:**
* Выделение целой вкладки под простой список («Проектор», «ПК») не оправдано. Список оборудования меняется раз в несколько лет.
* При этом привязка оборудования к аудиториям происходит на вкладке `classrooms`.
### 2.4. Перегруженность вкладки групп (`groups`)
На вкладке групп на одном экране выводятся сразу три тяжелые формы и три таблицы:
1. Создание/редактирование групп + таблица групп.
2. Создание подгрупп для лабораторных + таблица подгрупп.
3. Назначение календарного графика группе + таблица назначений.
**В чём проблема:**
* Экран перегружен элементами управления.
* Для работы с подгруппами или назначениями графиков конкретной группы пользователю приходится выбирать эту группу из длинного выпадающего списка в отдельной форме, вместо того чтобы выполнять действия в контексте строки этой группы в таблице.
### 2.5. Нахождение системной вкладки «База данных» в общем меню расписания
Вкладка `database` отвечает за управление тенантами (добавление новых вузов-клиентов, создание для них БД и запуск Flyway миграций).
**В чём проблема:**
* Это технический функционал уровня Super-Admin / DevOps. Он не имеет отношения к ежедневному планированию расписания. Нахождение его в одном ряду с расписанием и группами нарушает логику интерфейса.
---
## 3. Рекомендации по оптимизации структуры (Что убрать, объединить и перенести)
```mermaid
graph TD
subgraph Было (13 вкладок в меню ADMIN)
direction TB
T1[database]
T2[edu-forms]
T3[departments-data]
T4[profiles]
T5[equipments]
T6[classrooms]
T7[groups]
T8[users]
T9[subjects]
T10[department-workspace]
T11[schedule]
T12[schedule-view]
T13[academic-calendar]
T14[auditorium-workload]
end
subgraph Стало (Сгруппированное меню + настройки)
direction TB
subgraph Основное меню SPA (Группы вкладок)
direction TB
DASH[1. Дашборд / Главная]
subgraph Модуль: Расписание
SCH_ED[2. Конструктор расписания]
SCH_VI[3. Просмотр расписаний]
CAL[4. Календарный график]
WORK[5. Загруженность]
end
subgraph Модуль: Структура
UNIV[6. Структура вуза<br/>Кафедры, Специальности, Профили]
GRP[7. Группы и подгруппы]
SUBJ[8. Дисциплины]
end
subgraph Модуль: Ресурсы
CLASS[9. Аудиторный фонд<br/>Аудитории + Оборудование]
end
USR[10. Пользователи]
end
subgraph SPA Настройки (Настройки системы)
direction TB
SET_GEN[Общие настройки]
SET_TIME[Временные слоты сетка]
SET_FORM[Формы обучения из edu-forms]
SET_DB[Базы данных / Тенанты из database]
end
end
T1 -->|Перенести| SET_DB
T2 -->|Перенести| SET_FORM
T3 -->|Объединить| UNIV
T4 -->|Объединить| UNIV
T5 -->|Объединить| CLASS
T6 -->|Объединить| CLASS
T7 -->|Оптимизировать| GRP
T8 -->|Оставить| USR
T9 -->|Оптимизировать| SUBJ
T10 -->|Сохранить для роли Кафедра| SUBJ
T11 -->|Оставить| SCH_ED
T12 -->|Оставить| SCH_VI
T13 -->|Оставить| CAL
T14 -->|Оставить| WORK
```
### 3.1. Перенос в настройки (`/admin/settings/`)
1. **База данных (`database`)**: Перенести в SPA настроек в раздел «Управление тенантами». Доступ должен быть только у роли `ADMIN`.
2. **Формы обучения (`edu-forms`)**: Перенести в настройки. Данный справочник заполняется один раз и не требует оперативного доступа из главного меню.
### 3.2. Объединение вкладок
1. **Объединить Кафедры, Специальности и Профили** в одну вкладку **«Структура вуза»** (`university-structure`):
* Интерфейс делится на две вкладки внутри страницы: **«Кафедры»** и **«Специальности»**.
* Управление профилями должно происходить прямо внутри списка специальностей. Например, в строке специальности в таблице добавляется кнопка «Профили», открывающая список профилей этой специальности в раскрывающейся строке (accordion) или модальном окне.
2. **Объединить Аудитории (`classrooms`) и Оборудование (`equipments`)** в одну вкладку **«Аудиторный фонд»**:
* Основной экран показывает список аудиторий.
* Рядом располагается кнопка «Управление каталогом оборудования», которая открывает модальное окно с простым списком (добавить/удалить тип оборудования). Это избавит от необходимости держать под это отдельную вкладку в сайдбаре.
### 3.3. Реорганизация вкладки групп (`groups`)
* **Оставить на вкладке только список групп** с возможностью фильтрации по курсу, кафедре и форме обучения.
* **Вынести подгруппы и назначения календарей из глобальных форм:**
* В строке таблицы для каждой группы добавить действия:
* `[Подгруппы]` — открывает компактное модальное окно для быстрого создания/редактирования подгрупп (лабораторных) именно для этой группы.
* `[Календарь]` — открывает модальное окно назначения календарного учебного графика на учебные годы для этой группы.
* Это уберет лишние тяжелые формы с главной страницы и исключит необходимость выбора группы из селектов вручную.
### 3.4. Оптимизация Дисциплин (`subjects`) и Кабинета кафедры
* **Исправить форму добавления дисциплины:** Заменить ручной числовой ввод ID кафедры (`new-subject-department`) на стандартный выпадающий список (выбор из загруженных кафедр).
* **Связать привязку преподавателей с дисциплинами:** Вместо двух разрозненных таблиц на вкладке `subjects` («Все дисциплины» и «Привязки преподавателей»), объединить их:
* В строке дисциплины выводить список привязанных преподавателей (в виде чипов/badge).
* По клику на кнопку «Преподаватели» в строке дисциплины открывать модальное окно привязки/отвязки преподавателей для этой конкретной дисциплины.
---
## 4. Что необходимо добавить (Новый функционал)
### 4.1. Главная страница / Дашборд (`dashboard`)
При входе в систему администратор и сотрудники учебного отдела должны видеть не сырые таблицы пользователей или расписания, а высокоуровневую аналитическую панель:
* **Информационные карточки (Метрики):**
* Всего активных групп / студентов.
* Количество задействованных преподавателей.
* Процент занятости аудиторного фонда на текущий день.
* Количество запланированных пар на сегодня.
* **Мониторинг расписания в реальном времени:**
* Список идущих прямо сейчас пар (текущий временной слот).
* Ближайшие свободные аудитории.
* **Быстрый переход:** Кнопки быстрого создания правила расписания, добавления переноса/замены.
### 4.2. Центр предупреждений и конфликтов (Red Zone)
В соответствии с планируемой бизнес-логикой (раздел 5 `docs/BUSINESS_LOGIC.md`), система должна автоматически валидировать расписание и предупреждать о коллизиях.
Необходима отдельная вкладка или виджет на дашборде **«Конфликты расписания»**:
* **Критические ошибки (Блокирующие генерацию):**
* *Накладки преподавателей:* Преподаватель назначен в один и тот же временной слот в разные аудитории / к разным группам (кроме потоковых лекций).
* *Накладки аудиторий:* Две разные пары стоят в одной аудитории в один слот.
* **Предупреждения (Мягкие проверки):**
* *Превышение вместимости:* Численность группы (или сумма подгрупп при совмещенных занятиях) превышает физическую вместимость аудитории.
* *Недостаток оборудования:* Дисциплина (например, лабораторная по химии) требует определенного оборудования, которого нет в назначенной аудитории.
* *Превышение нагрузки:* Преподаватель превысил недельный лимит часов.
---
## 5. Концепция новой структуры меню (Сайдбара)
Для уменьшения визуального шума сайдбар предлагается сделать **двухуровневым** (сворачиваемые группы) или четко разделить визуальными разделителями:
```
[ Логотип Magistr ]
---------------------------
👤 Кабинет кафедры <-- (Показывается только для роли DEPARTMENT)
📈 Дашборд <-- (Новый главный экран для ADMIN / EDU_OFFICE)
📁 РАСПИСАНИЕ
🗓 Конструктор правил
🗓 Точечные изменения <-- (Выделенный функционал оверрайдов/переносов)
📅 Календарный график
📊 Загруженность
🔍 Просмотр расписаний
🗂 СУБЪЕКТЫ И РЕСУРСЫ
🏫 Аудиторный фонд <-- (Объединенные Аудитории + Оборудование)
🎓 Группы обучения <-- (Группы + модалки подгрупп/календарей)
🏛 Структура вуза <-- (Кафедры + Специальности + Профили)
📚 Дисциплины <-- (Дисциплины + модалки преподавателей)
⚙️ СИСТЕМА
👥 Пользователи
⚙️ Настройки <-- (Переход в настройки SPA: слоты, БД, формы обучения)
---------------------------
[ Профиль / Выход ]
```
---
## 6. Идеи по оптимизации фронтенда и взаимодействия с API
### 6.1. Кэширование справочников на клиенте
На данный момент при переключении почти каждой вкладки заново запрашиваются списки кафедр, специальностей, учебных годов и т.д.
* **Решение:** Реализовать простой кэш-менеджер во фронтенд-клиенте (`api.js` или отдельный `cache.js`):
```javascript
const cache = new Map();
export async function getCachedData(key, fetchPromise) {
if (!cache.has(key)) {
cache.set(key, await fetchPromise);
}
return cache.get(key);
}
export function clearCache(key) {
if (key) cache.delete(key);
else cache.clear();
}
```
Это позволит избежать десятков дублирующих запросов при переключении между вкладками `groups`, `subjects`, `schedule`.
### 6.2. Ленивая инициализация вкладок (Lazy Loading)
Сейчас в `main.js` импортируются все JS-модули вкладок одновременно при загрузке страницы:
```javascript
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
// ...еще 10 импортов
```
* **Решение:** Перейти на динамический импорт модулей инициализации при переходе на конкретную вкладку:
```javascript
async function switchTab(tab) {
// ...
const viewModule = await import(`./views/${tab}.js`);
viewModule.init();
// ...
}
```
Это уменьшит размер начального JS-бандла и ускорит первую загрузку страницы.
### 6.3. Единый реестр кастомных выпадающих списков
В проекте реализованы кастомные селекты (выпадающие списки с поддержкой поиска и чекбоксов). При динамической подгрузке HTML-шаблонов часто требуется их повторная инициализация.
* **Решение:** Создать глобальный шину событий (Event Bus) или MutationObserver, который автоматически подхватывает элементы с классами `.custom-select` или атрибутами `data-select` и инициализирует их без вызова ручного кода `initAllCustomDropdowns()` в каждом отдельном скрипте представления.
---
## Резюме
Предложенные изменения позволят:
1. Сократить количество пунктов основного меню с **13** до **8-9** логически сгруппированных разделов.
2. Исключить ручной ввод ID (заменив на селекты) и разгрузить перегруженные интерфейсы (выносом подгрупп и назначений в контекстные модальные окна групп).
3. Повысить отзывчивость интерфейса за счет клиентского кэширования справочников и ленивой загрузки JS-модулей.
4. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -136,6 +146,35 @@ public class AcademicCalendarController {
}
}
@GetMapping("/{id}/subjects")
public ResponseEntity<?> getSubjects(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
}
@PutMapping("/{id}/subjects")
@Transactional
public ResponseEntity<?> saveSubjects(@PathVariable Long id,
@RequestBody List<AcademicCalendarSubjectDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
@@ -146,6 +185,9 @@ public class AcademicCalendarController {
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
@@ -209,6 +251,53 @@ public class AcademicCalendarController {
throw new IllegalArgumentException("Код активности обязателен");
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
}
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
Set<Long> subjectIds = new HashSet<>();
Set<String> uniqueRows = new HashSet<>();
for (AcademicCalendarSubjectDto row : rows) {
if (row == null || row.semesterNumber() == null || row.subjectId() == null) {
throw new IllegalArgumentException("Семестр и дисциплина обязательны");
}
if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) {
throw new IllegalArgumentException("Номер семестра выходит за пределы графика");
}
if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) {
throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру");
}
subjectIds.add(row.subjectId());
}
Map<Long, Subject> subjectsById = subjectRepository.findAllById(subjectIds).stream()
.collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new));
for (Long subjectId : subjectIds) {
Subject subject = subjectsById.get(subjectId);
if (subject == null) {
throw new IllegalArgumentException("Дисциплина не найдена");
}
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику");
}
}
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
calendarSubjectRepository.flush();
List<AcademicCalendarSubject> entities = rows.stream()
.map(row -> {
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
calendarSubject.setAcademicCalendar(calendar);
calendarSubject.setSemesterNumber(row.semesterNumber());
calendarSubject.setSubject(subjectsById.get(row.subjectId()));
return calendarSubject;
})
.toList();
calendarSubjectRepository.saveAll(entities);
}
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality();
@@ -247,4 +336,17 @@ public class AcademicCalendarController {
activityType.getAllowSchedule()
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -2,17 +2,22 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@@ -26,21 +31,29 @@ import java.util.Objects;
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService;
}
@@ -127,6 +140,7 @@ public class DepartmentWorkspaceController {
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
@@ -136,6 +150,90 @@ public class DepartmentWorkspaceController {
.toList();
}
@PostMapping("/teachers/{teacherId}/assignments")
@Transactional
public ResponseEntity<?> addTeacherToDepartment(@PathVariable Long teacherId,
@RequestBody(required = false) Map<String, Object> request) {
Long requestedDepartmentId = asLong(request == null ? null : request.get("departmentId"));
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = userRepository.findById(teacherId).orElse(null);
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
}
LocalDate today = LocalDate.now();
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
}
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(today);
assignment.setPrimaryAssignment(false);
assignment.setComment(trimToNull(request == null ? null : request.get("comment")));
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
return ResponseEntity.ok(toAssignmentMap(assignment));
}
@GetMapping("/teacher-requests")
public List<TeacherCreationRequestResponse> getTeacherRequests(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
if (effectiveDepartmentId == null) {
return teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
return teacherCreationRequestRepository.findByDepartment_IdOrderByCreatedAtDesc(effectiveDepartmentId).stream()
.map(this::toTeacherCreationRequestResponse)
.toList();
}
@PostMapping("/teacher-requests")
public ResponseEntity<?> createTeacherRequest(@RequestBody CreateTeacherCreationRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные заявки обязательны"));
}
Long departmentId = effectiveDepartmentId(request == null ? null : request.getDepartmentId());
if (departmentId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
}
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
String username = trimToNull(request.getUsername());
String fullName = trimToNull(request.getFullName());
String jobTitle = trimToNull(request.getJobTitle());
if (username == null || fullName == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин и ФИО преподавателя обязательны"));
}
if (username.length() > 50 || fullName.length() > 255 || (jobTitle != null && jobTitle.length() > 255)) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей заявки"));
}
if (userRepository.findByUsername(username).isPresent()
|| teacherCreationRequestRepository.existsByUsernameAndStatus(username, TeacherCreationRequestStatus.PENDING)) {
return ResponseEntity.badRequest().body(Map.of("message", "Логин уже занят или ожидает одобрения"));
}
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
teacherRequest.setDepartment(department);
teacherRequest.setUsername(username);
teacherRequest.setFullName(fullName);
teacherRequest.setJobTitle(jobTitle == null ? "Не указано" : jobTitle);
teacherRequest.setComment(trimToNull(request.getComment()));
teacherRequest.setRequestedBy(AuthContext.currentUserId());
return ResponseEntity.ok(toTeacherCreationRequestResponse(teacherCreationRequestRepository.save(teacherRequest)));
}
@GetMapping("/schedule")
public ResponseEntity<?> getSchedule(
@RequestParam(required = false) Long departmentId,
@@ -172,10 +270,64 @@ public class DepartmentWorkspaceController {
return response;
}
private TeacherCreationRequestResponse toTeacherCreationRequestResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private Map<String, Object> toAssignmentMap(TeacherDepartmentAssignment assignment) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", assignment.getId());
response.put("teacherId", assignment.getTeacher().getId());
response.put("teacherName", assignment.getTeacher().getFullName());
response.put("departmentId", assignment.getDepartment().getId());
response.put("departmentName", assignment.getDepartment().getDepartmentName());
response.put("validFrom", assignment.getValidFrom());
response.put("primaryAssignment", assignment.getPrimaryAssignment());
return response;
}
private Long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && !text.isBlank()) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
return user.departmentId();
return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
}
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
}

View File

@@ -1,15 +1,18 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.Subject;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
@@ -24,9 +27,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/groups")
@@ -41,6 +47,7 @@ public class GroupController {
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
@@ -51,6 +58,7 @@ public class GroupController {
SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
@@ -60,6 +68,7 @@ public class GroupController {
this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
@@ -303,8 +312,10 @@ public class GroupController {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
.map(this::toAssignmentDto)
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
return ResponseEntity.ok(assignments.stream()
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
.toList());
}
@@ -345,7 +356,13 @@ public class GroupController {
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment)));
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(calendar.getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
}
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@@ -360,7 +377,31 @@ public class GroupController {
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) {
private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
List<StudentGroupCalendarAssignment> assignments) {
Set<Long> calendarIds = assignments.stream()
.map(assignment -> assignment.getAcademicCalendar().getId())
.collect(Collectors.toSet());
if (calendarIds.isEmpty()) {
return Collections.emptyMap();
}
return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream()
.collect(Collectors.groupingBy(
calendarSubject -> calendarSubject.getAcademicCalendar().getId(),
Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList())
));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
return toAssignmentDto(
assignment,
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
);
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
List<AcademicCalendarSubjectDto> subjects) {
return new GroupCalendarAssignmentDto(
assignment.getId(),
assignment.getStudentGroup().getId(),
@@ -368,7 +409,21 @@ public class GroupController {
assignment.getAcademicYear().getId(),
assignment.getAcademicYear().getTitle(),
assignment.getAcademicCalendar().getId(),
assignment.getAcademicCalendar().getTitle()
assignment.getAcademicCalendar().getTitle(),
subjects
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*;
@RestController
@@ -19,6 +21,12 @@ import java.util.*;
public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
ScheduleParity.BOTH,
ScheduleParity.ODD,
ScheduleParity.EVEN
);
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
try {
ScheduleRule rule = new ScheduleRule();
applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
}
try {
ScheduleRule candidate = new ScheduleRule();
applyRule(candidate, request);
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
if (conflict != null) {
return conflictResponse(conflict);
}
applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache();
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
rule.getSlots().addAll(slots);
}
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"message", "Невозможно сохранить правило: слот занят",
"conflictRule", toDto(conflict.rule()),
"conflictFields", conflict.fields().stream().toList(),
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
));
}
private String conflictFieldLabel(String field) {
return switch (field) {
case "teacher" -> "Преподаватель";
case "classroom" -> "Аудитория";
case "group" -> "Группа";
default -> field;
};
}
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
return scheduleRuleRepository.findAllWithDetails().stream()
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
.filter(Objects::nonNull)
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
.orElse(null);
}
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
Collection<ScheduleRuleSlot> newSlots,
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
Collection<ScheduleRuleSlot> existingSlots) {
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
LinkedHashSet<String> fields = new LinkedHashSet<>();
for (ScheduleRuleSlot newSlot : newSlots) {
for (ScheduleRuleSlot existingSlot : existingSlots) {
if (sameTimeSlot(newSlot, existingSlot)
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
fields.addAll(conflictFields(newSlot, existingSlot));
}
}
}
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
}
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
LinkedHashSet<String> fields = new LinkedHashSet<>();
if (Objects.equals(teacherId(first), teacherId(second))) {
fields.add("teacher");
}
if (Objects.equals(classroomId(first), classroomId(second))) {
fields.add("classroom");
}
if (sameAudience(first, second)) {
fields.add("group");
}
return fields;
}
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
&& parityOverlaps(first.getParity(), second.getParity())
&& Objects.equals(timeSlotId(first), timeSlotId(second));
}
private Long teacherId(ScheduleRuleSlot slot) {
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
}
private Long classroomId(ScheduleRuleSlot slot) {
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
}
private Long timeSlotId(ScheduleRuleSlot slot) {
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
}
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
if (Objects.equals(first, second)) {
return true;
}
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
}
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
Set<Long> firstGroupIds = groupIds(first);
Set<Long> secondGroupIds = groupIds(second);
firstGroupIds.retainAll(secondGroupIds);
for (Long groupId : firstGroupIds) {
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
return true;
}
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
firstSubgroupIds.retainAll(secondSubgroupIds);
if (!firstSubgroupIds.isEmpty()) {
return true;
}
}
return false;
}
private Set<Long> groupIds(ScheduleRuleSlot slot) {
Set<Long> ids = new HashSet<>();
if (slot.getScheduleRule() == null) {
return ids;
}
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
ids.add(group.getId());
}
return ids;
}
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
return subgroupIdsForGroup(slot, groupId).isEmpty();
}
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
Set<Long> ids = new HashSet<>();
for (Subgroup subgroup : slot.getSubgroups()) {
if (subgroup.getStudentGroup() != null
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
ids.add(subgroup.getId());
}
}
return ids;
}
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
if (slots.isEmpty()) {
return activeWeeks;
}
ScheduleRule rule = slots.iterator().next().getScheduleRule();
if (rule == null || rule.getSemester() == null) {
return activeWeeks;
}
int maxWeeks = maxWeeksForRule(rule);
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
int totalHours = rule.academicHoursFor(category);
if (totalHours <= 0) {
continue;
}
List<ScheduleRuleSlot> categorySlots = slots.stream()
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.sorted(this::compareSlotsForGeneration)
.toList();
if (categorySlots.isEmpty()) {
continue;
}
Map<String, Integer> consumedHours = new HashMap<>();
int startWeek = Math.max(1, rule.startWeekFor(category));
for (int week = startWeek; week <= maxWeeks; week++) {
for (ScheduleRuleSlot slot : categorySlots) {
if (!slotAppliesToWeek(slot, week)) {
continue;
}
for (String scopeKey : consumptionScopes(slot, category)) {
int consumed = consumedHours.getOrDefault(scopeKey, 0);
if (consumed >= totalHours) {
continue;
}
activeWeeks.get(slot).add(week);
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
}
}
}
}
return activeWeeks;
}
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
return false;
}
Set<Integer> weeks = new HashSet<>(first);
weeks.retainAll(second);
return !weeks.isEmpty();
}
private int maxWeeksForRule(ScheduleRule rule) {
Semester semester = rule.getSemester();
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
return Math.max(1, (int) Math.ceil(days / 7.0));
}
return 18;
}
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
int dayCompare = Integer.compare(
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
);
if (dayCompare != 0) {
return dayCompare;
}
int orderCompare = Integer.compare(
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
);
if (orderCompare != 0) {
return orderCompare;
}
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
}
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
ScheduleParity parity = slot.getParity();
if (parity == null || parity == ScheduleParity.BOTH) {
return true;
}
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
return parity == weekParity;
}
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
return slot.getSubgroups().stream()
.map(subgroup -> "subgroup:" + subgroup.getId())
.toList();
}
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
return List.of("rule");
}
return slot.getScheduleRule().getGroups().stream()
.map(group -> "group:" + group.getId())
.toList();
}
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
}
if (slotDto.parity() == null) {
throw new IllegalArgumentException("Чётность недели обязательна");
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
}
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
.thenComparing(Subgroup::getName))
.toList();
}
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
}

View File

@@ -66,6 +66,10 @@ public class SubgroupController {
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
String capacityError = validateGroupCapacity(group, null, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
Subgroup subgroup = new Subgroup();
subgroup.setStudentGroup(group);
@@ -95,6 +99,10 @@ public class SubgroupController {
if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity());
if (capacityError != null) {
return ResponseEntity.badRequest().body(Map.of("message", capacityError));
}
subgroup.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity());
@@ -113,6 +121,10 @@ public class SubgroupController {
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try {
subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup);
@@ -127,8 +139,39 @@ public class SubgroupController {
if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно";
}
if (request.studentCapacity() != null && request.studentCapacity() < 0) {
return "Численность подгруппы не может быть отрицательной";
if (request.studentCapacity() == null) {
return "Численность подгруппы обязательна";
}
if (request.studentCapacity() <= 0) {
return "Численность подгруппы должна быть больше 0";
}
return null;
}
private String validateGroupCapacity(StudentGroup group, Long excludedSubgroupId, Integer requestedCapacity) {
Long currentCapacity = excludedSubgroupId == null
? subgroupRepository.sumActiveStudentCapacityByGroupId(group.getId())
: subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(group.getId(), excludedSubgroupId);
long totalCapacity = currentCapacity + requestedCapacity.longValue();
if (totalCapacity > group.getGroupSize()) {
return "Сумма численностей подгрупп не может превышать численность группы (" + group.getGroupSize() + " чел.)";
}
return null;
}
private String validateDeleteKeepsCompleteSplit(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
long remainingCount = subgroupRepository.countActiveByGroupIdExcludingId(group.getId(), subgroup.getId());
if (remainingCount == 0) {
return null;
}
Long remainingCapacity = subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(
group.getId(),
subgroup.getId()
);
if (remainingCapacity < group.getGroupSize()) {
return "Нельзя удалить одну подгруппу из деления: измените количество подгрупп в форме настройки или отключите деление";
}
return null;
}

View File

@@ -0,0 +1,190 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-requests")
@RequireRoles({Role.ADMIN})
public class TeacherCreationRequestController {
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
BCryptPasswordEncoder passwordEncoder) {
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping
public List<TeacherCreationRequestResponse> getRequests(@RequestParam(required = false) TeacherCreationRequestStatus status) {
return (status == null
? teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc()
: teacherCreationRequestRepository.findByStatusOrderByCreatedAtDesc(status))
.stream()
.map(this::toResponse)
.toList();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
if (review == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Данные одобрения обязательны"));
}
String username = firstNotBlank(review.getUsername(), request.getUsername());
String fullName = firstNotBlank(review.getFullName(), request.getFullName());
String jobTitle = firstNotBlank(review.getJobTitle(), request.getJobTitle(), "Не указано");
if (username.length() > 50 || fullName.length() > 255 || jobTitle.length() > 255) {
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей преподавателя"));
}
if (review.getPassword() == null || review.getPassword().length() < 8) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль преподавателя должен быть не короче 8 символов"));
}
if (userRepository.findByUsername(username).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь с таким логином уже существует"));
}
Long departmentId = review.getDepartmentId() == null ? request.getDepartment().getId() : review.getDepartmentId();
Department department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || department.isArchivedRecord()) {
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
}
User teacher = new User();
teacher.setUsername(username);
teacher.setPassword(passwordEncoder.encode(review.getPassword()));
teacher.setRole(Role.TEACHER);
teacher.setFullName(fullName);
teacher.setJobTitle(jobTitle);
teacher.setDepartmentId(department.getId());
userRepository.save(teacher);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Создан по заявке кафедры #" + request.getId());
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
request.setUsername(username);
request.setFullName(fullName);
request.setJobTitle(jobTitle);
request.setDepartment(department);
request.setStatus(TeacherCreationRequestStatus.APPROVED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review.getReviewComment()));
request.setCreatedTeacherId(teacher.getId());
teacherCreationRequestRepository.save(request);
return ResponseEntity.ok(Map.of(
"request", toResponse(request),
"teacher", toUserResponse(teacher, department)
));
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody(required = false) ReviewTeacherCreationRequest review) {
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
if (request == null) {
return ResponseEntity.notFound().build();
}
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
}
request.setStatus(TeacherCreationRequestStatus.REJECTED);
request.setReviewedBy(AuthContext.currentUserId());
request.setReviewedAt(LocalDateTime.now());
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
}
private TeacherCreationRequestResponse toResponse(TeacherCreationRequest request) {
Department department = request.getDepartment();
return new TeacherCreationRequestResponse(
request.getId(),
department == null ? null : department.getId(),
department == null ? null : department.getDepartmentName(),
request.getUsername(),
request.getFullName(),
request.getJobTitle(),
request.getComment(),
request.getStatus().name(),
request.getRequestedBy(),
request.getCreatedAt(),
request.getReviewedBy(),
request.getReviewedAt(),
request.getReviewComment(),
request.getCreatedTeacherId()
);
}
private UserResponse toUserResponse(User user, Department department) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
department.getDepartmentName()
);
response.setDepartmentId(department.getId());
response.setStatus(user.getStatus());
return response;
}
private String firstNotBlank(String... values) {
for (String value : values) {
String normalized = trimToNull(value);
if (normalized != null) {
return normalized;
}
}
return "";
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}

View File

@@ -24,8 +24,10 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@@ -75,12 +77,12 @@ public class UserController {
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
Role.TEACHER,
departmentId,
LifecycleEntity.STATUS_ARCHIVED
);
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
@@ -96,6 +98,11 @@ public class UserController {
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request == null) {
String errorMessage = "Данные пользователя обязательны";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
if (request.getUsername() == null || request.getUsername().isBlank()) {
@@ -103,8 +110,8 @@ public class UserController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
String errorMessage = "Пароль минимум 4 символа";
if (request.getPassword() == null || request.getPassword().length() < 8) {
String errorMessage = "Пароль минимум 8 символов";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
@@ -127,6 +134,12 @@ public class UserController {
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department requestedDepartment = departmentRepository.findById(request.getDepartmentId()).orElse(null);
if (requestedDepartment == null || requestedDepartment.isArchivedRecord()) {
String errorMessage = "Активная кафедра не найдена";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
@@ -145,17 +158,14 @@ public class UserController {
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
if (role == Role.TEACHER) {
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
if (department != null) {
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(user);
assignment.setDepartment(requestedDepartment);
assignment.setValidFrom(LocalDate.now());
assignment.setPrimaryAssignment(true);
assignment.setComment("Начальная кафедра при создании пользователя");
assignment.setCreatedBy(AuthContext.currentUserId());
teacherDepartmentAssignmentRepository.save(assignment);
}
logger.info("Пользователь успешно создан с ID: {}", user.getId());
@@ -193,6 +203,13 @@ public class UserController {
if (!userRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
var currentUser = AuthContext.getCurrentUser();
if (currentUser != null
&& currentUser.role() == Role.DEPARTMENT
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
}
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
.map(this::toAssignmentDto)
.toList());
@@ -247,12 +264,12 @@ public class UserController {
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (!canAccessDepartment(departmentId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
}
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
.map(this::toUserResponse)
.toList());
}
@@ -289,4 +306,28 @@ public class UserController {
assignment.getComment()
);
}
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
Map<Long, User> teachersById = new LinkedHashMap<>();
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
if (assignments != null) {
assignments.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(Objects::nonNull)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
}
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
if (directTeachers != null) {
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
}
return List.copyOf(teachersById.values());
}
private boolean canAccessDepartment(Long departmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser == null
|| currentUser.role() != Role.DEPARTMENT
|| (departmentId != null && departmentId.equals(currentUser.departmentId()));
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarSubjectDto(
Long id,
Long calendarId,
Integer semesterNumber,
Long subjectId,
String subjectName,
String subjectCode,
Long departmentId
) {
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
public class CreateTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private String comment;
private Long departmentId;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -1,5 +1,7 @@
package com.magistr.app.dto;
import java.util.List;
public record GroupCalendarAssignmentDto(
Long id,
Long groupId,
@@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto(
Long academicYearId,
String academicYearTitle,
Long calendarId,
String calendarTitle
String calendarTitle,
List<AcademicCalendarSubjectDto> subjects
) {
}

View File

@@ -0,0 +1,59 @@
package com.magistr.app.dto;
public class ReviewTeacherCreationRequest {
private String username;
private String fullName;
private String jobTitle;
private Long departmentId;
private String password;
private String reviewComment;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.dto;
import java.time.LocalDateTime;
public record TeacherCreationRequestResponse(
Long id,
Long departmentId,
String departmentName,
String username,
String fullName,
String jobTitle,
String comment,
String status,
Long requestedBy,
LocalDateTime createdAt,
Long reviewedBy,
LocalDateTime reviewedAt,
String reviewComment,
Long createdTeacherId
) {
}

View File

@@ -0,0 +1,65 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(
name = "academic_calendar_subjects",
uniqueConstraints = @UniqueConstraint(
name = "uq_academic_calendar_subject",
columnNames = {"calendar_id", "semester_number", "subject_id"}
),
indexes = {
@Index(name = "idx_academic_calendar_subjects_calendar_semester", columnList = "calendar_id, semester_number"),
@Index(name = "idx_academic_calendar_subjects_subject", columnList = "subject_id")
}
)
public class AcademicCalendarSubject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "calendar_id", nullable = false)
private AcademicCalendar academicCalendar;
@Column(name = "semester_number", nullable = false)
private Integer semesterNumber;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subject_id", nullable = false)
private Subject subject;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public AcademicCalendar getAcademicCalendar() {
return academicCalendar;
}
public void setAcademicCalendar(AcademicCalendar academicCalendar) {
this.academicCalendar = academicCalendar;
}
public Integer getSemesterNumber() {
return semesterNumber;
}
public void setSemesterNumber(Integer semesterNumber) {
this.semesterNumber = semesterNumber;
}
public Subject getSubject() {
return subject;
}
public void setSubject(Subject subject) {
this.subject = subject;
}
}

View File

@@ -0,0 +1,168 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "teacher_creation_requests")
public class TeacherCreationRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
@Column(nullable = false, length = 50)
private String username;
@Column(name = "full_name", nullable = false, length = 255)
private String fullName;
@Column(name = "job_title", nullable = false, length = 255)
private String jobTitle;
@Column(columnDefinition = "TEXT")
private String comment;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TeacherCreationRequestStatus status = TeacherCreationRequestStatus.PENDING;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "created_teacher_id")
private Long createdTeacherId;
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() {
return jobTitle;
}
public void setJobTitle(String jobTitle) {
this.jobTitle = jobTitle;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public TeacherCreationRequestStatus getStatus() {
return status;
}
public void setStatus(TeacherCreationRequestStatus status) {
this.status = status == null ? TeacherCreationRequestStatus.PENDING : status;
}
public Long getRequestedBy() {
return requestedBy;
}
public void setRequestedBy(Long requestedBy) {
this.requestedBy = requestedBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Long getReviewedBy() {
return reviewedBy;
}
public void setReviewedBy(Long reviewedBy) {
this.reviewedBy = reviewedBy;
}
public LocalDateTime getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
public Long getCreatedTeacherId() {
return createdTeacherId;
}
public void setCreatedTeacherId(Long createdTeacherId) {
this.createdTeacherId = createdTeacherId;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum TeacherCreationRequestStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -0,0 +1,34 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendarSubject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface AcademicCalendarSubjectRepository extends JpaRepository<AcademicCalendarSubject, Long> {
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id = :calendarId
order by calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdWithSubject(@Param("calendarId") Long calendarId);
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id in :calendarIds
order by calendar.id asc, calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
void deleteByAcademicCalendar_Id(Long calendarId);
}

View File

@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
and subgroup.status <> 'ARCHIVED'
""")
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
""")
Long sumActiveStudentCapacityByGroupId(@Param("groupId") Long groupId);
@Query("""
select coalesce(sum(subgroup.studentCapacity), 0)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
Long sumActiveStudentCapacityByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
@Query("""
select count(subgroup)
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and subgroup.status <> 'ARCHIVED'
and subgroup.id <> :excludedId
""")
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
@Param("excludedId") Long excludedId);
}

View File

@@ -0,0 +1,18 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
List<TeacherCreationRequest> findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status);
List<TeacherCreationRequest> findAllByOrderByCreatedAtDesc();
boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status);
}

View File

@@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
join fetch a.teacher
join fetch a.department
where a.department.id = :departmentId
and a.primaryAssignment = true
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
order by a.teacher.fullName
@@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
@Query("""
select count(a) > 0
from TeacherDepartmentAssignment a
where a.teacher.id = :teacherId
and a.department.id = :departmentId
and a.validFrom <= :date
and (a.validTo is null or a.validTo >= :date)
""")
boolean existsActiveAssignment(
@Param("teacherId") Long teacherId,
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date
);
}

View File

@@ -133,6 +133,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
@@ -143,6 +147,34 @@ WHERE u.role = 'TEACHER'
WHERE existing.teacher_id = u.id
);
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
username VARCHAR(50) NOT NULL,
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
comment TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
requested_by BIGINT REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_by BIGINT REFERENCES users(id),
reviewed_at TIMESTAMP,
review_comment TEXT,
created_teacher_id BIGINT REFERENCES users(id),
CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_department
ON teacher_creation_requests(department_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_status
ON teacher_creation_requests(status, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_creation_requests_pending_username
ON teacher_creation_requests(username)
WHERE status = 'PENDING';
-- ==========================================
-- Образовательные формы
-- ==========================================
@@ -531,7 +563,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
course_count INT NOT NULL DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0),
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8),
CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
);
@@ -585,6 +617,22 @@ CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
ON CONFLICT (calendar_id, course_number, date) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
semester_number INT NOT NULL,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0),
CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester
ON academic_calendar_subjects(calendar_id, semester_number);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject
ON academic_calendar_subjects(subject_id);
CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
@@ -932,12 +980,14 @@ COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика';
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
@@ -1068,6 +1118,11 @@ COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
COMMENT ON COLUMN academic_calendar_subjects.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...';
COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects';
COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки';
COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы';
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';

View File

@@ -0,0 +1,6 @@
ALTER TABLE subgroups
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
ON subgroups (group_id, lower(name))
WHERE status <> 'ARCHIVED';

View File

@@ -11,18 +11,23 @@ import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DepartmentWorkspaceControllerTest {
@@ -43,7 +48,9 @@ class DepartmentWorkspaceControllerTest {
subjectRepository,
mock(SubjectCommentRepository.class),
mock(UserRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -67,7 +74,9 @@ class DepartmentWorkspaceControllerTest {
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
@@ -77,6 +86,39 @@ class DepartmentWorkspaceControllerTest {
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
}
@Test
void departmentCanAddExistingTeacherAsSecondaryAssignment() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User teacher = user(20L, "teacher", "Иванов Иван Иванович", 2L);
Department department = new Department(1L, "Кафедра ИБ", 1L);
when(userRepository.findById(20L)).thenReturn(Optional.of(teacher));
when(departmentRepository.findById(1L)).thenReturn(Optional.of(department));
when(assignmentRepository.existsActiveAssignment(20L, 1L, LocalDate.now())).thenReturn(false);
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
departmentRepository,
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
var response = controller.addTeacherToDepartment(20L, Map.of("comment", "Совместительство"));
assertThat(response.getStatusCode().value()).isEqualTo(200);
ArgumentCaptor<TeacherDepartmentAssignment> captor = ArgumentCaptor.forClass(TeacherDepartmentAssignment.class);
verify(assignmentRepository).save(captor.capture());
TeacherDepartmentAssignment saved = captor.getValue();
assertThat(saved.getTeacher()).isEqualTo(teacher);
assertThat(saved.getDepartment()).isEqualTo(department);
assertThat(saved.getPrimaryAssignment()).isFalse();
assertThat(saved.getCreatedBy()).isEqualTo(3L);
}
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();

View File

@@ -23,13 +23,16 @@ class UserControllerTest {
void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
mock(TeacherDepartmentAssignmentRepository.class)
assignmentRepository
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))

View File

@@ -19,6 +19,7 @@
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
@@ -64,7 +65,8 @@
3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
6. В разделе групп администратор назначает календарный график группе на нужный учебный год.
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря.

View File

@@ -147,6 +147,8 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users`
Создание пользователя.
@@ -165,7 +167,7 @@ Refresh-токен ротируется при каждом успешном о
**Валидация:**
- `username` — обязателен и уникален
- `password` — минимум 4 символа
- `password` — минимум 8 символов
- `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
@@ -196,7 +198,40 @@ Refresh-токен ротируется при каждом успешном о
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
Список преподавателей кафедры на конкретную дату по таблице истории.
Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
---
## Заявки на создание преподавателей
Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
**Тело одобрения:**
```json
{
"departmentId": 2,
"username": "teacher.new",
"password": "secure-pass",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"reviewComment": "Данные проверены"
}
```
Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.
**Тело отклонения:**
```json
{
"reviewComment": "Нужно уточнить ФИО"
}
```
---
@@ -361,6 +396,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки |
| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика |
**Тело создания/обновления графика:**
```json
@@ -375,6 +412,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
```
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`.
**Ячейка сетки графика:**
```json
@@ -388,6 +426,17 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
}
```
**Привязка дисциплин к графику:**
```json
[
{ "semesterNumber": 1, "subjectId": 1 },
{ "semesterNumber": 1, "subjectId": 2 },
{ "semesterNumber": 2, "subjectId": 3 }
]
```
`semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. `PUT /subjects` выполняет атомарную полную замену списка: перед вставкой нового набора старые привязки этого графика удаляются и синхронизируются с БД.
### `POST /api/admin/schedule-rules`
Создание правила динамического расписания.
@@ -435,6 +484,25 @@ CRUD доступен по:
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
```json
{
"message": "Невозможно сохранить правило: слот занят",
"conflictRule": {
"id": 12,
"subjectName": "Математический анализ",
"semesterId": 1,
"groupNames": ["ИБ-101"],
"slots": []
},
"conflictFields": ["classroom"],
"conflictReasons": ["Аудитория"]
}
```
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
### `GET /api/lesson-types`
Справочник типов занятий для конструктора правил расписания.
@@ -539,10 +607,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
| `POST` | `/api/department/teachers/{teacherId}/assignments` | Добавить существующего преподавателя на кафедру |
| `GET` | `/api/department/teacher-requests` | Заявки кафедры на создание преподавателей |
| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
| `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
```json
{
"departmentId": 2,
"comment": "Совместительство"
}
```
`POST /api/department/teacher-requests` принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.
```json
{
"username": "teacher.new",
"fullName": "Новый Преподаватель",
"jobTitle": "Доцент",
"comment": "Нужен для дисциплин кафедры"
}
```
---
## Кафедры и специальности
@@ -686,6 +777,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп |
@@ -729,6 +824,30 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
**Ответ назначения:**
```json
{
"id": 12,
"groupId": 1,
"groupName": "ИВТ-21-1",
"academicYearId": 1,
"academicYearTitle": "2025-2026",
"calendarId": 5,
"calendarTitle": "09.03.04 очная форма 2025-2026",
"subjects": [
{
"id": 44,
"calendarId": 5,
"semesterNumber": 1,
"subjectId": 1,
"subjectName": "Высшая математика",
"subjectCode": "Б1.О.01",
"departmentId": 1
}
]
}
```
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
---

View File

@@ -8,7 +8,7 @@
|------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
@@ -27,7 +27,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
---
@@ -58,8 +60,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms)
@@ -93,14 +96,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
### Перевод преподавателей между кафедрами
### Кафедральные связи преподавателей
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные.
Правила:
- у преподавателя должна быть одна открытая основная кафедра;
- преподаватель может иметь несколько открытых неосновных кафедр;
- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
---
@@ -118,6 +126,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `academic_calendar_days` | Дневная сетка по курсу и дате |
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
@@ -171,13 +180,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
### Точечные изменения расписания

View File

@@ -1,420 +0,0 @@
# План стилизации кнопок
Дата аудита: 01.06.2026
Среда аудита: `https://localhost`
Метод: визуальный обход через Playwright, фиксация скриншотов и computed styles.
## 1. Краткое резюме
Интерфейс уже имеет узнаваемую базу: тёмная админ-панель, фиолетовый акцент, компактные таблицы и отдельные публичные кабинеты преподавателя/студента. Главная проблема не в отдельных кнопках, а в отсутствии единого контракта между смыслом действия и его визуальным видом.
Сейчас одинаковые классы могут выглядеть по-разному в разных SPA, а одинаковые действия могут получать разные цвета, размеры и плотность. Из-за этого пользователь каждый раз заново считывает интерфейс: где главное действие, где безопасное второстепенное, а где рискованное.
Цель новой структуры: закрепить единую кнопку как системный компонент с понятными ролями, размерами и правилами использования.
## 2. Что было проверено
| Раздел | Проверенные сценарии | Скриншот |
|---|---|---|
| Авторизация | Вход, показ пароля, переключение темы | [`audit-buttons-01-login.png`](../audit-buttons-01-login.png) |
| Дашборд админки | Быстрые переходы, recheck, сайдбар | [`audit-buttons-02-dashboard.png`](../audit-buttons-02-dashboard.png) |
| Пользователи | Создание пользователя, архивирование | [`audit-buttons-03-users-form.png`](../audit-buttons-03-users-form.png) |
| Меню настроек | Выпадающее меню, выход, переход в настройки | [`audit-buttons-04-settings-menu.png`](../audit-buttons-04-settings-menu.png) |
| Настройки | Навигация отдельной SPA | [`audit-buttons-05-settings-general.png`](../audit-buttons-05-settings-general.png) |
| Кабинет преподавателя | Недели, сегодня, выход, тема | [`audit-buttons-06-teacher.png`](../audit-buttons-06-teacher.png) |
| Кабинет студента | Недели, сегодня, выход, тема | [`audit-buttons-07-student.png`](../audit-buttons-07-student.png) |
| Модальное окно | Создание ручной сетки, отмена, закрытие | [`audit-buttons-08-modal.png`](../audit-buttons-08-modal.png) |
Дополнительно были сохранены скриншоты отдельных разделов: календарный график, аудитории, группы, дисциплины, расписание, формы обучения, база данных, временные слоты.
## 3. Текущее состояние
### 3.1. Авторизация
![Экран авторизации](../audit-buttons-01-login.png)
Найдены кнопки:
| Кнопка | Текущая роль | Комментарий |
|---|---|---|
| `Войти` | Primary | Корректно считывается как главное действие экрана. |
| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. |
| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. |
Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру.
### 3.2. Дашборд админки
![Дашборд админки](../audit-buttons-02-dashboard.png)
Найдены кнопки и ссылки-кнопки:
| Кнопка | Текущий вид | Проблема |
|---|---|---|
| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. |
| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. |
| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. |
| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. |
| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. |
Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию.
### 3.3. Формы и таблицы админки
![Форма пользователей](../audit-buttons-03-users-form.png)
Типичные кнопки:
| Кнопка | Смысл | Текущее впечатление |
|---|---|---|
| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. |
| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. |
| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. |
| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. |
Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках.
### 3.4. Меню настроек и выход
![Меню настроек](../audit-buttons-04-settings-menu.png)
Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль.
Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки.
### 3.5. Отдельная SPA настроек
![Общие настройки](../audit-buttons-05-settings-general.png)
В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему.
Пример различий:
| Класс | Основная админка | Настройки |
|---|---|---|
| `btn-primary` | Часто прозрачный фон, белый текст, визуальная заливка может приходить из другого слоя | Фиолетовая заливка `rgb(139, 92, 246)`, высота `40px`, жирность `700` |
| `btn-delete` | Красный tint, высота часто `28px`, font-weight `400` | Красный tint, высота `40px`, font-weight `700` |
| `btn-secondary` | Фиолетовый tint или светлая surface-кнопка | Нейтральная surface-кнопка |
Вывод: одинаковое имя класса не является дизайн-контрактом. Компонент кнопки нужно нормализовать на уровне токенов и модификаторов.
### 3.6. Кабинеты преподавателя и студента
![Кабинет преподавателя](../audit-buttons-06-teacher.png)
![Кабинет студента](../audit-buttons-07-student.png)
В публичных кабинетах используется отдельная светлая система:
| Элемент | Преподаватель | Студент |
|---|---|---|
| `Сегодня` | Primary blue `rgb(29, 78, 216)` | Primary teal `rgb(15, 118, 110)` |
| `Выйти` | Outline, белый фон, radius `8px` | Outline, белый фон, radius `8px` |
| `` / `` | Outline icon-like buttons | Outline icon-like buttons |
| `Переключить тему` | Текстовый символ `◐` | Текстовый символ `◐` |
Проблема: одно действие `Сегодня` меняет цвет в зависимости от кабинета. Это допустимо для брендинга ролей, но плохо для системных кнопок. Ролевые цвета лучше использовать в расписании, бейджах и карточках, а не в базовой кнопочной иерархии.
### 3.7. Модальные окна
![Модальное окно создания ручной сетки](../audit-buttons-08-modal.png)
Найдены:
| Кнопка | Роль | Рекомендация |
|---|---|---|
| `Создать сетку` | Primary | Оставить primary. |
| `Отмена` | Secondary/Ghost | Сделать ghost или secondary без акцента. |
| `Закрыть` (`×`) | Icon close | Привести к icon-only button `40x40` или `36x36`, с `aria-label="Закрыть"`. |
Модальная структура в целом правильная, но её нужно подчинить тем же токенам, что и остальные кнопки.
## 4. Семантическая группировка найденных кнопок
### Primary: главное действие
Примеры: `Войти`, `Создать`, `Добавить`, `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот`, `Создать сетку`.
Правило: одна primary-кнопка на форму, модальное окно или смысловой блок. Если на экране несколько форм, primary может быть в каждой форме, но не должна превращаться в оформление всех кнопок подряд.
### Secondary: обычное второстепенное действие
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус`, `Преподаватели`, `Загрузить сетку`, `Заполнить диапазон`.
Правило: использовать для действий, которые помогают выполнить задачу, но не являются главным подтверждением.
### Tertiary / Ghost: тихое действие
Примеры: `Очистить`, `Очистить форму`, `Отмена`, `Назад в панель`, `Свернуть`, `Из формы`, `Все`, `Точечные изменения / Просмотр`.
Правило: использовать там, где действие должно быть доступно, но не конкурировать с сохранением или созданием.
### Danger: деструктивное действие
Примеры: `Удалить`.
Правило: только для необратимых или почти необратимых операций. Красный цвет должен быть зарезервирован именно под риск.
### Danger subtle: рискованное, но не удаление
Примеры: `Архив`, `Архивировать`, `Удалить сетку`, `Выйти`.
Правило: использовать для действий с негативным или сессионным эффектом, но без визуальной паники solid-red кнопки.
### Icon button: компактное служебное действие
Примеры: `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, ``, ``.
Правило: фиксированный квадратный размер, единое состояние hover/focus, обязательный доступный текст через `aria-label`.
### Navigation item: навигация, а не кнопка действия
Примеры: пункты сайдбара `Дашборд`, `Конструктор правил`, `Календарный график`, `Пользователи`, `Общие настройки`.
Правило: не смешивать навигацию с action button. У неё отдельные active, hover и focus-состояния.
## 5. Предлагаемая дизайн-система кнопок
### 5.1. Токены
| Токен | Светлая тема | Тёмная тема | Назначение |
|---|---|---|---|
| `--button-primary-bg` | `#4F46E5` | `#8B5CF6` | Основное действие |
| `--button-primary-bg-hover` | `#4338CA` | `#7C3AED` | Hover primary |
| `--button-primary-text` | `#FFFFFF` | `#FFFFFF` | Текст primary |
| `--button-secondary-bg` | `#FFFFFF` | `rgba(255,255,255,0.04)` | Второстепенная поверхность |
| `--button-secondary-bg-hover` | `#F1F5F9` | `rgba(255,255,255,0.08)` | Hover secondary |
| `--button-secondary-border` | `#CBD5E1` | `rgba(255,255,255,0.10)` | Граница secondary |
| `--button-secondary-text` | `#334155` | `#F8FAFC` | Текст secondary |
| `--button-ghost-bg-hover` | `#F8FAFC` | `rgba(255,255,255,0.06)` | Hover ghost |
| `--button-danger-bg` | `#DC2626` | `#DC2626` | Удаление |
| `--button-danger-bg-hover` | `#B91C1C` | `#B91C1C` | Hover удаления |
| `--button-danger-subtle-bg` | `#FEF2F2` | `rgba(239,68,68,0.13)` | Риск без solid-заливки |
| `--button-danger-subtle-text` | `#B91C1C` | `#FECACA` | Текст risk subtle |
| `--button-focus-ring` | `rgba(79,70,229,0.35)` | `rgba(139,92,246,0.45)` | Focus outline |
Примечание: для всего продукта лучше выбрать один primary-акцент. Для текущей тёмной админки логично оставить фиолетовый `#8B5CF6`, а для светлой темы использовать более читаемый `#4F46E5`. Ролевые цвета преподавателя и студента лучше вынести из кнопок в расписание и бейджи.
### 5.2. Размеры
| Размер | Высота | Padding | Шрифт | Где применять |
|---|---:|---|---|---|
| `sm` | `32px` | `0 12px` | `13px / 600` | Таблицы, компактные действия строк |
| `md` | `40px` | `0 16px` | `14px / 600` | Формы, фильтры, модальные окна |
| `lg` | `48px` | `0 20px` | `15px / 700` | Вход, главный CTA экрана |
| `icon-sm` | `32x32` | `0` | icon `18px` | Табличные иконки |
| `icon-md` | `40x40` | `0` | icon `20px` | Верхние панели, модалки, навигация недель |
Базовый `border-radius`: `8px`.
Почему `8px`: интерфейс административный и рабочий. Радиус `8px` сохраняет мягкость, но выглядит собраннее, чем повсеместные `10px`, особенно в таблицах и плотных формах.
### 5.3. Варианты
#### Primary
Использовать для главного подтверждения.
| Состояние | Стиль |
|---|---|
| Default | Фон primary, белый текст, без видимой границы |
| Hover | Фон темнее на 8-12% |
| Focus | Видимый ring `2px` |
| Disabled | Нейтральный фон, muted-текст, `cursor: not-allowed` |
Примеры: `Войти`, `Создать`, `Сохранить правило`, `Создать сетку`.
#### Secondary
Использовать для обычных действий рядом с primary.
| Состояние | Стиль |
|---|---|
| Default | Surface-фон, тонкая граница, основной текст |
| Hover | Чуть более светлая/контрастная поверхность |
| Focus | Такой же focus ring, как у primary |
| Disabled | Opacity `0.5` |
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`.
#### Ghost
Использовать для тихих действий.
| Состояние | Стиль |
|---|---|
| Default | Прозрачный фон, muted/regular text |
| Hover | Лёгкий surface-hover |
| Focus | Ring без изменения иерархии |
Примеры: `Отмена`, `Очистить`, `Назад в панель`, `Свернуть`.
#### Danger
Использовать только для удаления.
| Состояние | Стиль |
|---|---|
| Default | Красная заливка, белый текст |
| Hover | Более тёмный красный |
| Focus | Красный или общий focus ring |
Примеры: `Удалить`.
#### Danger subtle
Использовать для рискованных действий без полного удаления.
| Состояние | Стиль |
|---|---|
| Default | Красный tint-фон, красный текст, мягкая border |
| Hover | Более насыщенный tint |
| Focus | Видимый ring |
Примеры: `Архивировать`, `Архив`, `Выйти`, `Удалить сетку`.
#### Icon button
Использовать для действий, где текст заменён иконкой.
| Параметр | Правило |
|---|---|
| Размер | `32x32` в таблицах, `40x40` в тулбарах |
| Иконка | 18-20px, один набор иконок |
| Текст | Только через `aria-label` |
| Форма | Квадрат с radius `8px`, не круг, кроме специальных случаев avatar/fab |
Примеры: `Переключить тему`, `Показать пароль`, `Закрыть`, `Предыдущая неделя`, `Следующая неделя`.
## 6. Маппинг текущих кнопок на новую систему
| Текущие кнопки | Новый тип | Размер |
|---|---|---|
| `Войти` | `Primary` | `lg` |
| `Создать`, `Добавить` | `Primary` | `md` |
| `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот` | `Primary` | `md` |
| `Создать сетку` | `Primary` | `md` |
| `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус` | `Secondary` | `sm` в таблицах, `md` в формах |
| `Преподаватели`, `Подгруппы`, `Календарь`, `Сетка` | `Secondary` | `sm` |
| `Очистить`, `Очистить форму`, `Отмена`, `Свернуть`, `Из формы`, `Все` | `Ghost` | `sm` или `md` |
| `Точечные изменения / Просмотр`, `Назад в панель` | `Ghost navigation` | `md` |
| `Удалить` | `Danger` | `sm` в таблицах, `md` в модалках |
| `Архив`, `Архивировать`, `Выйти`, `Удалить сетку` | `Danger subtle` | `sm` или `md` |
| `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, ``, `` | `Icon button` | `icon-sm` или `icon-md` |
| Пункты сайдбара | `Nav item` | отдельный nav-компонент |
## 7. Правила композиции
### Формы
Primary-кнопка должна стоять в конце формы или action row. Рядом с ней допустимы secondary/ghost-действия, но не ещё одна primary без причины.
Рекомендуемый порядок:
| Сценарий | Порядок |
|---|---|
| Форма создания | `Очистить` как ghost, `Создать` как primary |
| Форма редактирования | `Отмена` как ghost, `Сохранить` как primary |
| Фильтр | `Сбросить` как ghost, `Показать` как secondary или primary, если это единственное действие блока |
### Таблицы
В строках таблицы использовать только `sm`.
Рекомендуемый порядок:
1. Основное безопасное действие: `Изменить`, `Преподаватели`, `Подгруппы`.
2. Дополнительное действие: `Календарь`, `Сетка`.
3. Рискованное действие: `Архивировать` или `Удалить`.
Удаление лучше отделять визуально от остальных кнопок небольшим gap.
### Модальные окна
В модалке primary должен соответствовать заголовку и цели модалки.
Пример:
| Заголовок | Primary | Secondary/Ghost |
|---|---|---|
| `Новая ручная сетка` | `Создать сетку` | `Отмена` |
| `Удалить слот` | `Удалить` как danger | `Отмена` |
| `Редактировать слот` | `Сохранить слот` | `Отмена` |
Кнопка закрытия всегда icon-only с `aria-label="Закрыть"`.
### Навигация
Сайдбарные пункты не должны наследовать стили action-кнопок. У них отдельная логика:
| Состояние | Стиль |
|---|---|
| Default | Прозрачный фон, muted-текст |
| Hover | Нейтральный surface-hover |
| Active | Акцентный tint-фон, accent-текст |
| Focus | Видимый ring/outline |
## 8. Главные исправления по приоритету
| Приоритет | Что сделать | Почему важно |
|---|---|---|
| P1 | Нормализовать `btn-primary`, `btn-secondary`, `btn-delete` между основной админкой и настройками | Один класс должен означать один визуальный контракт |
| P1 | Развести `Danger` и `Danger subtle` | `Удалить` и `Архивировать` не должны выглядеть одинаково опасными |
| P1 | Ввести размерную шкалу `sm/md/lg/icon` | Таблицы и формы станут визуально ровнее |
| P2 | Унифицировать `Переключить тему`, `Закрыть`, ``, ``, `Показать пароль` как icon buttons | Повысит предсказуемость и доступность |
| P2 | Убрать ролевые primary-цвета из student/teacher кнопок | Одинаковые действия будут считываться одинаково |
| P3 | Заменить текстовые символы `◐`, `×`, ``, `` на иконки единого набора | Интерфейс станет аккуратнее и доступнее |
## 9. Визуальный референс поведения
```text
Primary
[ Сохранить правило ]
Фон: акцентный, текст белый. Только главное действие.
Secondary
[ Изменить ]
Фон: поверхность, тонкая граница. Обычное действие.
Ghost
[ Отмена ]
Фон: прозрачный. Тихое действие без конкуренции.
Danger
[ Удалить ]
Фон: красный. Только необратимое действие.
Danger subtle
[ Архивировать ]
Фон: красный tint. Рискованное, но не полноценное удаление.
Icon
[ icon ]
Фиксированный квадрат. Доступное название через aria-label.
```
## 10. Почему это решение улучшит интерфейс
Единая кнопочная система снижает когнитивную нагрузку. Пользователь перестаёт разгадывать каждый экран и начинает быстро узнавать роли: главное действие, второстепенное действие, отмена, удаление, навигация.
Разделение `Danger` и `Danger subtle` особенно важно для админ-панели. Сейчас `Архивировать` визуально конкурирует с `Удалить`, хотя риск у этих действий разный. После унификации красный solid будет означать только настоящее удаление.
Единая размерная шкала сделает таблицы спокойнее. В строках не будет скачков высоты и плотности, а формы и модалки начнут выглядеть как части одной системы.
Общий primary-цвет вернёт продукту целостность. Ролевые цвета можно сохранить там, где они помогают считывать расписание и принадлежность данных, но базовые действия должны оставаться стабильными.
## 11. Приложение: дополнительные скриншоты
| Раздел | Скриншот |
|---|---|
| Конструктор правил | ![Конструктор правил](../audit-buttons-rules.png) |
| Календарный график | ![Календарный график](../audit-buttons-calendar.png) |
| Просмотр расписаний | ![Просмотр расписаний](../audit-buttons-schedule-view.png) |
| Аудиторный фонд | ![Аудиторный фонд](../audit-buttons-classrooms.png) |
| Группы обучения | ![Группы обучения](../audit-buttons-groups.png) |
| Структура вуза | ![Структура вуза](../audit-buttons-structure.png) |
| Дисциплины | ![Дисциплины](../audit-buttons-subjects.png) |
| Пользователи | ![Пользователи](../audit-buttons-users.png) |
| Временные слоты | ![Временные слоты](../audit-buttons-settings-time-slots.png) |
| Формы обучения | ![Формы обучения](../audit-buttons-settings-study-forms.png) |
| База данных | ![База данных](../audit-buttons-settings-database.png) |

View File

@@ -148,6 +148,21 @@ erDiagram
TEXT comment
}
teacher_creation_requests {
BIGSERIAL id PK
BIGINT department_id FK
VARCHAR username
VARCHAR full_name
VARCHAR job_title
TEXT comment
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TIMESTAMP reviewed_at
TEXT review_comment
BIGINT created_teacher_id FK
}
subject_comments {
BIGSERIAL id PK
BIGINT subject_id FK
@@ -233,6 +248,14 @@ erDiagram
BIGINT activity_type_id FK
}
academic_calendar_subjects {
BIGSERIAL id PK
BIGINT calendar_id FK
INT semester_number
BIGINT subject_id FK
TIMESTAMP created_at
}
student_group_calendar_assignments {
BIGSERIAL id PK
BIGINT group_id FK
@@ -306,11 +329,14 @@ erDiagram
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id"
departments ||--o{ teacher_creation_requests : "department_id"
users ||--o{ teacher_creation_requests : "requested_by/reviewed_by/created_teacher_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ academic_calendar_subjects : "subject_id"
subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
@@ -325,6 +351,7 @@ erDiagram
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_id"
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
@@ -443,7 +470,7 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов |
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
@@ -528,7 +555,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя.
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр.
#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID заявки |
| `department_id` | BIGINT FK → departments | Кафедра, которая запрашивает преподавателя |
| `username` | VARCHAR(50) | Предлагаемый логин |
| `full_name` | VARCHAR(255) | ФИО преподавателя |
| `job_title` | VARCHAR(255) | Должность |
| `comment` | TEXT | Комментарий кафедры |
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
| `review_comment` | TEXT | Комментарий администратора |
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
| `created_at` | TIMESTAMP | Дата создания заявки |
| `updated_at` | TIMESTAMP | Дата последнего изменения |
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
#### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание |
@@ -608,7 +655,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
| `course_count` | INT CHECK(> 0) | Количество курсов в сетке |
| `course_count` | INT CHECK(18) | Количество курсов в сетке |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления |
@@ -623,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
#### `academic_calendar_subjects` — Дисциплины календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
| `created_at` | TIMESTAMP | Дата создания привязки |
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`.
#### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -713,13 +771,13 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
> Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов

View File

@@ -37,35 +37,31 @@ frontend/
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
│ │ ├── users.js # Управление пользователями
│ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами
│ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения
│ │ ├── profiles.js # Профили обучения специальностей
│ │ ├── dashboard.js # Дашборд
│ │ ├── users.js # Управление пользователями
│ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
│ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами
│ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики
│ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности
│ │ ── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ ├── views/ # HTML-шаблоны представлений
│ │ ├── dashboard.html
│ │ ├── users.html
│ │ ├── teacher-requests.html
│ │ ├── groups.html
│ │ ├── classrooms.html
│ │ ├── subjects.html
│ │ ├── equipments.html
│ │ ├── edu-forms.html
│ │ ├── profiles.html
│ │ ├── university-structure.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
│ │ ── auditorium-workload.html
│ │ ├── database.html
│ │ └── departments-data.html
│ │ ── auditorium-workload.html
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
@@ -129,32 +125,32 @@ frontend/
| Tab | Описание | API |
|-----|----------|-----|
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
| `equipments` | Оборудование | `/api/equipments` |
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -208,6 +204,8 @@ export const api = {
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
---
## Аутентификация (Frontend)

View File

@@ -385,6 +385,22 @@
align-items: end;
}
.department-workspace-control-card {
display: grid;
gap: 1rem;
}
.department-workspace-control-card .card-header-row {
margin-bottom: 0;
}
.department-workspace-filter-grid {
display: grid;
grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 0.65fr));
gap: 1rem;
align-items: end;
}
.schedule-view-metrics,
.department-workspace-grid {
display: grid;
@@ -396,7 +412,99 @@
align-items: stretch;
}
.department-workspace-grid-wide {
grid-template-columns: repeat(12, minmax(0, 1fr));
align-items: stretch;
}
.department-workspace-actions-grid {
gap: 1rem;
}
.department-subject-import-card {
grid-column: span 7;
grid-row: span 2;
}
.department-teacher-assign-card {
grid-column: span 5;
}
.department-teacher-request-card {
grid-column: span 5;
}
.department-teachers-card {
grid-column: 1 / -1;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card,
.department-workspace-table-card {
min-width: 0;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card {
display: flex;
flex-direction: column;
}
.department-subject-import-card .stack-form,
.department-teacher-assign-card .stack-form,
.department-teacher-request-card .stack-form {
display: flex;
flex-direction: column;
flex: 1;
}
.department-subject-import-card .subject-import-slots-panel {
flex: 1;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn {
align-self: flex-start;
margin-top: auto;
}
.teacher-request-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 180px;
}
.teacher-request-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
}
#teacher-requests-tbody input,
#teacher-requests-tbody select {
min-width: 150px;
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font: inherit;
font-size: 0.85rem;
}
#teacher-requests-tbody input:focus,
#teacher-requests-tbody select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.department-teachers-card {
display: flex;
flex-direction: column;
@@ -534,6 +642,274 @@
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
.schedule-conflict-modal {
z-index: 1200;
padding: 0.35rem;
overflow-x: hidden;
overflow-y: auto;
}
#schedule-conflict-modal .schedule-conflict-card {
width: min(1760px, calc(100vw - 0.7rem));
max-width: calc(100vw - 0.7rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}
.schedule-conflict-head {
display: grid;
gap: 0.3rem;
margin-right: 2rem;
margin-bottom: 0.65rem;
}
.schedule-conflict-head h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.05rem;
letter-spacing: 0;
text-transform: none;
}
.schedule-conflict-head p {
margin: 0;
color: var(--text-secondary);
line-height: 1.35;
}
.schedule-conflict-kicker {
width: max-content;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-reasons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 0.15rem;
}
.schedule-conflict-reasons[hidden] {
display: none;
}
.schedule-conflict-reasons span {
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
}
.schedule-conflict-reasons strong {
padding: 0.2rem 0.5rem;
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-size: 0.76rem;
font-weight: 800;
}
.schedule-conflict-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.schedule-conflict-rule-box {
display: grid;
gap: 0.25rem;
min-width: 0;
padding: 0.65rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-conflict-rule-box-active {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
}
.schedule-conflict-rule-box span,
.schedule-conflict-slot-fixed span {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-rule-box strong,
.schedule-conflict-slot-fixed strong {
min-width: 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.schedule-conflict-rule-box small,
.schedule-conflict-slot-fixed small {
color: var(--text-secondary);
line-height: 1.4;
}
.schedule-conflict-form {
display: grid;
gap: 0.55rem;
}
.schedule-conflict-slots {
display: grid;
gap: 0.55rem;
max-height: none;
overflow: visible;
padding-right: 0;
}
.schedule-conflict-slot-row {
display: grid;
grid-template-columns: minmax(160px, 1.05fr) minmax(104px, 0.72fr) minmax(112px, 0.72fr) minmax(132px, 0.85fr) minmax(190px, 1.2fr) minmax(160px, 0.95fr);
gap: 0.5rem;
align-items: end;
min-width: 0;
max-width: 100%;
padding: 0.6rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 88%, transparent);
}
.schedule-conflict-slot-fixed {
display: grid;
gap: 0.25rem;
align-self: stretch;
align-content: center;
min-width: 0;
}
.schedule-conflict-slot-fixed.schedule-conflict-field-hit {
padding-left: 0.55rem;
border-left: 3px solid rgba(245, 158, 11, 0.75);
}
.schedule-conflict-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0;
}
.schedule-conflict-card .form-group,
.schedule-move-slot-card .form-group {
min-width: 0;
}
.schedule-conflict-card .form-group label,
.schedule-move-slot-card .form-group label {
margin-bottom: 0.28rem;
font-size: 0.7rem;
}
.schedule-conflict-card .form-group select,
.schedule-move-slot-card .form-group select {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0.55rem 0.65rem;
font-size: 0.84rem;
}
.schedule-conflict-card .form-alert,
.schedule-move-slot-card .form-alert {
margin-top: 0;
}
.schedule-conflict-field-hit label {
color: var(--warning);
}
.schedule-conflict-field-hit select {
border-color: rgba(245, 158, 11, 0.65);
background: color-mix(in srgb, rgba(245, 158, 11, 0.15) 35%, var(--bg-input));
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.12);
}
.schedule-visual-context-menu {
position: fixed;
z-index: 1300;
display: grid;
min-width: 220px;
padding: 0.35rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
}
.schedule-visual-context-menu[hidden] {
display: none;
}
[data-theme="light"] .schedule-visual-context-menu {
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
}
.schedule-visual-context-menu button {
width: 100%;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.88rem;
text-align: left;
cursor: pointer;
}
.schedule-visual-context-menu button:hover,
.schedule-visual-context-menu button:focus-visible {
outline: none;
background: var(--bg-hover);
}
.schedule-visual-context-menu button.danger {
color: var(--error);
}
#schedule-move-slot-modal .schedule-move-slot-card {
width: min(620px, calc(100vw - 2rem));
box-sizing: border-box;
margin: auto;
max-height: none;
padding: 1rem;
overflow: visible;
}
.schedule-move-slot-form {
display: grid;
gap: 0.55rem;
}
@media (max-width: 980px) {
.schedule-conflict-slot-row {
grid-template-columns: minmax(180px, 1.2fr) repeat(2, minmax(140px, 1fr));
}
}
.subject-import-rows-list {
display: flex;
flex-direction: column;
@@ -544,7 +920,7 @@
.subject-import-row {
display: grid;
grid-template-columns: 160px minmax(260px, 1fr) auto;
grid-template-columns: minmax(120px, 0.7fr) minmax(240px, 1.3fr) auto;
column-gap: 0.65rem;
row-gap: 0.75rem;
align-items: end;
@@ -691,11 +1067,27 @@
min-width: 0;
}
.subject-import-row .btn {
min-height: 42px;
}
.subject-import-row .form-group label {
margin-bottom: 0.45rem;
}
@media (max-width: 900px) {
.department-workspace-grid-wide {
grid-template-columns: 1fr;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card {
grid-column: 1;
grid-row: auto;
}
.academic-year-semesters {
grid-template-columns: 1fr;
}
@@ -705,6 +1097,57 @@
}
}
@media (max-width: 1280px) and (min-width: 901px) {
.department-workspace-grid-wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.department-subject-import-card,
.department-teachers-card {
grid-column: 1 / -1;
grid-row: auto;
}
.department-teacher-assign-card,
.department-teacher-request-card {
grid-column: span 1;
}
}
@media (max-width: 700px) {
.department-workspace-filter-grid {
grid-template-columns: 1fr;
}
.subject-import-row {
grid-template-columns: 1fr;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn,
.subject-import-row .btn {
width: 100%;
}
#schedule-conflict-modal .schedule-conflict-card {
padding: 1rem;
}
.schedule-conflict-rules,
.schedule-conflict-slot-row {
grid-template-columns: 1fr;
}
.schedule-conflict-actions {
flex-direction: column-reverse;
}
.schedule-conflict-actions .btn {
width: 100%;
}
}
.academic-year-semester-meta {
display: grid;
gap: 0.2rem;
@@ -728,33 +1171,60 @@
.schedule-visual-handle {
position: fixed;
top: 50%;
right: 0;
right: 0 !important;
z-index: 905;
width: 40px;
height: 40px;
border: 1px solid var(--button-secondary-border);
border-right: 0;
border-radius: var(--button-radius) 0 0 var(--button-radius);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
transform: translateY(-50%);
transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
width: 46px !important;
height: 100px !important;
border: 1px solid var(--bg-card-border) !important;
border-left: 3px solid var(--accent) !important;
border-right: 0 !important;
border-radius: 8px 0 0 8px !important;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-card)) !important;
color: var(--text-primary) !important;
box-shadow: -4px 4px 16px rgba(0, 0, 0, 0.25) !important;
transform: translateY(-50%) !important;
transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1),
width 0.3s cubic-bezier(0.16, 1, 0.3, 1),
background 0.25s ease,
color 0.25s ease,
box-shadow 0.3s ease,
border-color 0.25s ease !important;
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
}
.schedule-visual-handle:hover,
.schedule-visual-handle.open {
border-color: var(--button-secondary-border);
background: var(--button-secondary-bg-hover);
color: var(--button-secondary-text);
.schedule-visual-handle:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-card)) !important;
color: var(--text-primary) !important;
width: 62px !important;
box-shadow: -8px 8px 24px rgba(0, 0, 0, 0.35) !important;
transform: translateY(-50%) !important;
}
.schedule-visual-handle.open {
right: min(92vw, 1180px);
right: min(92vw, 1180px) !important;
border-color: var(--bg-card-border) !important;
border-left: 3px solid var(--accent) !important;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)) !important;
color: var(--text-primary) !important;
}
.schedule-visual-handle.open:hover {
transform: translateY(-50%) !important;
width: 62px !important;
}
.schedule-visual-handle.open .schedule-visual-handle-icon {
transform: rotate(180deg);
transform: rotate(180deg) !important;
}
.schedule-visual-handle-icon {
width: 38px !important;
height: 38px !important;
stroke-width: 3px !important;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1) !important;
}
.schedule-visual-backdrop {
@@ -1090,31 +1560,31 @@
.schedule-visual-edit-rule {
position: absolute;
top: 0.26rem;
right: 0.26rem;
top: 0.18rem;
right: 0.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
width: 1rem;
height: 1.25rem;
padding: 0;
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
color: var(--accent-hover);
border: 0;
border-radius: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.82rem;
font-size: 1rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
box-shadow: none;
transition: color var(--transition), transform var(--transition);
}
.schedule-visual-edit-rule:hover,
.schedule-visual-edit-rule:focus-visible {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
color: var(--text-primary);
background: transparent;
color: var(--accent-hover);
outline: none;
transform: translateY(-1px);
}
@@ -1135,7 +1605,7 @@
}
.schedule-visual-handle.open {
right: calc(100vw - 42px);
right: calc(100vw - 46px) !important;
}
.schedule-visual-header {
@@ -1154,6 +1624,157 @@
}
}
.academic-calendar-tabs,
.university-structure-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--bg-card-border);
}
.academic-calendar-tab,
.university-structure-tab {
min-height: 40px;
padding: 0.55rem 0.9rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-weight: 700;
cursor: pointer;
transition: color var(--transition), background var(--transition), border-color var(--transition);
}
.academic-calendar-tab:hover,
.academic-calendar-tab:focus-visible,
.university-structure-tab:hover,
.university-structure-tab:focus-visible {
color: var(--text-primary);
background: var(--bg-hover);
outline: none;
}
.academic-calendar-tab.active,
.university-structure-tab.active {
color: var(--accent-hover);
background: var(--bg-card);
border-color: var(--bg-card-border);
}
.academic-calendar-tab-panel,
.university-structure-tab-panel {
display: grid;
gap: 1rem;
}
.academic-calendar-tab-panel[hidden],
.university-structure-tab-panel[hidden] {
display: none;
}
.calendar-subject-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.calendar-subject-semester {
min-height: 104px;
padding: 0.8rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 84%, var(--bg-card));
}
.calendar-subject-semester-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.65rem;
}
.calendar-subject-semester-head strong {
color: var(--text-primary);
font-size: 0.92rem;
}
.calendar-subject-semester-head span,
.calendar-subject-empty {
color: var(--text-secondary);
font-size: 0.84rem;
}
.calendar-subject-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-subject-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 30px;
padding: 0.25rem 0.28rem 0.25rem 0.55rem;
gap: 0.35rem;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--bg-card-border));
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-card));
color: var(--text-primary);
font-size: 0.84rem;
}
.calendar-subject-chip span {
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-subject-remove {
width: 22px;
height: 22px;
flex: 0 0 22px;
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.calendar-subject-remove:hover,
.calendar-subject-remove:focus-visible {
background: var(--danger);
color: white;
outline: none;
}
.assignment-subjects {
display: grid;
gap: 0.45rem;
min-width: 220px;
}
.assignment-subject-semester {
display: grid;
gap: 0.15rem;
}
.assignment-subject-semester strong {
color: var(--accent-hover);
font-size: 0.78rem;
}
.assignment-subject-semester span {
color: var(--text-primary);
font-size: 0.86rem;
line-height: 1.35;
}
.calendar-fill-panel {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
@@ -1660,11 +2281,10 @@
color: var(--text-secondary);
}
@media (max-width: 1180px) {
@media (max-width: 1350px) {
.schedule-view-filter-grid,
.schedule-view-metrics,
.department-workspace-grid {
.department-workspace-grid:not(.department-workspace-grid-wide) {
grid-template-columns: 1fr;
}
@@ -1674,11 +2294,23 @@
.schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(4, 1fr);
}
.schedule-slot-remove {
grid-column: 4;
justify-self: end;
width: auto;
}
}
@media (max-width: 1024px) {
.calendar-fill-panel {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
.calendar-fill-panel {
grid-template-columns: repeat(2, minmax(180px, 1fr));
.calendar-subject-summary {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.calendar-semesters-grid {
@@ -1698,6 +2330,17 @@
flex-direction: column;
gap: 0.2rem;
}
.schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
.schedule-slot-remove {
grid-column: 2;
justify-self: end;
width: auto;
}
}
@media (max-width: 640px) {
@@ -1710,6 +2353,12 @@
grid-template-columns: 1fr;
}
.schedule-slot-remove {
grid-column: 1;
justify-self: stretch;
width: 100%;
}
.subject-import-row {
grid-template-columns: 1fr;
}
@@ -1718,6 +2367,17 @@
grid-template-columns: 1fr;
}
.academic-calendar-tab,
.university-structure-tab {
flex: 1 1 100%;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
}
.calendar-subject-summary {
grid-template-columns: 1fr;
}
.calendar-day-dialog-grid {
grid-template-columns: 1fr;
}
@@ -2011,6 +2671,86 @@ input[type="number"] {
box-shadow: 0 0 0 3px var(--accent-glow);
}
.filter-row .custom-multi-select {
width: min(240px, 100%);
min-width: 220px;
}
.filter-row .select-box {
min-height: 38px;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
}
.filter-row .select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subgroup-config-grid {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
grid-template-areas:
"count capacities"
"actions actions";
gap: 1rem 0.75rem;
align-items: end;
}
.subgroup-count-field {
grid-area: count;
min-width: 0;
margin-bottom: 0;
}
.subgroup-capacity-fields {
grid-area: capacities;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
min-width: 0;
}
.subgroup-capacity-fields:empty {
display: none;
}
.subgroup-capacity-fields-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subgroup-capacity-fields-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.subgroup-capacity-field {
min-width: 0;
margin-bottom: 0;
}
.subgroup-actions {
grid-area: actions;
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0;
}
@media (max-width: 720px) {
.subgroup-config-grid {
grid-template-columns: 1fr;
grid-template-areas:
"count"
"capacities"
"actions";
}
.subgroup-capacity-fields {
grid-template-columns: 1fr;
}
}
/* ===== Custom Multi-Select ===== */
.custom-multi-select {
@@ -2136,6 +2876,17 @@ input[type="number"] {
transition: background 0.2s ease;
}
.form-group .checkbox-group-vertical .checkbox-item,
.filter-row .checkbox-group-vertical .checkbox-item {
display: flex;
align-items: center;
margin-bottom: 0;
color: var(--text-primary);
font-size: 0.9rem;
text-transform: none;
letter-spacing: 0;
}
.checkbox-item:hover {
background: var(--bg-hover);
}

View File

@@ -104,6 +104,7 @@
}
.nav-item svg {
flex: 0 0 auto;
transition: transform var(--transition);
}
@@ -112,6 +113,38 @@
transform: scale(1.15) rotate(-5deg);
}
.nav-item span:not(.nav-badge) {
min-width: 0;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
margin-left: auto;
padding: 0 0.45rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 999px;
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
}
.nav-badge[hidden],
.topbar-request-badge[hidden] {
display: none !important;
}
[data-theme="light"] .nav-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
@@ -247,7 +280,7 @@
display: none;
}
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span {
position: absolute;
left: calc(100% + 10px);
@@ -271,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .sidebar.collapsed .nav-item span,
[data-theme="light"] .sidebar.collapsed .nav-item span:not(.nav-badge),
[data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sidebar.collapsed .nav-item:hover span,
.sidebar.collapsed .nav-item:hover span:not(.nav-badge),
.sidebar.collapsed .btn-settings:hover span {
opacity: 1;
visibility: visible;
@@ -299,6 +332,16 @@
overflow: visible;
}
.sidebar.collapsed .nav-badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
min-width: 18px;
height: 18px;
padding: 0 0.3rem;
font-size: 0.65rem;
}
.sidebar.collapsed .btn-settings {
justify-content: center;
padding: 0.65rem 0;
@@ -333,9 +376,44 @@
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.topbar-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.topbar-request-badge {
min-height: 30px;
padding: 0 0.75rem;
border: 1px solid rgba(248, 113, 113, 0.26);
border-radius: 999px;
background: rgba(248, 113, 113, 0.13);
color: #fecaca;
font: inherit;
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.topbar-request-badge:hover {
transform: translateY(-1px);
background: rgba(248, 113, 113, 0.2);
border-color: rgba(248, 113, 113, 0.42);
}
[data-theme="light"] .topbar-request-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.content {
padding: 1.5rem 2rem;
display: flex;

View File

@@ -73,3 +73,82 @@
.modal-close:hover {
color: var(--error);
}
#modal-edit-group .edit-group-modal {
width: min(980px, calc(100vw - 2rem));
max-height: none;
overflow: visible;
}
#modal-edit-group #edit-group-form {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
#modal-edit-group .edit-group-form-row {
display: grid;
gap: 1rem;
align-items: end;
}
#modal-edit-group .edit-group-form-row-main {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#modal-edit-group .form-group {
min-width: 0;
}
#modal-edit-group .edit-group-submit {
display: flex;
align-items: flex-end;
}
#modal-edit-group .edit-group-submit .btn {
width: 100%;
}
@media (max-width: 980px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
#modal-edit-group .edit-group-form-row-main,
#modal-edit-group .edit-group-form-row-details {
grid-template-columns: 1fr;
}
}
#schedule-conflict-modal {
padding: 0.35rem;
overflow-x: hidden;
overflow-y: auto;
}
#schedule-conflict-modal .modal-content {
width: min(1760px, calc(100vw - 0.7rem));
max-width: calc(100vw - 0.7rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}
#schedule-move-slot-modal .modal-content {
width: min(620px, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
max-height: none;
box-sizing: border-box;
margin: auto;
padding: 1rem;
overflow: visible;
}

View File

@@ -67,6 +67,17 @@
<span>Дашборд</span>
</a>
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
<span>Заявки</span>
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
@@ -221,7 +232,10 @@
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 id="page-title">Загрузка...</h1>
<div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header>
<!-- Dynamic Content Injected Here -->

View File

@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
const error = new Error(data?.message || `Ошибка HTTP: ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
@@ -109,15 +112,23 @@ function invalidateCache(url) {
'/api/specialties',
'/api/education-forms',
'/api/users',
'/api/teacher-requests',
'/api/department/teacher-requests',
'/api/department/teachers',
'/api/equipments',
'/api/classrooms'
];
for (const prefix of cacheKeysToClear) {
if (url.includes(prefix)) {
for (const cacheKey of cache.keys()) {
if (cacheKey.includes(prefix)) {
cache.delete(cacheKey);
}
const affectedPrefixes = new Set(cacheKeysToClear.filter(prefix => url.includes(prefix)));
if (url.includes('/api/teacher-requests')) {
affectedPrefixes.add('/api/users');
}
if (url.includes('/api/department/teachers')) {
affectedPrefixes.add('/api/users/teachers');
}
for (const prefix of affectedPrefixes) {
for (const cacheKey of cache.keys()) {
if (cacheKey.includes(prefix)) {
cache.delete(cacheKey);
}
}
}

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { isAuthenticatedAsAny, logout } from './api.js';
import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -38,6 +38,14 @@ const ROUTES = {
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',
@@ -115,7 +123,7 @@ const ROUTES = {
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
},
EDUCATION_OFFICE: {
defaultTab: 'dashboard',
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
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 roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/';
});
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
@@ -271,6 +286,42 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: 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('Не удалось загрузить счётчик заявок:', error.message);
}
}
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 : roleNavigation.defaultTab);

View File

@@ -70,6 +70,16 @@ export async function initAcademicCalendar() {
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 semesters = [];
@@ -78,6 +88,8 @@ export async function initAcademicCalendar() {
let studyForms = [];
let activityTypes = [];
let calendars = [];
let subjects = [];
let calendarSubjectRows = [];
let timeSlotScopes = [];
let timeSlotDateAssignments = [];
let activeCalendarDay = null;
@@ -95,6 +107,7 @@ export async function initAcademicCalendar() {
await Promise.all([
loadBaseLists(),
loadYears(),
loadSubjects(),
loadStudyForms(),
loadActivityTypes(),
loadTimeSlotScopes(),
@@ -106,6 +119,9 @@ export async function initAcademicCalendar() {
}
function bindEvents() {
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
});
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
@@ -115,6 +131,11 @@ export async function initAcademicCalendar() {
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')) {
@@ -361,6 +382,11 @@ export async function initAcademicCalendar() {
populateStudyFormSelect();
}
async function loadSubjects() {
subjects = await api.get('/api/subjects');
populateSubjectSelect();
}
async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects();
@@ -380,6 +406,7 @@ export async function initAcademicCalendar() {
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
populateSubjectCalendarSelect();
} catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error;
@@ -402,6 +429,7 @@ export async function initAcademicCalendar() {
<td>
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn btn-sm btn-secondary btn-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td>
</tr>
@@ -440,9 +468,11 @@ export async function initAcademicCalendar() {
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 = 'Редактирование календарного графика';
@@ -460,13 +490,23 @@ export async function initAcademicCalendar() {
}
if (gridButton) {
activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect);
refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (subjectsButton) {
activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id;
refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить календарный график?')) return;
try {
@@ -489,6 +529,149 @@ export async function initAcademicCalendar() {
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() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
populateSubjectSemesterSelect();
calendarSubjectRows = [];
if (!calendar) {
renderCalendarSubjects();
return;
}
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
renderCalendarSubjects();
} catch (error) {
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
}
}
function addCalendarSubject() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
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();
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
}
async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar();
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();
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();
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
}
function renderCalendarSubjects() {
const calendar = selectedSubjectCalendar();
if (!calendar) {
subjectCount.textContent = '';
subjectSummary.innerHTML = '<div class="loading-row">Выберите календарный график</div>';
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 `
<section class="calendar-subject-semester">
<div class="calendar-subject-semester-head">
<strong>${escapeHtml(semesterOptionLabel(semesterNumber))}</strong>
<span>${semesterRows.length} дисциплин</span>
</div>
${semesterRows.length
? `<div class="calendar-subject-chips">${semesterRows.map(renderSubjectChip).join('')}</div>`
: '<div class="calendar-subject-empty">Нет дисциплин</div>'}
</section>
`;
}).join('');
}
function renderSubjectChip(row) {
return `
<span class="calendar-subject-chip">
<span>${escapeHtml(subjectRowLabel(row))}</span>
<button type="button"
class="calendar-subject-remove"
data-semester="${escapeHtml(String(row.semesterNumber))}"
data-subject="${escapeHtml(String(row.subjectId))}"
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
</span>
`;
}
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
@@ -568,8 +751,8 @@ export async function initAcademicCalendar() {
selectedCalendarDayKey = null;
selectedCalendarDays = [];
const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => `
<details class="calendar-course-block" ${index === 0 ? 'open' : ''}>
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<details class="calendar-course-block">
<summary class="calendar-course-header">
<span class="calendar-course-title">
<span class="calendar-course-chevron" aria-hidden="true"></span>
@@ -1104,6 +1287,21 @@ export async function initAcademicCalendar() {
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
? '<option value="">Выберите дисциплину</option>' + sortedSubjects.map(subject =>
`<option value="${subject.id}">${escapeHtml(subjectLabel(subject))}</option>`
).join('')
: '<option value="">Нет дисциплин</option>';
if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) {
subjectSelect.value = current;
}
syncSelects(subjectSelect);
}
function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
@@ -1121,6 +1319,41 @@ export async function initAcademicCalendar() {
syncSelects(editorCalendarSelect);
}
function populateSubjectCalendarSelect() {
if (!subjectCalendarSelect) return;
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
subjectCalendarSelect.value = current;
}
populateSubjectSemesterSelect();
syncSelects(subjectCalendarSelect);
}
function populateSubjectSemesterSelect() {
if (!subjectSemesterSelect) return;
const calendar = selectedSubjectCalendar();
const current = subjectSemesterSelect.value;
if (!calendar) {
subjectSemesterSelect.innerHTML = '<option value="">Выберите график</option>';
syncSelects(subjectSemesterSelect);
return;
}
const semesterCount = calendarSemesterCount(calendar);
subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
const semesterNumber = index + 1;
return `<option value="${semesterNumber}">${escapeHtml(semesterOptionLabel(semesterNumber))}</option>`;
}).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 = '<option value="">Все курсы</option>' +
@@ -1145,6 +1378,38 @@ export async function initAcademicCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
}
function selectedSubjectCalendar() {
return calendars.find(calendar => String(calendar.id) === 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 || '';
}
@@ -1265,7 +1530,16 @@ export async function initAcademicCalendar() {
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 => {
if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu();
}
});
}
}

View File

@@ -21,8 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-container">
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span>
</label>
`).join('');

View File

@@ -14,6 +14,12 @@ export async function initDepartmentWorkspace() {
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');
@@ -25,6 +31,8 @@ export async function initDepartmentWorkspace() {
const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
@@ -34,6 +42,8 @@ export async function initDepartmentWorkspace() {
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');
@@ -61,6 +71,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace();
} catch (error) {
@@ -74,7 +85,9 @@ export async function initDepartmentWorkspace() {
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
}
async function loadSubjects() {
@@ -165,6 +178,68 @@ export async function initDepartmentWorkspace() {
}
}
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 || 'дисциплина'}`;
@@ -215,14 +290,46 @@ export async function initDepartmentWorkspace() {
lastTeachers = null;
teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers();
renderTeacherSelect();
} catch (error) {
lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
}
}
@@ -319,6 +426,49 @@ export async function initDepartmentWorkspace() {
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
@@ -366,6 +516,30 @@ function subjectCountLabel(count) {
return `${count} дисциплин`;
}
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">

View File

@@ -10,6 +10,8 @@ async function fetchEducationForms() {
}
}
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
@@ -17,7 +19,10 @@ export async function initGroups() {
const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfSelect = document.getElementById('filter-ef');
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 modalEditGroup = document.getElementById('modal-edit-group');
@@ -36,8 +41,8 @@ export async function initGroups() {
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
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');
@@ -61,12 +66,14 @@ export async function initGroups() {
let academicYears = [];
let calendars = [];
let currentAssignments = [];
let activeSubgroupGroup = null;
bindEvents();
await loadInitialData();
function bindEvents() {
filterEfSelect.addEventListener('change', applyGroupFilter);
initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
@@ -75,6 +82,7 @@ export async function initGroups() {
// Модалка подгрупп
manageSubgroupForm.addEventListener('submit', saveSubgroup);
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
@@ -101,6 +109,37 @@ export async function initGroups() {
});
}
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, calendars] = await Promise.all([
@@ -148,13 +187,63 @@ export async function initGroups() {
}
function applyGroupFilter() {
const filterId = filterEfSelect.value;
const filtered = filterId
? allGroups.filter(group => group.educationFormId == filterId)
const selectedIds = selectedFilterEducationFormIds();
const filtered = selectedIds.length
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups;
renderGroups(filtered);
}
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) {
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
@@ -188,11 +277,30 @@ export async function initGroups() {
function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms);
const currentFilter = filterEfSelect.value;
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
syncSelects(filterEfSelect);
renderFilterEfCheckboxes(forms);
}
function renderFilterEfCheckboxes(forms) {
const selectedIds = new Set(selectedFilterEducationFormIds());
const allChecked = !selectedIds.size;
filterEfCheckboxes.innerHTML = `
<label class="checkbox-item">
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
<span class="checkmark"></span>
<span>Все</span>
</label>
${forms.map(form => {
const id = String(form.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(form.name)}</span>
</label>
`;
}).join('')}
`;
updateFilterEfText();
}
function populateEfSelect(select, forms) {
@@ -411,6 +519,7 @@ export async function initGroups() {
// --- Управление подгруппами ---
function openSubgroupsModal(group) {
activeSubgroupGroup = group;
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id;
resetSubgroupForm();
@@ -424,13 +533,19 @@ export async function initGroups() {
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr>
<td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td>
</tr>
`).join('');
@@ -438,46 +553,157 @@ export async function initGroups() {
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
manageSubgroupNameInput.value = '';
manageSubgroupCapacityInput.value = '';
subgroupFormTitle.textContent = 'Создание подгруппы';
btnSaveSubgroup.textContent = 'Создать';
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.style.display = 'none';
hideAlert('manage-subgroup-alert');
}
function currentGroupSubgroups(groupId) {
return subgroups
.filter(sub => String(sub.groupId) === String(groupId))
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
}
function naturalSubgroupOrder(name) {
const number = String(name || '').match(/\d+/)?.[0];
return number ? String(number).padStart(3, '0') : String(name || '');
}
function renderSubgroupCapacityFields(count, capacities = []) {
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
if (!count) {
manageSubgroupCapacities.innerHTML = '';
return;
}
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
const suggested = suggestedSubgroupCapacities(groupSize, count);
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
const value = capacities[index] ?? suggested[index] ?? '';
return `
<div class="form-group subgroup-capacity-field">
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
</div>
`;
}).join('');
}
function suggestedSubgroupCapacities(groupSize, count) {
if (!groupSize || !count) return [];
const base = Math.floor(groupSize / count);
const remainder = groupSize % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
async function saveSubgroup(event) {
event.preventDefault();
hideAlert('manage-subgroup-alert');
const groupId = manageSubgroupGroupIdInput.value;
const subgroupId = manageSubgroupIdInput.value;
const name = manageSubgroupNameInput.value.trim();
const capacity = manageSubgroupCapacityInput.value;
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const count = Number(manageSubgroupCountSelect.value);
const existingSubgroups = currentGroupSubgroups(groupId);
if (!name || !capacity) {
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
if (!group) {
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return;
}
const payload = {
name,
studentCapacity: Number(capacity)
};
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 {
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
const saved = subgroupId
? await api.put(endpoint, payload)
: await api.post(endpoint, payload);
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm();
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups();
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();
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');
@@ -487,17 +713,19 @@ export async function initGroups() {
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return;
manageSubgroupIdInput.value = subgroup.id;
manageSubgroupNameInput.value = subgroup.name || '';
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
subgroupFormTitle.textContent = 'Редактирование подгруппы';
btnSaveSubgroup.textContent = 'Сохранить';
btnCancelSubgroupEdit.style.display = 'inline-block';
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 (!confirm('Удалить подгруппу?')) return;
try {
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
@@ -522,24 +750,25 @@ export async function initGroups() {
}
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignmentsList();
} catch (error) {
manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignmentsList() {
if (!currentAssignments.length) {
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
return;
}
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
@@ -547,6 +776,26 @@ export async function initGroups() {
`).join('');
}
function renderAssignmentSubjects(subjects) {
if (!subjects.length) {
return '<span class="muted">Не привязаны</span>';
}
const grouped = subjects.reduce((acc, subject) => {
const key = String(subject.semesterNumber || '-');
if (!acc.has(key)) acc.set(key, []);
acc.get(key).push(subject);
return acc;
}, new Map());
return `<div class="assignment-subjects">${Array.from(grouped.entries())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([semesterNumber, items]) => `
<div class="assignment-subject-semester">
<strong>${escapeHtml(semesterNumber)} семестр</strong>
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
</div>
`).join('')}</div>`;
}
async function saveAssignment(event) {
event.preventDefault();
hideAlert('manage-calendar-alert');
@@ -602,6 +851,11 @@ export async function initGroups() {
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 ? 'Активна' : 'Неактивна');

View File

@@ -87,6 +87,28 @@ export async function initSchedule() {
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 subjects = [];
@@ -104,6 +126,9 @@ export async function initSchedule() {
let visualGroupFilterTouched = false;
let visualPeriodTouched = false;
let syncingVisualPeriod = false;
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
bindEvents();
@@ -127,6 +152,19 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick);
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) {
@@ -148,7 +186,25 @@ export async function initSchedule() {
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer();
if (event.key !== 'Escape') 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) => {
@@ -271,7 +327,6 @@ export async function initSchedule() {
visualToggleButton?.classList.add('open');
visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = false;
}
@@ -282,7 +337,6 @@ export async function initSchedule() {
visualToggleButton?.classList.remove('open');
visualToggleButton?.setAttribute('aria-expanded', 'false');
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = true;
}
@@ -403,6 +457,10 @@ export async function initSchedule() {
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 || '',
@@ -500,8 +558,10 @@ export async function initSchedule() {
<button type="button"
class="schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
title="Изменить правило"
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">&#8942;</button>
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
title="Действия со слотом"
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">&#8942;</button>
</article>
`;
}
@@ -582,14 +642,197 @@ export async function initSchedule() {
function handleVisualStageClick(event) {
const editButton = event.target.closest('.schedule-visual-edit-rule');
if (!editButton) return;
const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId));
event.stopPropagation();
openVisualContextMenu(editButton);
}
function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
fillRuleForm(rule);
closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
visualContextTarget = {
ruleId: button.dataset.ruleId,
slotId: button.dataset.slotId || '',
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
function closeVisualContextMenu() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
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.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 (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) 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.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.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) {
@@ -772,20 +1015,302 @@ export async function initSchedule() {
async function saveRule(event) {
event.preventDefault();
hideAlert('schedule-rule-alert');
let payload = null;
let id = '';
try {
const payload = buildRulePayload();
const id = ruleIdInput.value;
const saved = id
? await api.put('/api/admin/schedule-rules/' + id, payload)
: await api.post('/api/admin/schedule-rules', payload);
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
resetRuleForm();
await loadRules();
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) {
resetRuleForm();
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 = `
<span>Причина:</span>
${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
`;
}
function renderConflictSlots(rule, conflictFields = []) {
const slots = rule.slots || [];
if (!conflictSlotsContainer) return;
if (!slots.length) {
conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
return;
}
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
const fixedParts = [
slot.lessonTypeName,
slot.lessonFormat,
slotSubgroupNames(slot).join(', ')
].filter(Boolean);
const groupHitClass = conflictFieldClass('group', conflictFields);
return `
<section class="schedule-conflict-slot-row" data-index="${index}">
<div class="schedule-conflict-slot-fixed ${groupHitClass}">
<span>Слот ${index + 1}</span>
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
</div>
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
</section>
`;
}).join('');
}
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),
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);

View File

@@ -0,0 +1,136 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.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');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
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 = prompt('Причина отклонения');
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);
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) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
}
}

View File

@@ -53,41 +53,26 @@ export async function initUniversityStructure() {
let allProfiles = [];
// --- Логика переключения табов ---
function deactivateTabs() {
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => {
if (tab) {
tab.classList.remove('active');
tab.style.borderBottomColor = 'transparent';
tab.style.color = 'var(--text-secondary)';
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;
}
});
[contentDepartments, contentSpecialties, contentProfiles].forEach(content => {
if (content) content.style.display = 'none';
});
}
tabDepartments.addEventListener('click', () => {
deactivateTabs();
tabDepartments.classList.add('active');
tabDepartments.style.borderBottomColor = 'var(--primary)';
tabDepartments.style.color = 'var(--text-main)';
contentDepartments.style.display = 'block';
});
tabSpecialties.addEventListener('click', () => {
deactivateTabs();
tabSpecialties.classList.add('active');
tabSpecialties.style.borderBottomColor = 'var(--primary)';
tabSpecialties.style.color = 'var(--text-main)';
contentSpecialties.style.display = 'block';
});
tabProfiles.addEventListener('click', () => {
deactivateTabs();
tabProfiles.classList.add('active');
tabProfiles.style.borderBottomColor = 'var(--primary)';
tabProfiles.style.color = 'var(--text-main)';
contentProfiles.style.display = 'block';
structureTabs.forEach(({ button }) => {
button?.addEventListener('click', () => activateStructureTab(button));
});
// --- Загрузка данных ---

View File

@@ -71,6 +71,10 @@ export async function initUsers() {
showAlert('create-alert', 'Заполните все поля', 'error');
return;
}
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
const data = await api.post('/api/users', {

View File

@@ -1,3 +1,10 @@
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
</div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card create-card">
<h2 id="academic-year-form-title">Учебный год</h2>
<form id="academic-year-form" novalidate>
@@ -152,6 +159,9 @@
</div>
</div>
</section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
<div class="card create-card">
<h2>Редактор годового графика</h2>
<div class="form-row">
@@ -191,6 +201,49 @@
<div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div>
</div>
</section>
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-subjects" data-calendar-tab-panel="subjects" role="tabpanel" hidden>
<div class="card create-card">
<div class="card-header-row">
<h2>Дисциплины графика</h2>
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-refresh">Обновить</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="calendar-subject-calendar">График</label>
<select id="calendar-subject-calendar">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-subject-semester">Семестр</label>
<select id="calendar-subject-semester">
<option value="">Выберите график</option>
</select>
</div>
<div class="form-group">
<label for="calendar-subject-id">Дисциплина</label>
<select id="calendar-subject-id">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-add">Добавить</button>
<button type="button" class="btn btn-md btn-primary" id="calendar-subject-save">Сохранить</button>
</div>
<div class="form-alert" id="calendar-subject-alert" role="alert"></div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Привязки по семестрам</h2>
<span id="calendar-subject-count" class="muted"></span>
</div>
<div id="calendar-subject-summary" class="calendar-subject-summary">
<div class="loading-row">Выберите календарный график</div>
</div>
</div>
</section>
<div class="modal-overlay" id="calendar-day-dialog">
<div class="modal-content card calendar-day-dialog-card">

View File

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

View File

@@ -49,10 +49,26 @@
<div class="card-header-row">
<h2>Все группы</h2>
<div class="filter-row">
<label for="filter-ef">Фильтр:</label>
<select id="filter-ef">
<option value="">Все формы</option>
</select>
<label for="filter-ef-box">Фильтр:</label>
<div class="custom-multi-select">
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="filter-ef-menu">
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
<label class="checkbox-item">
<input type="checkbox" value="all" checked>
<span class="checkmark"></span>
<span>Все</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="table-wrap">
@@ -82,12 +98,12 @@
<!-- Модальное окно редактирования группы -->
<div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card">
<div class="modal-content card edit-group-modal">
<h2>Редактировать группу</h2>
<button class="modal-close" id="modal-edit-group-close">&times;</button>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="form-row" style="margin-top: 1rem;">
<div class="form-row edit-group-form-row edit-group-form-row-main">
<div class="form-group">
<label for="edit-group-name">Название группы</label>
<input type="text" id="edit-group-name" required>
@@ -103,7 +119,7 @@
</select>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-row edit-group-form-row edit-group-form-row-details">
<div class="form-group">
<label for="edit-group-department">Кафедра</label>
<select id="edit-group-department">
@@ -126,8 +142,8 @@
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
<div class="form-group edit-group-submit">
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-group-alert" role="alert"></div>
@@ -152,17 +168,18 @@
<form id="manage-subgroup-form">
<input type="hidden" id="manage-subgroup-id">
<input type="hidden" id="manage-subgroup-group-id">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-subgroup-name">Название подгруппы</label>
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required>
<div class="subgroup-config-grid">
<div class="form-group subgroup-count-field">
<label for="manage-subgroup-count">Количество подгрупп</label>
<select id="manage-subgroup-count">
<option value="0">Без разделения на подгруппы</option>
<option value="2">Две подгруппы</option>
<option value="3">Три подгруппы</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-subgroup-capacity">Численность (чел.)</label>
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
</div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Создать</button>
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
<div class="subgroup-actions">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
</div>
</div>
@@ -192,7 +209,7 @@
<!-- Модальное окно назначения Календарного графика -->
<div class="modal-overlay" id="modal-manage-calendar">
<div class="modal-content card" style="max-width: 800px; width: 90%;">
<div class="modal-content card" style="max-width: 960px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Календарные графики группы</h2>
@@ -232,12 +249,13 @@
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Дисциплины</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-calendar-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>

View File

@@ -91,6 +91,72 @@
</form>
</div>
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
</div>
<div class="schedule-conflict-rules">
<section class="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">-</strong>
<small id="schedule-conflict-new-rule-meta">-</small>
</section>
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">-</strong>
<small id="schedule-conflict-existing-rule-meta">-</small>
</section>
</div>
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
</div>
</form>
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
</div>
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="schedule-move-slot-day">День</label>
<select id="schedule-move-slot-day" required></select>
</div>
<div class="form-group">
<label for="schedule-move-slot-time">Пара</label>
<select id="schedule-move-slot-time" required></select>
</div>
</div>
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Правила динамического расписания</h2>

View File

@@ -0,0 +1,28 @@
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<div class="teacher-request-toolbar">
<span class="muted" id="teacher-requests-count">0 заявок</span>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>

View File

@@ -1,13 +1,13 @@
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
<div class="tab-container">
<div class="tabs-header-buttons" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<button class="btn-tab active" id="btn-tab-departments" style="background: none; border: none; border-bottom: 2px solid var(--primary); color: var(--text-main); font-weight: 600; padding: 0.5rem 1rem; cursor: pointer;">Кафедры</button>
<button class="btn-tab" id="btn-tab-specialties" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Специальности</button>
<button class="btn-tab" id="btn-tab-profiles" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Профили</button>
<div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
<button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
<button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</button>
<button type="button" class="university-structure-tab" id="btn-tab-profiles" role="tab" aria-selected="false" aria-controls="tab-content-profiles">Профили</button>
</div>
<!-- Раздел Кафедр -->
<div id="tab-content-departments" class="tab-content-section">
<div id="tab-content-departments" class="university-structure-tab-panel active" role="tabpanel" aria-labelledby="btn-tab-departments">
<div class="card create-card">
<h2>Создание кафедры</h2>
<form id="create-department-form">
@@ -51,7 +51,7 @@
</div>
<!-- Раздел Специальностей -->
<div id="tab-content-specialties" class="tab-content-section" style="display: none;">
<div id="tab-content-specialties" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-specialties" hidden>
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
@@ -90,11 +90,12 @@
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Раздел Профилей -->
<div id="tab-content-profiles" class="tab-content-section" style="display: none;">
<div id="tab-content-profiles" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-profiles" hidden>
<div class="card create-card">
<h2 id="main-profile-form-title">Создание профиля</h2>
<form id="create-profile-tab-form">

View File

@@ -9,7 +9,7 @@
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="password" required>
<input type="text" id="new-password" placeholder="Минимум 8 символов" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>