diff --git a/DEVOPS.md b/DEVOPS.md new file mode 100644 index 0000000..0a35470 --- /dev/null +++ b/DEVOPS.md @@ -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
PostgreSQL 16)] + Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU
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-` и `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. + +Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр». diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 68dfc2d..0000000 --- a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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`. diff --git a/analysis_results.md b/analysis_results.md deleted file mode 100644 index 44c82ff..0000000 --- a/analysis_results.md +++ /dev/null @@ -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. Структура вуза
Кафедры, Специальности, Профили] - GRP[7. Группы и подгруппы] - SUBJ[8. Дисциплины] - end - - subgraph Модуль: Ресурсы - CLASS[9. Аудиторный фонд
Аудитории + Оборудование] - 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. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса. diff --git a/audit-buttons-01-login.png b/audit-buttons-01-login.png deleted file mode 100644 index 0126525..0000000 Binary files a/audit-buttons-01-login.png and /dev/null differ diff --git a/audit-buttons-02-dashboard.png b/audit-buttons-02-dashboard.png deleted file mode 100644 index 37379d0..0000000 Binary files a/audit-buttons-02-dashboard.png and /dev/null differ diff --git a/audit-buttons-03-users-form.png b/audit-buttons-03-users-form.png deleted file mode 100644 index 3908c5b..0000000 Binary files a/audit-buttons-03-users-form.png and /dev/null differ diff --git a/audit-buttons-04-settings-menu.png b/audit-buttons-04-settings-menu.png deleted file mode 100644 index 4be70fa..0000000 Binary files a/audit-buttons-04-settings-menu.png and /dev/null differ diff --git a/audit-buttons-05-settings-general.png b/audit-buttons-05-settings-general.png deleted file mode 100644 index 56270c9..0000000 Binary files a/audit-buttons-05-settings-general.png and /dev/null differ diff --git a/audit-buttons-06-teacher.png b/audit-buttons-06-teacher.png deleted file mode 100644 index 2715515..0000000 Binary files a/audit-buttons-06-teacher.png and /dev/null differ diff --git a/audit-buttons-07-student.png b/audit-buttons-07-student.png deleted file mode 100644 index a67b4a2..0000000 Binary files a/audit-buttons-07-student.png and /dev/null differ diff --git a/audit-buttons-08-modal.png b/audit-buttons-08-modal.png deleted file mode 100644 index 91c9241..0000000 Binary files a/audit-buttons-08-modal.png and /dev/null differ diff --git a/audit-buttons-calendar.png b/audit-buttons-calendar.png deleted file mode 100644 index 7e7860b..0000000 Binary files a/audit-buttons-calendar.png and /dev/null differ diff --git a/audit-buttons-classrooms.png b/audit-buttons-classrooms.png deleted file mode 100644 index bdaaa0e..0000000 Binary files a/audit-buttons-classrooms.png and /dev/null differ diff --git a/audit-buttons-groups.png b/audit-buttons-groups.png deleted file mode 100644 index c77a12f..0000000 Binary files a/audit-buttons-groups.png and /dev/null differ diff --git a/audit-buttons-rules.png b/audit-buttons-rules.png deleted file mode 100644 index 39627fc..0000000 Binary files a/audit-buttons-rules.png and /dev/null differ diff --git a/audit-buttons-schedule-view.png b/audit-buttons-schedule-view.png deleted file mode 100644 index a76f7e3..0000000 Binary files a/audit-buttons-schedule-view.png and /dev/null differ diff --git a/audit-buttons-settings-database.png b/audit-buttons-settings-database.png deleted file mode 100644 index b82fcd4..0000000 Binary files a/audit-buttons-settings-database.png and /dev/null differ diff --git a/audit-buttons-settings-study-forms.png b/audit-buttons-settings-study-forms.png deleted file mode 100644 index b5af080..0000000 Binary files a/audit-buttons-settings-study-forms.png and /dev/null differ diff --git a/audit-buttons-settings-time-slots.png b/audit-buttons-settings-time-slots.png deleted file mode 100644 index 79c6eff..0000000 Binary files a/audit-buttons-settings-time-slots.png and /dev/null differ diff --git a/audit-buttons-structure.png b/audit-buttons-structure.png deleted file mode 100644 index 956c41d..0000000 Binary files a/audit-buttons-structure.png and /dev/null differ diff --git a/audit-buttons-subjects.png b/audit-buttons-subjects.png deleted file mode 100644 index a7b4e42..0000000 Binary files a/audit-buttons-subjects.png and /dev/null differ diff --git a/audit-buttons-users.png b/audit-buttons-users.png deleted file mode 100644 index b180fc4..0000000 Binary files a/audit-buttons-users.png and /dev/null differ diff --git a/audit-buttons-workload.png b/audit-buttons-workload.png deleted file mode 100644 index 658b076..0000000 Binary files a/audit-buttons-workload.png and /dev/null differ diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 478b952..97d624f 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -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 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 rows) { + if (rows.size() > 1000) { + throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); + } + + int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2); + Set subjectIds = new HashSet<>(); + Set 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 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 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() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index d8ac277..0d7d9d5 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -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 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 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 toAssignmentMap(TeacherDepartmentAssignment assignment) { + Map 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; } diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 25c4f4f..6650638 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -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 assignments = assignmentRepository.findByGroupIdWithDetails(id); + Map> 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 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> loadCalendarSubjectsByCalendar( + List assignments) { + Set 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> subjectsByCalendarId) { + return toAssignmentDto( + assignment, + subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of()) + ); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment, + List 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() ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index b0624b4..346a7e4 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 newSlots, Long excludeRuleId) { + Map> 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 newSlots, + Map> newActiveWeeks, + Collection existingSlots) { + Map> existingActiveWeeks = activeWeeksBySlot(existingSlots); + LinkedHashSet 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 conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) { + LinkedHashSet 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 firstGroupIds = groupIds(first); + Set secondGroupIds = groupIds(second); + firstGroupIds.retainAll(secondGroupIds); + + for (Long groupId : firstGroupIds) { + if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) { + return true; + } + Set firstSubgroupIds = subgroupIdsForGroup(first, groupId); + Set secondSubgroupIds = subgroupIdsForGroup(second, groupId); + firstSubgroupIds.retainAll(secondSubgroupIds); + if (!firstSubgroupIds.isEmpty()) { + return true; + } + } + return false; + } + + private Set groupIds(ScheduleRuleSlot slot) { + Set 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 subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) { + Set 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> activeWeeksBySlot(Collection slots) { + Map> 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 categorySlots = slots.stream() + .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) + .sorted(this::compareSlotsForGeneration) + .toList(); + if (categorySlots.isEmpty()) { + continue; + } + + Map 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 first, Set second) { + if (first == null || second == null || first.isEmpty() || second.isEmpty()) { + return false; + } + Set 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 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 fields) { + } } diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java index 498821b..66e75e1 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -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; } diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java new file mode 100644 index 0000000..c6b0289 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherCreationRequestController.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index dc088d7..9c2ae1a 100755 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -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 users = userRepository.findByRoleAndDepartmentIdAndStatusNot( - Role.TEACHER, - departmentId, - LifecycleEntity.STATUS_ARCHIVED - ); + List 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 teachersForDepartment(Long departmentId, LocalDate date) { + Map teachersById = new LinkedHashMap<>(); + List 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 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())); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java new file mode 100644 index 0000000..1fa817c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarSubjectDto.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java new file mode 100644 index 0000000..2c646f2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CreateTeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java index a949840..d9a239b 100644 --- a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java @@ -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 subjects ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java new file mode 100644 index 0000000..acc35e6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ReviewTeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java new file mode 100644 index 0000000..f6d921a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherCreationRequestResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java new file mode 100644 index 0000000..76fefe9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarSubject.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java new file mode 100644 index 0000000..3f4cda5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java new file mode 100644 index 0000000..764b145 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherCreationRequestStatus.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum TeacherCreationRequestStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java new file mode 100644 index 0000000..3b40083 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarSubjectRepository.java @@ -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 { + + @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 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 findByCalendarIdInWithSubject(@Param("calendarIds") Collection calendarIds); + + void deleteByAcademicCalendar_Id(Long calendarId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java index 49715e1..ec2a34e 100644 --- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository { 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); } diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java new file mode 100644 index 0000000..44dfaec --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherCreationRequestRepository.java @@ -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 { + + List findByDepartment_IdOrderByCreatedAtDesc(Long departmentId); + + List findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status); + + List findAllByOrderByCreatedAtDesc(); + + boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java index 8caaf02..be94f19 100644 --- a/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherDepartmentAssignmentRepository.java @@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository= :date) order by a.teacher.fullName @@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository 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 + ); } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 5332257..a0fc2fe 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 назначенного календарного графика'; diff --git a/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql b/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql new file mode 100644 index 0000000..9f5959d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql @@ -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'; diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java index 9f281e9..2d41912 100644 --- a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -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 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(); diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java index b751e4e..5c39d3d 100644 --- a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java @@ -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)) diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index 9225edb..7e3cb74 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -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` сразу использует новые правила календаря. diff --git a/docs/API.md b/docs/API.md index faa2506..fb4570d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 + } + ] +} +``` + Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. --- diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 88722a7..b9bcb54 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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 мог предложить перенос этого правила. ### Точечные изменения расписания diff --git a/docs/BUTTON_STYLING_PLAN.md b/docs/BUTTON_STYLING_PLAN.md deleted file mode 100644 index 68db2c9..0000000 --- a/docs/BUTTON_STYLING_PLAN.md +++ /dev/null @@ -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) | diff --git a/docs/DATABASE.md b/docs/DATABASE.md index bc3f180..146d48d 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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(1–8) | Количество курсов в сетке | | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления | @@ -623,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели 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-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d8f54d5..a793505 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 444e36c..9cb01b8 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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); } diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index 57bbc88..207a7d1 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -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; @@ -298,6 +331,16 @@ padding: 0.75rem 0; 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; @@ -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; diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index 2ba19c1..aee706c 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -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; +} diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 6327494..32d95df 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -66,6 +66,17 @@ Дашборд + + + + + + + + + Заявки + + -

Загрузка...

+
+

Загрузка...

+ +
diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 0b679db..ff34b4c 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -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); } } } diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 4fda5d3..bff3cf5 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -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); diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 9c8eede..2116d6c 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -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 = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; @@ -402,6 +429,7 @@ export async function initAcademicCalendar() { + @@ -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 = '
Загрузка...
'; + try { + const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); + calendarSubjectRows = sortCalendarSubjectRows(rows || []); + renderCalendarSubjects(); + } catch (error) { + subjectSummary.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; + 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 = '
Выберите календарный график
'; + return; + } + + const semesterCount = calendarSemesterCount(calendar); + subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`; + subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => { + const semesterNumber = index + 1; + const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber); + return ` +
+
+ ${escapeHtml(semesterOptionLabel(semesterNumber))} + ${semesterRows.length} дисциплин +
+ ${semesterRows.length + ? `
${semesterRows.map(renderSubjectChip).join('')}
` + : '
Нет дисциплин
'} +
+ `; + }).join(''); + } + + function renderSubjectChip(row) { + return ` + + ${escapeHtml(subjectRowLabel(row))} + + + `; + } + async function loadCalendarGrid() { 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) => ` -
+ calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` +
@@ -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 + ? '' + sortedSubjects.map(subject => + `` + ).join('') + : ''; + if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) { + subjectSelect.value = current; + } + syncSelects(subjectSelect); + } + function populateActivitySelects() { const activityOptions = '' + activityTypes.map(type => ``).join(''); @@ -1121,6 +1319,41 @@ export async function initAcademicCalendar() { syncSelects(editorCalendarSelect); } + function populateSubjectCalendarSelect() { + if (!subjectCalendarSelect) return; + const current = subjectCalendarSelect.value; + subjectCalendarSelect.innerHTML = calendars.length + ? '' + calendars.map(calendar => + `` + ).join('') + : ''; + 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 = ''; + syncSelects(subjectSemesterSelect); + return; + } + const semesterCount = calendarSemesterCount(calendar); + subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => { + const semesterNumber = index + 1; + return ``; + }).join(''); + if (current && Number(current) >= 1 && Number(current) <= semesterCount) { + subjectSemesterSelect.value = current; + } + syncSelects(subjectSemesterSelect); + } + function populateFillCourseSelect() { const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); fillCourseSelect.innerHTML = '' + @@ -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(); + } + }); + } } diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index 681cf93..e426580 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -21,8 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select } container.innerHTML = equipments.map(eq => ` -