Compare commits
19 Commits
dynamic_sc
...
db0218f4ca
| Author | SHA1 | Date | |
|---|---|---|---|
| db0218f4ca | |||
| 9e6e0038c6 | |||
| 6a03931190 | |||
|
|
3d50b6b006 | ||
|
|
53298d9d07 | ||
| 776728cf83 | |||
|
|
08b8607f05 | ||
| 6d9f6b7a42 | |||
| 4e1985c28e | |||
|
|
a966648a7e | ||
|
|
f3273f6972 | ||
|
|
4adce0b89c | ||
| 43d72c5235 | |||
|
|
4b05d4287c | ||
|
|
5a81a911ff | ||
| 57e55298d5 | |||
| da7c90fa6a | |||
| 3217586657 | |||
|
|
30972db9ee |
278
DEVOPS.md
Normal file
278
DEVOPS.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# РАЗРАБОТКА И ВНЕДРЕНИЕ ОТКАЗОУСТОЙЧИВОЙ МУЛЬТИТЕНАНТНОЙ ИНФРАСТРУКТУРЫ ДЛЯ СИСТЕМЫ УПРАВЛЕНИЯ УНИВЕРСИТЕТСКИМ РАСПИСАНИЕМ «МАГИСТР»
|
||||
|
||||
**Диссертационное исследование и отчет о проделанной инженерной работе в качестве DevOps-архитектора проекта**
|
||||
|
||||
---
|
||||
|
||||
## ВВЕДЕНИЕ
|
||||
|
||||
Современные информационные системы для образовательных учреждений требуют строгого соблюдения требований к безопасности, изоляции данных различных организаций (университетов), гибкости масштабирования и высокой наблюдаемости (observability). В рамках разработки проекта **«Магистр»** — системы управления расписанием учебных занятий — передо мной встала задача проектирования и построения инфраструктуры, способной обслуживать десятки университетов в рамках единого прикладного контура при условии жесткой изоляции их баз данных.
|
||||
|
||||
В качестве DevOps-инженера проекта я спроектировал и реализовал архитектурное решение, объединяющее технологии аппаратной виртуализации, оркестрации контейнеров, автоматизации конфигурации, распределенного мониторинга и непрерывной интеграции. В данном документе подробно описаны теоретические предпосылки, практическая реализация и архитектурные решения, внедренные мной в продакшн-окружение проекта.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 1. СИСТЕМНАЯ АРХИТЕКТУРА И КОНЦЕПЦИЯ МУЛЬТИТЕНАНТНОСТИ
|
||||
|
||||
### 1.1 Выбор паттерна изоляции данных
|
||||
При проектировании мультитенантных (multi-tenant) систем классически выделяют три подхода к организации баз данных:
|
||||
1. **Shared Database & Shared Schema**: Все клиенты используют общие таблицы, записи разделяются по полю `tenant_id`. Подход дешев в обслуживании, но несет колоссальные риски утечки данных из-за ошибок в SQL-запросах приложения и не позволяет разграничивать физический доступ к базам.
|
||||
2. **Shared Database & Separate Schemas**: Одна СУБД, но разные логические схемы для каждого клиента. Обеспечивает умеренную изоляцию, но сохраняет единую точку отказа и общие аппаратные ресурсы СУБД.
|
||||
3. **Database-per-Tenant (Выбранный подход)**: Каждый клиент (университет) владеет собственной физически изолированной базой данных, расположенной на выделенном сервере или виртуальной машине.
|
||||
|
||||
Для проекта «Магистр» мной был выбран и реализован паттерн **Database-per-Tenant**. Это обусловлено спецификой клиентов (высшие учебные заведения), которые согласно законодательству (например, ФЗ-152 «О персональных данных») обязаны хранить свои данные локально или требовать полной изоляции конфиденциальной информации. Кроме того, данный подход позволяет:
|
||||
* Исключить влияние высокой нагрузки одного университета (например, в период составления сессий) на доступность системы для других вузов («синдром шумного соседа»).
|
||||
* Выполнять индивидуальное резервное копирование и обслуживание БД каждого университета без остановки всей платформы.
|
||||
* Переносить базы данных вузов на их собственные физические серверы в локальных сетях (on-premise), сохраняя при этом работу приложения в центральном облаке.
|
||||
|
||||
### 1.2 Архитектурная схема движения трафика
|
||||
Ниже представлена разработанная мной схема прохождения сетевых запросов и агрегации телеметрии в системе:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[Пользовательский браузер] -->|HTTPS: tenant.zuev.company| Caddy[Caddy Reverse Proxy]
|
||||
Caddy -->|HTTP Round-Robin| Ingress[Traefik Ingress Controller]
|
||||
|
||||
subgraph K3s Cluster
|
||||
Ingress -->|Route /*| Frontend[Frontend Pods x2 Apache]
|
||||
Ingress -->|Route /api/*| Backend[Backend Pods x1-x2 Spring Boot]
|
||||
Backend -->|K8s API: Get/Patch CM| K8sAPI[Kubernetes API Server]
|
||||
BackendConfig[ConfigMap: tenants-config] -.->|Mounted JSON| Backend
|
||||
end
|
||||
|
||||
subgraph Proxmox VE Infrastructure
|
||||
Backend -->|JDBC Connection| VM1[(VM 1: DBMS MSU<br>PostgreSQL 16)]
|
||||
Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU<br>PostgreSQL 16)]
|
||||
|
||||
VM1 -.->|Metrics OTLP/gRPC| Collector1[OTel Collector VM1]
|
||||
VM2 -.->|Metrics OTLP/gRPC| Collector2[OTel Collector VM2]
|
||||
end
|
||||
|
||||
subgraph Observability Hub
|
||||
Backend -->|Logs & Traces OTLP| SigNoz[SigNoz APM Server]
|
||||
Collector1 -->|Postgres Metrics| SigNoz
|
||||
Collector2 -->|Postgres Metrics| SigNoz
|
||||
end
|
||||
|
||||
classDef cluster fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
|
||||
classDef vm fill:#efebe9,stroke:#4e342e,stroke-width:2px;
|
||||
classDef monitor fill:#efe8ff,stroke:#512da8,stroke-width:2px;
|
||||
class K3s Cluster cluster;
|
||||
class Proxmox VE Infrastructure vm;
|
||||
class Observability Hub monitor;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 2. АППАРАТНАЯ ВИРТУАЛИЗАЦИЯ НА БАЗЕ PROMVOX VE
|
||||
|
||||
Для размещения баз данных клиентов и вспомогательных инфраструктурных служб мной был развернут гипервизор **Proxmox Virtual Environment (PVE)** на выделенных серверных мощностях.
|
||||
|
||||
### 2.1 Конфигурация виртуализации и изоляции ресурсов
|
||||
Каждая база данных университета разворачивается в выделенной виртуальной машине (KVM), что гарантирует жесткую изоляцию на уровне ядра ОС и гипервизора.
|
||||
* **Сетевой уровень**: В Proxmox настроена виртуальная коммутация (Linux Bridge `vmbr0`). Сетевые интерфейсы виртуальных машин баз данных вынесены в приватную подсеть, изолированную от внешней сети. Доступ к ним имеет только подсеть кластера Kubernetes и управляющая машина администратора.
|
||||
* **Дисковая подсистема**: Использовано локальное хранилище на базе ZFS с зеркалированием (RAID-10 на SSD-накопителях). Это обеспечивает:
|
||||
* Высокую скорость операций ввода-вывода (IOPS), критически важную для СУБД при транзакционных нагрузках.
|
||||
* Механизм мгновенных снимков (snapshots) для безопасного проведения обновлений.
|
||||
* Аппаратное сжатие данных (LZ4) без потери производительности, снижающее износ накопителей.
|
||||
* **Резервное копирование**: Интегрирован **Proxmox Backup Server (PBS)**. Каждую ночь выполняются инкрементальные бэкапы виртуальных машин с дедупликацией на уровне блоков данных, что минимизирует нагрузку на сеть и диски, позволяя восстановить любую БД на любой момент времени за последние 30 дней.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 3. ОРКЕСТРАЦИЯ КОНТЕЙНЕРНОЙ ИНФРАСТРУКТУРЫ В KUBERNETES (K3s)
|
||||
|
||||
В качестве платформы оркестрации приложений был выбран **K3s** — сертифицированный дистрибутив Kubernetes от Rancher, оптимизированный под средние и малые нагрузки, обладающий минимальными накладными расходами на RAM/CPU для Control Plane, но сохраняющий полную совместимость с upstream-спецификациями Kubernetes API.
|
||||
|
||||
### 3.1 Архитектура манифестов и структура ресурсов
|
||||
Мной была разработана декларативная структура ресурсов, развернутая в изолированном пространстве имен `magistr` (манифест `namespace.yaml`):
|
||||
|
||||
* **Frontend (`frontend.yaml`)**:
|
||||
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
|
||||
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd (`httpd:alpine`).
|
||||
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
|
||||
|
||||
* **Backend (`backend.yaml`)**:
|
||||
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
|
||||
* Для сборки образов используется multi-stage сборка Maven (JDK 17) и запуск под управлением `eclipse-temurin:17-jre-alpine`.
|
||||
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
|
||||
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
|
||||
|
||||
* **Ingress (`ingress.yaml`)**:
|
||||
* В роли Ingress-контроллера выступает стандартный для K3s **Traefik**.
|
||||
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` для тестового окружения, `n8n.zuev.company` и т.д.). Маршрутизация по путям разделяет трафик: запросы к API (`/api`) проксируются на бэкенд-сервис, а запросы к статическим ресурсам и страницам (`/`) — на фронтенд-сервис.
|
||||
|
||||
### 3.2 Реализация динамической мультитенантности через K8s API
|
||||
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
|
||||
|
||||
1. **Хранение конфигурации**:
|
||||
Список подключений к базам данных университетов хранится в JSON-формате (`tenants.json`) внутри Kubernetes `ConfigMap` с именем `tenants-config`.
|
||||
|
||||
2. **Проблема Read-Only монтирования**:
|
||||
По умолчанию Kubernetes монтирует ConfigMap как файловую систему в режиме Read-Only. Это исключало возможность для Java-приложения перезаписывать `tenants.json` при запросах от администратора на добавление нового тенанта.
|
||||
|
||||
3. **Решение с использованием `initContainer` и `emptyDir`**:
|
||||
Я спроектировал схему с временным перезаписываемым томом (`emptyDir`):
|
||||
* В спецификацию пода backend добавлен `initContainer` на базе образа `busybox`.
|
||||
* При старте пода `initContainer` монтирует ConfigMap `tenants-config` и копирует файл `tenants.json` во временный раздел `emptyDir` (путь `/config`).
|
||||
* Основной контейнер `backend` монтирует этот же `emptyDir` в режиме Read-Write.
|
||||
|
||||
4. **K8s RBAC и динамическое сохранение**:
|
||||
Чтобы предоставить бэкенду возможность сохранять изменения конфигурации обратно в кластер, мной были написаны манифесты авторизации (`rbac.yaml`):
|
||||
* Создан `ServiceAccount` `backend-sa` и назначен поду бэкенда.
|
||||
* Описана Kubernetes `Role` `backend-configmap-role`, дающая права только на операции `get` и `patch` для ресурса `configmaps` с конкретным именем `tenants-config` (принцип наименьших привилегий).
|
||||
* Создан `RoleBinding` `backend-configmap-binding`, связывающий сервис-аккаунт с этой ролью.
|
||||
|
||||
В коде бэкенда класс `ConfigMapUpdater` через Kubernetes Java Client отправляет PATCH-запрос к API-серверу при добавлении нового тенанта, обновляя ConfigMap в самом кластере.
|
||||
|
||||
5. **Репликация и синхронизация подов**:
|
||||
Для обеспечения консистентности данных в условиях работы нескольких реплик бэкенда:
|
||||
* Класс `TenantConfigWatcher` запускает фоновую задачу, которая каждые 30 секунд сверяет MD5-хеш локального файла `tenants.json` с версией из ConfigMap.
|
||||
* При обнаружении расхождения файл перезаписывается, DataSource-инстансы пересоздаются в памяти приложения на лету, исключая рассинхронизацию подов.
|
||||
* *Примечание*: В ходе тестирования для минимизации задержек и исключения эффекта "мигания" данных на время отладки количество реплик backend было ограничено до 1, с последующим переходом на полноценный StatefulSet/Shared Storage при росте количества нод.
|
||||
|
||||
### 3.3 Повышение отказоустойчивости бэкенда (Resiliency)
|
||||
При запуске бэкенда в облаке возникала проблема: если база данных одного из университетов становилась недоступной (например, из-за сетевого сбоя или регламентных работ), пул подключений HikariCP падал с ошибкой при инициализации бинов, отправляя под бэкенда в циклическую перезагрузку (`CrashLoopBackOff`), что делало недоступной систему для *всех* остальных вузов.
|
||||
|
||||
Для предотвращения этого каскадного сбоя я внедрил комплекс защитных механизмов:
|
||||
* **H2 Fallback**: В сборку (`pom.xml`) добавлена СУБД H2 in-memory. Если при старте ConfigMap пуст, Spring Boot инициализирует пустой JPA-контекст на базе H2, что позволяет приложению успешно пройти фазу запуска.
|
||||
* **HikariCP Resiliency**: В конфигурации источников данных задано свойство `setInitializationFailTimeout(-1)`. Это заставляет HikariCP игнорировать отсутствие связи с целевой БД при старте приложения, продолжая попытки подключения в фоновом режиме.
|
||||
* **Явное указание диалекта Hibernate**: Принудительно задан `org.hibernate.dialect.PostgreSQLDialect` в `TenantDataSourceConfig.java`. Это избавило Hibernate от необходимости выполнять тестовый запрос к БД для автоматического определения диалекта на ранних этапах инициализации контекста.
|
||||
* **Автоматическое создание таблиц (`DataInitializer`)**: Для новых БД вузов полностью автоматизирован процесс наката структуры. При обнаружении новой базы Java-код проверяет наличие таблицы `users` и, в случае ее отсутствия, самостоятельно применяет SQL-скрипт инициализации (`init.sql`), включая создание дефолтных справочников и учетной записи суперадминистратора.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 4. АВТОМАТИЗАЦИЯ КОНФИГУРАЦИИ СУБД С ПОМОЩЬЮ ANSIBLE
|
||||
|
||||
Для развертывания и администрирования баз данных университетов на удаленных виртуальных машинах Proxmox мной был спроектирован и реализован комплексный плейбук **Ansible** с модульной структурой ролей.
|
||||
|
||||
### 4.1 Структура и функционал Ansible-ролей
|
||||
Вся конфигурация целевого сервера разбита на 6 последовательных этапов (плейбук `site.yml`):
|
||||
|
||||
1. **`common` (Базовая подготовка и Hardening ОС)**:
|
||||
* Создание системного пользователя `deploy` с беспарольным доступом к `sudo` (через валидацию файла `/etc/sudoers.d/deploy` утилитой `visudo`).
|
||||
* Установка системных утилит (`curl`, `git`, `htop`, `chrony` для синхронизации времени по NTP).
|
||||
* **SSH Hardening**: Изменение стандартного SSH-порта на `2222`, отключение авторизации по паролям, запрет входа для пользователя `root`, ограничение таймаута сессий.
|
||||
* Адаптация под различные дистрибутивы ОС: написаны отдельные сценарии для семейств Debian/Ubuntu/Astra Linux (пакетный менеджер `apt`), RedHat/РЕД ОС (`dnf`) и ALT Linux (кастомная обертка для `apt-get`).
|
||||
|
||||
2. **`docker` (Среда контейнеризации)**:
|
||||
* Автоматическое добавление официальных GPG-ключей и репозиториев Docker CE.
|
||||
* Установка Docker Engine, CLI и Compose плагина.
|
||||
* Включение системной службы `docker` и добавление пользователя `deploy` в группу `docker` для беспарольного запуска контейнеров.
|
||||
|
||||
3. **`firewall` (Сетевая безопасность)**:
|
||||
* Настройка межсетевого экрана UFW (для Debian-based систем) или firewalld (для RedHat и ALT Linux).
|
||||
* Полная блокировка входящего трафика по умолчанию.
|
||||
* Разрешение входящих пакетов только на порт SSH (`2222`) и порт PostgreSQL (`5432`). Реализована поддержка ограничения доступа к порту `5432` только для IP-адресов нод Kubernetes-кластера (`firewall_postgresql_allowed_ips`).
|
||||
|
||||
4. **`postgresql` (Контейнеризированная СУБД)**:
|
||||
* Генерация файлов конфигурации `docker-compose.yml` и `.env` на основе шаблонов Jinja2.
|
||||
* Развертывание PostgreSQL 16 на базе легковесного Alpine-образа.
|
||||
* Физическое монтирование каталога данных БД с хост-системы (`/opt/magistr/postgres/data`), предотвращающее потерю данных при пересоздании контейнера.
|
||||
* Ограничение системных ресурсов контейнера (limits: memory: 512M) и тюнинг параметров PostgreSQL (`shared_buffers=256MB`, оптимизация пула соединений, логирование медленных запросов `log_min_duration_statement=1000`).
|
||||
|
||||
5. **`backup` (Резервное копирование на уровне БД)**:
|
||||
* Установка на хост автоматического bash-скрипта бэкапа.
|
||||
* Настройка задачи `cron` (запуск ежедневно в 03:00).
|
||||
* Скрипт осуществляет горячий дамп базы через `docker exec pg_dump`, архивирует его с помощью `gzip` и сохраняет в локальный каталог `/opt/magistr/backups/`.
|
||||
* Реализован алгоритм автоматической ротации: удаление архивных файлов старше заданного количества дней (по умолчанию 14 дней).
|
||||
|
||||
6. **`monitoring` (Сбор телеметрии хоста и БД)**:
|
||||
* Развертывание контейнера OpenTelemetry Collector на целевом сервере БД.
|
||||
* Конфигурирование коллектора на сбор метрик производительности PostgreSQL и ОС (процессор, память, диски) и их отправку на единый сервер SigNoz по OTLP/HTTP.
|
||||
|
||||
### 4.2 Управление секретами: Ansible Vault
|
||||
Для исключения попадания паролей администраторов БД в систему контроля версий (Git) все чувствительные переменные (например, `vault_postgresql_password`) вынесены в файл `group_vars/databases/vault.yml` и зашифрованы алгоритмом AES-256 с помощью **Ansible Vault**.
|
||||
|
||||
Для бесшовной интеграции в CI/CD пароль расшифрования считывается из локального файла `.vault_pass` на управляющей машине, доступ к которому ограничен правами `chmod 600`.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 5. НЕПРЕРЫВНАЯ ИНТЕГРАЦИЯ И ДОСТАВКА (CI/CD)
|
||||
|
||||
В рамках оптимизации внутренних процессов разработки и деплоя мной была развернута и настроена инфраструктура непрерывной интеграции на базе **Gitea** и **Gitea Actions**.
|
||||
|
||||
### 5.1 Автоматизация сборки (CI)
|
||||
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
|
||||
1. **Checkout**: Загрузка актуального исходного кода проекта на ранер.
|
||||
2. **Setup Buildx**: Инициализация Docker Buildx для оптимизации кэширования слоев.
|
||||
3. **Login to Registry**: Аутентификация во встроенном реестре контейнеров Gitea Container Registry (`git.zuev.company`) с использованием сервисного токена `ZUEV_TOKEN` (права `write:package`).
|
||||
4. **Build & Push**: Параллельная сборка Docker-образов для бэкенда и фронтенда с тегом `latest` и отправка их в приватный реестр.
|
||||
|
||||
### 5.2 Доставка в кластер (CD)
|
||||
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет типа `docker-registry` в пространстве имен `magistr`:
|
||||
```bash
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--docker-server=gitea.zuev.company \
|
||||
--docker-username=Zuev \
|
||||
--docker-password=${ZUEV_TOKEN} \
|
||||
--namespace=magistr
|
||||
```
|
||||
Этот секрет ассоциирован со спецификациями деплоев в `backend.yaml` и `frontend.yaml` через директиву `imagePullSecrets`. Обновление приложений в кластере после завершения сборки образов выполняется путем контролируемого перезапуска подов:
|
||||
```bash
|
||||
kubectl rollout restart deployment backend -n magistr
|
||||
kubectl rollout restart deployment frontend -n magistr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
|
||||
|
||||
Для маршрутизации внешнего трафика и защиты соединений на входе в инфраструктуру развернут веб-сервер **Caddy**.
|
||||
|
||||
### 6.1 Преимущества Caddy и управление SSL/TLS
|
||||
В отличие от классического Nginx, Caddy был выбран благодаря:
|
||||
* Встроенной интеграции с удостоверяющими центрами Let's Encrypt и ZeroSSL. При добавлении нового поддомена вуза (например, `swsu.zuev.company`) Caddy автоматически запрашивает, валидирует через DNS/HTTP-вызовы и устанавливает TLS-сертификат, а также следит за его продлением.
|
||||
* Лаконичному синтаксису конфигурации (`Caddyfile`).
|
||||
* Полноценной поддержке протокола HTTP/3 «из коробки».
|
||||
|
||||
### 6.2 Конфигурация балансировки
|
||||
Внешний Caddy-сервер принимает запросы ко всем поддоменам `*.zuev.company` и перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика нагрузки (L4/L7 Load Balancer):
|
||||
```caddyfile
|
||||
*.zuev.company {
|
||||
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
|
||||
lb_policy round_robin
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
}
|
||||
}
|
||||
```
|
||||
Сетевые запросы распределяются по нодам K3s по алгоритму Round-Robin. В случае недоступности одной из нод, Caddy временно исключает ее из пула, обеспечивая отказоустойчивость инфраструктуры на сетевом уровне.
|
||||
|
||||
---
|
||||
|
||||
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
|
||||
|
||||
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** и стандартов **OpenTelemetry (OTel)**.
|
||||
|
||||
### 7.1 Сбор телеметрии Backend
|
||||
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
|
||||
* **Трейсы (Traces)**: Сквозное прохождение HTTP-запросов через контроллеры, сервисы и JDBC-драйвер к базам данных.
|
||||
* **Метрики (Metrics)**: Метрики JVM (утилизация Heap, активность сборщика мусора GC, состояние потоков), метрики HTTP-запросов (интенсивность, латентность, коды ответов).
|
||||
* **Логи (Logs)**: Системные логи Logback экспортируются по протоколу OTLP напрямую в SigNoz.
|
||||
|
||||
### 7.2 Идентификация тенантов в мониторинге
|
||||
Для глубокого анализа производительности СУБД конкретных университетов критически важно разделять телеметрию по тенантам. Мной была реализована сквозная маркировка:
|
||||
* В бэкенде через Java-интерцептор `TenantInterceptor` при каждом запросе идентификатор тенанта помещается в контекст логирования SLF4J MDC (`MDC.put("tenant.id", tenant)`) и одновременно записывается в атрибуты текущего OpenTelemetry спана (`Span.current().setAttribute("tenant.id", tenant)`).
|
||||
* Благодаря переменной окружения `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=tenant.id`, OTel-агент автоматически парсит MDC и прикрепляет его к логам, отправляемым в SigNoz. Это позволяет администраторам фильтровать ошибки и строить графики нагрузки в разрезе каждого университета.
|
||||
|
||||
### 7.3 Мониторинг баз данных на хостах
|
||||
Для сбора детальной статистики с изолированных серверов баз данных:
|
||||
1. На хостах БД силами Ansible разворачивается OpenTelemetry Collector.
|
||||
2. В конфигурации коллектора (`otel-collector-config.yml.j2`) описывается ресивер `postgresql`, который подключается к локальной БД и считывает системные метрики (размер таблиц, cache hit ratio, количество транзакций, активные сессии, блокировки).
|
||||
3. К собираемым метрикам жестко прикрепляются ресурсные атрибуты хоста: `service.name=magistr-db-<tenant_domain>` и `university.name=<University Name>`.
|
||||
4. Метрики экспортируются в центральный коллектор SigNoz. В результате в интерфейсе SigNoz доступны кастомные дашборды JVM, PostgreSQL и HTTP, позволяющие оперативно локализовать проблемы с производительностью баз данных конкретных вузов.
|
||||
|
||||
---
|
||||
|
||||
## ЗАКЛЮЧЕНИЕ
|
||||
|
||||
Разработанная и внедренная мной DevOps-архитектура для проекта «Магистр» успешно решила задачи изоляции данных, отказоустойчивости и автоматизации администрирования.
|
||||
|
||||
### Ключевые результаты проделанной работы:
|
||||
1. **Изоляция данных**: Реализована физическая изоляция БД университетов на выделенных серверах (VM в Proxmox VE), соответствующая требованиям безопасности и законодательства.
|
||||
2. **Динамическое масштабирование**: Внедрен механизм динамического добавления тенантов без простоя системы (zero-downtime) за счет связки Spring Boot, K8s RBAC и ConfigMapWatcher.
|
||||
3. **Отказоустойчивость**: Минимизированы риски каскадных сбоев бэкенда за счет применения in-memory fallback баз данных и оптимизации параметров подключения HikariCP.
|
||||
4. **Автоматизация**: Создан универсальный Ansible-плейбук для быстрого ввода в эксплуатацию новых серверов БД (время развертывания «под ключ» сокращено до нескольких минут).
|
||||
5. **Наблюдаемость**: Реализован сквозной мониторинг логов, метрик и трассировок с детализацией по тенантам на базе стека OpenTelemetry + SigNoz.
|
||||
|
||||
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».
|
||||
@@ -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`.
|
||||
@@ -1,288 +0,0 @@
|
||||
# Анализ структуры вкладок и предложения по UX/UI оптимизации системы Magistr
|
||||
|
||||
В данном документе представлен детальный анализ текущей структуры интерфейса административной панели (SPA) и настроек системы Magistr. Выявлены пересекающиеся области функционала, сформулированы предложения по их объединению, переносу в настройки или удалению, а также предложены новые функциональные модули и технические решения для оптимизации UX/UI и производительности.
|
||||
|
||||
---
|
||||
|
||||
## 1. Анализ текущей структуры и доступности вкладок
|
||||
|
||||
На данный момент в основном SPA-интерфейсе администратора (`/admin/`) зарегистрировано **13 вкладок**, а в дополнительном SPA настроек (`/admin/settings/`) — **2 вкладки**.
|
||||
|
||||
В зависимости от роли пользователя, сайдбар фильтруется следующим образом:
|
||||
|
||||
| Вкладка (ID) | Описание | Доступность по ролям |
|
||||
| :--- | :--- | :--- |
|
||||
| `users` | CRUD пользователей и архивирование | `ADMIN` |
|
||||
| `groups` | CRUD групп, подгрупп и назначений календарей | `ADMIN` |
|
||||
| `edu-forms` | Справочник форм обучения | `ADMIN` |
|
||||
| `profiles` | Управление профилями обучения специальностей | `ADMIN` |
|
||||
| `equipments` | Каталог оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `classrooms` | Список аудиторий с привязкой оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `subjects` | CRUD всех дисциплин и привязка преподавателей | `ADMIN` |
|
||||
| `department-workspace` | Рабочая область кафедры (дисциплины, импорт, нагрузка) | `ADMIN`, `DEPARTMENT` |
|
||||
| `schedule-view` | Просмотр расписаний (read-only) | `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER` |
|
||||
| `schedule` | Конструктор правил динамического расписания | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `academic-calendar` | Календарные графики и Excel-редактор сетки | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `auditorium-workload` | Матрицы и таблицы загруженности | `ADMIN`, `EDUCATION_OFFICE` |
|
||||
| `database` | Управление тенантами (БД клиентов) | `ADMIN` |
|
||||
| **Settings** (SPA) | Общие настройки, Управление временными слотами | `ADMIN`, `EDUCATION_OFFICE` (ссылка в подвале) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Пересечения функционала и проблемные зоны
|
||||
|
||||
### 2.1. Избыточное дробление академической структуры
|
||||
Для описания структуры обучения сейчас используются четыре отдельные вкладки:
|
||||
1. `departments-data` (Создание кафедры/специальности)
|
||||
2. `profiles` (Профили обучения специальностей)
|
||||
3. `edu-forms` (Формы обучения)
|
||||
4. `groups` (В блоке создания группы выбираются форма, кафедра, специальность и профиль)
|
||||
|
||||
**В чём проблема:**
|
||||
* **Разрыв контекста:** Чтобы завести новую специальность с профилями, администратору сначала нужно пойти во вкладку `departments-data`, создать специальность, затем перейти во вкладку `profiles`, выбрать в селекте эту специальность и добавить профили.
|
||||
* **Перегрузка меню:** 13 вкладок в сайдбаре для роли `ADMIN` — это слишком много. Пользователю трудно ориентироваться визуально.
|
||||
|
||||
### 2.2. Дублирование и раздвоение управления дисциплинами
|
||||
Управление дисциплинами разнесено между вкладками `subjects` (для администратора) и `department-workspace` (для кафедры).
|
||||
|
||||
**В чём проблема:**
|
||||
* Администратор во вкладке `subjects` видит плоскую таблицу всех дисциплин всех кафедр, но не имеет удобных инструментов фильтрации по кафедре.
|
||||
* Форма создания новой дисциплины во вкладке `subjects` требует ввода **числового ID кафедры вручную** (поле `new-subject-department` типа `number`). Это критическая UX-ошибка: администратор не должен помнить числовые ID БД наизусть.
|
||||
* Привязка преподавателей к дисциплинам (`teacher_subjects`) находится на вкладке `subjects`. При этом сама нагрузка преподавателей и список преподавателей кафедры отображаются на вкладке `department-workspace`. Логичнее было бы видеть, какие дисциплины ведёт преподаватель, непосредственно при работе с его нагрузкой или в едином справочнике преподавателей/кафедры.
|
||||
|
||||
### 2.3. Низкая частота использования справочника оборудования (`equipments`)
|
||||
Оборудование существует только как подчиненная сущность для аудиторий (чтобы указать, что в аудитории 101 есть 20 ПК и 1 проектор).
|
||||
|
||||
**В чём проблема:**
|
||||
* Выделение целой вкладки под простой список («Проектор», «ПК») не оправдано. Список оборудования меняется раз в несколько лет.
|
||||
* При этом привязка оборудования к аудиториям происходит на вкладке `classrooms`.
|
||||
|
||||
### 2.4. Перегруженность вкладки групп (`groups`)
|
||||
На вкладке групп на одном экране выводятся сразу три тяжелые формы и три таблицы:
|
||||
1. Создание/редактирование групп + таблица групп.
|
||||
2. Создание подгрупп для лабораторных + таблица подгрупп.
|
||||
3. Назначение календарного графика группе + таблица назначений.
|
||||
|
||||
**В чём проблема:**
|
||||
* Экран перегружен элементами управления.
|
||||
* Для работы с подгруппами или назначениями графиков конкретной группы пользователю приходится выбирать эту группу из длинного выпадающего списка в отдельной форме, вместо того чтобы выполнять действия в контексте строки этой группы в таблице.
|
||||
|
||||
### 2.5. Нахождение системной вкладки «База данных» в общем меню расписания
|
||||
Вкладка `database` отвечает за управление тенантами (добавление новых вузов-клиентов, создание для них БД и запуск Flyway миграций).
|
||||
|
||||
**В чём проблема:**
|
||||
* Это технический функционал уровня Super-Admin / DevOps. Он не имеет отношения к ежедневному планированию расписания. Нахождение его в одном ряду с расписанием и группами нарушает логику интерфейса.
|
||||
|
||||
---
|
||||
|
||||
## 3. Рекомендации по оптимизации структуры (Что убрать, объединить и перенести)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Было (13 вкладок в меню ADMIN)
|
||||
direction TB
|
||||
T1[database]
|
||||
T2[edu-forms]
|
||||
T3[departments-data]
|
||||
T4[profiles]
|
||||
T5[equipments]
|
||||
T6[classrooms]
|
||||
T7[groups]
|
||||
T8[users]
|
||||
T9[subjects]
|
||||
T10[department-workspace]
|
||||
T11[schedule]
|
||||
T12[schedule-view]
|
||||
T13[academic-calendar]
|
||||
T14[auditorium-workload]
|
||||
end
|
||||
|
||||
subgraph Стало (Сгруппированное меню + настройки)
|
||||
direction TB
|
||||
subgraph Основное меню SPA (Группы вкладок)
|
||||
direction TB
|
||||
DASH[1. Дашборд / Главная]
|
||||
|
||||
subgraph Модуль: Расписание
|
||||
SCH_ED[2. Конструктор расписания]
|
||||
SCH_VI[3. Просмотр расписаний]
|
||||
CAL[4. Календарный график]
|
||||
WORK[5. Загруженность]
|
||||
end
|
||||
|
||||
subgraph Модуль: Структура
|
||||
UNIV[6. Структура вуза<br/>Кафедры, Специальности, Профили]
|
||||
GRP[7. Группы и подгруппы]
|
||||
SUBJ[8. Дисциплины]
|
||||
end
|
||||
|
||||
subgraph Модуль: Ресурсы
|
||||
CLASS[9. Аудиторный фонд<br/>Аудитории + Оборудование]
|
||||
end
|
||||
|
||||
USR[10. Пользователи]
|
||||
end
|
||||
|
||||
subgraph SPA Настройки (Настройки системы)
|
||||
direction TB
|
||||
SET_GEN[Общие настройки]
|
||||
SET_TIME[Временные слоты сетка]
|
||||
SET_FORM[Формы обучения из edu-forms]
|
||||
SET_DB[Базы данных / Тенанты из database]
|
||||
end
|
||||
end
|
||||
|
||||
T1 -->|Перенести| SET_DB
|
||||
T2 -->|Перенести| SET_FORM
|
||||
T3 -->|Объединить| UNIV
|
||||
T4 -->|Объединить| UNIV
|
||||
T5 -->|Объединить| CLASS
|
||||
T6 -->|Объединить| CLASS
|
||||
T7 -->|Оптимизировать| GRP
|
||||
T8 -->|Оставить| USR
|
||||
T9 -->|Оптимизировать| SUBJ
|
||||
T10 -->|Сохранить для роли Кафедра| SUBJ
|
||||
T11 -->|Оставить| SCH_ED
|
||||
T12 -->|Оставить| SCH_VI
|
||||
T13 -->|Оставить| CAL
|
||||
T14 -->|Оставить| WORK
|
||||
```
|
||||
|
||||
### 3.1. Перенос в настройки (`/admin/settings/`)
|
||||
1. **База данных (`database`)**: Перенести в SPA настроек в раздел «Управление тенантами». Доступ должен быть только у роли `ADMIN`.
|
||||
2. **Формы обучения (`edu-forms`)**: Перенести в настройки. Данный справочник заполняется один раз и не требует оперативного доступа из главного меню.
|
||||
|
||||
### 3.2. Объединение вкладок
|
||||
1. **Объединить Кафедры, Специальности и Профили** в одну вкладку **«Структура вуза»** (`university-structure`):
|
||||
* Интерфейс делится на две вкладки внутри страницы: **«Кафедры»** и **«Специальности»**.
|
||||
* Управление профилями должно происходить прямо внутри списка специальностей. Например, в строке специальности в таблице добавляется кнопка «Профили», открывающая список профилей этой специальности в раскрывающейся строке (accordion) или модальном окне.
|
||||
2. **Объединить Аудитории (`classrooms`) и Оборудование (`equipments`)** в одну вкладку **«Аудиторный фонд»**:
|
||||
* Основной экран показывает список аудиторий.
|
||||
* Рядом располагается кнопка «Управление каталогом оборудования», которая открывает модальное окно с простым списком (добавить/удалить тип оборудования). Это избавит от необходимости держать под это отдельную вкладку в сайдбаре.
|
||||
|
||||
### 3.3. Реорганизация вкладки групп (`groups`)
|
||||
* **Оставить на вкладке только список групп** с возможностью фильтрации по курсу, кафедре и форме обучения.
|
||||
* **Вынести подгруппы и назначения календарей из глобальных форм:**
|
||||
* В строке таблицы для каждой группы добавить действия:
|
||||
* `[Подгруппы]` — открывает компактное модальное окно для быстрого создания/редактирования подгрупп (лабораторных) именно для этой группы.
|
||||
* `[Календарь]` — открывает модальное окно назначения календарного учебного графика на учебные годы для этой группы.
|
||||
* Это уберет лишние тяжелые формы с главной страницы и исключит необходимость выбора группы из селектов вручную.
|
||||
|
||||
### 3.4. Оптимизация Дисциплин (`subjects`) и Кабинета кафедры
|
||||
* **Исправить форму добавления дисциплины:** Заменить ручной числовой ввод ID кафедры (`new-subject-department`) на стандартный выпадающий список (выбор из загруженных кафедр).
|
||||
* **Связать привязку преподавателей с дисциплинами:** Вместо двух разрозненных таблиц на вкладке `subjects` («Все дисциплины» и «Привязки преподавателей»), объединить их:
|
||||
* В строке дисциплины выводить список привязанных преподавателей (в виде чипов/badge).
|
||||
* По клику на кнопку «Преподаватели» в строке дисциплины открывать модальное окно привязки/отвязки преподавателей для этой конкретной дисциплины.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что необходимо добавить (Новый функционал)
|
||||
|
||||
### 4.1. Главная страница / Дашборд (`dashboard`)
|
||||
При входе в систему администратор и сотрудники учебного отдела должны видеть не сырые таблицы пользователей или расписания, а высокоуровневую аналитическую панель:
|
||||
* **Информационные карточки (Метрики):**
|
||||
* Всего активных групп / студентов.
|
||||
* Количество задействованных преподавателей.
|
||||
* Процент занятости аудиторного фонда на текущий день.
|
||||
* Количество запланированных пар на сегодня.
|
||||
* **Мониторинг расписания в реальном времени:**
|
||||
* Список идущих прямо сейчас пар (текущий временной слот).
|
||||
* Ближайшие свободные аудитории.
|
||||
* **Быстрый переход:** Кнопки быстрого создания правила расписания, добавления переноса/замены.
|
||||
|
||||
### 4.2. Центр предупреждений и конфликтов (Red Zone)
|
||||
В соответствии с планируемой бизнес-логикой (раздел 5 `docs/BUSINESS_LOGIC.md`), система должна автоматически валидировать расписание и предупреждать о коллизиях.
|
||||
Необходима отдельная вкладка или виджет на дашборде **«Конфликты расписания»**:
|
||||
* **Критические ошибки (Блокирующие генерацию):**
|
||||
* *Накладки преподавателей:* Преподаватель назначен в один и тот же временной слот в разные аудитории / к разным группам (кроме потоковых лекций).
|
||||
* *Накладки аудиторий:* Две разные пары стоят в одной аудитории в один слот.
|
||||
* **Предупреждения (Мягкие проверки):**
|
||||
* *Превышение вместимости:* Численность группы (или сумма подгрупп при совмещенных занятиях) превышает физическую вместимость аудитории.
|
||||
* *Недостаток оборудования:* Дисциплина (например, лабораторная по химии) требует определенного оборудования, которого нет в назначенной аудитории.
|
||||
* *Превышение нагрузки:* Преподаватель превысил недельный лимит часов.
|
||||
|
||||
---
|
||||
|
||||
## 5. Концепция новой структуры меню (Сайдбара)
|
||||
|
||||
Для уменьшения визуального шума сайдбар предлагается сделать **двухуровневым** (сворачиваемые группы) или четко разделить визуальными разделителями:
|
||||
|
||||
```
|
||||
[ Логотип Magistr ]
|
||||
---------------------------
|
||||
👤 Кабинет кафедры <-- (Показывается только для роли DEPARTMENT)
|
||||
📈 Дашборд <-- (Новый главный экран для ADMIN / EDU_OFFICE)
|
||||
|
||||
📁 РАСПИСАНИЕ
|
||||
🗓 Конструктор правил
|
||||
🗓 Точечные изменения <-- (Выделенный функционал оверрайдов/переносов)
|
||||
📅 Календарный график
|
||||
📊 Загруженность
|
||||
🔍 Просмотр расписаний
|
||||
|
||||
🗂 СУБЪЕКТЫ И РЕСУРСЫ
|
||||
🏫 Аудиторный фонд <-- (Объединенные Аудитории + Оборудование)
|
||||
🎓 Группы обучения <-- (Группы + модалки подгрупп/календарей)
|
||||
🏛 Структура вуза <-- (Кафедры + Специальности + Профили)
|
||||
📚 Дисциплины <-- (Дисциплины + модалки преподавателей)
|
||||
|
||||
⚙️ СИСТЕМА
|
||||
👥 Пользователи
|
||||
⚙️ Настройки <-- (Переход в настройки SPA: слоты, БД, формы обучения)
|
||||
---------------------------
|
||||
[ Профиль / Выход ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Идеи по оптимизации фронтенда и взаимодействия с API
|
||||
|
||||
### 6.1. Кэширование справочников на клиенте
|
||||
На данный момент при переключении почти каждой вкладки заново запрашиваются списки кафедр, специальностей, учебных годов и т.д.
|
||||
* **Решение:** Реализовать простой кэш-менеджер во фронтенд-клиенте (`api.js` или отдельный `cache.js`):
|
||||
```javascript
|
||||
const cache = new Map();
|
||||
export async function getCachedData(key, fetchPromise) {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, await fetchPromise);
|
||||
}
|
||||
return cache.get(key);
|
||||
}
|
||||
export function clearCache(key) {
|
||||
if (key) cache.delete(key);
|
||||
else cache.clear();
|
||||
}
|
||||
```
|
||||
Это позволит избежать десятков дублирующих запросов при переключении между вкладками `groups`, `subjects`, `schedule`.
|
||||
|
||||
### 6.2. Ленивая инициализация вкладок (Lazy Loading)
|
||||
Сейчас в `main.js` импортируются все JS-модули вкладок одновременно при загрузке страницы:
|
||||
```javascript
|
||||
import { initUsers } from './views/users.js';
|
||||
import { initGroups } from './views/groups.js';
|
||||
// ...еще 10 импортов
|
||||
```
|
||||
* **Решение:** Перейти на динамический импорт модулей инициализации при переходе на конкретную вкладку:
|
||||
```javascript
|
||||
async function switchTab(tab) {
|
||||
// ...
|
||||
const viewModule = await import(`./views/${tab}.js`);
|
||||
viewModule.init();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Это уменьшит размер начального JS-бандла и ускорит первую загрузку страницы.
|
||||
|
||||
### 6.3. Единый реестр кастомных выпадающих списков
|
||||
В проекте реализованы кастомные селекты (выпадающие списки с поддержкой поиска и чекбоксов). При динамической подгрузке HTML-шаблонов часто требуется их повторная инициализация.
|
||||
* **Решение:** Создать глобальный шину событий (Event Bus) или MutationObserver, который автоматически подхватывает элементы с классами `.custom-select` или атрибутами `data-select` и инициализирует их без вызова ручного кода `initAllCustomDropdowns()` в каждом отдельном скрипте представления.
|
||||
|
||||
---
|
||||
|
||||
## Резюме
|
||||
|
||||
Предложенные изменения позволят:
|
||||
1. Сократить количество пунктов основного меню с **13** до **8-9** логически сгруппированных разделов.
|
||||
2. Исключить ручной ввод ID (заменив на селекты) и разгрузить перегруженные интерфейсы (выносом подгрупп и назначений в контекстные модальные окна групп).
|
||||
3. Повысить отзывчивость интерфейса за счет клиентского кэширования справочников и ленивой загрузки JS-модулей.
|
||||
4. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
|
||||
@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/academic-calendars")
|
||||
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@@ -136,6 +146,35 @@ public class AcademicCalendarController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/subjects")
|
||||
public ResponseEntity<?> getSubjects(@PathVariable Long id) {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/subjects")
|
||||
@Transactional
|
||||
public ResponseEntity<?> saveSubjects(@PathVariable Long id,
|
||||
@RequestBody List<AcademicCalendarSubjectDto> rows) {
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
@@ -146,6 +185,9 @@ public class AcademicCalendarController {
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
if (request.courseCount() > 8) {
|
||||
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
|
||||
}
|
||||
if (request.academicYearId() == null || request.specialtyId() == null
|
||||
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
|
||||
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
|
||||
@@ -209,6 +251,53 @@ public class AcademicCalendarController {
|
||||
throw new IllegalArgumentException("Код активности обязателен");
|
||||
}
|
||||
|
||||
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
|
||||
if (rows.size() > 1000) {
|
||||
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
|
||||
}
|
||||
|
||||
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
|
||||
Set<Long> subjectIds = new HashSet<>();
|
||||
Set<String> uniqueRows = new HashSet<>();
|
||||
for (AcademicCalendarSubjectDto row : rows) {
|
||||
if (row == null || row.semesterNumber() == null || row.subjectId() == null) {
|
||||
throw new IllegalArgumentException("Семестр и дисциплина обязательны");
|
||||
}
|
||||
if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) {
|
||||
throw new IllegalArgumentException("Номер семестра выходит за пределы графика");
|
||||
}
|
||||
if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) {
|
||||
throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру");
|
||||
}
|
||||
subjectIds.add(row.subjectId());
|
||||
}
|
||||
|
||||
Map<Long, Subject> subjectsById = subjectRepository.findAllById(subjectIds).stream()
|
||||
.collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new));
|
||||
for (Long subjectId : subjectIds) {
|
||||
Subject subject = subjectsById.get(subjectId);
|
||||
if (subject == null) {
|
||||
throw new IllegalArgumentException("Дисциплина не найдена");
|
||||
}
|
||||
if (subject.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику");
|
||||
}
|
||||
}
|
||||
|
||||
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
|
||||
calendarSubjectRepository.flush();
|
||||
List<AcademicCalendarSubject> entities = rows.stream()
|
||||
.map(row -> {
|
||||
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
|
||||
calendarSubject.setAcademicCalendar(calendar);
|
||||
calendarSubject.setSemesterNumber(row.semesterNumber());
|
||||
calendarSubject.setSubject(subjectsById.get(row.subjectId()));
|
||||
return calendarSubject;
|
||||
})
|
||||
.toList();
|
||||
calendarSubjectRepository.saveAll(entities);
|
||||
}
|
||||
|
||||
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
|
||||
AcademicYear year = calendar.getAcademicYear();
|
||||
Speciality speciality = calendar.getSpeciality();
|
||||
@@ -247,4 +336,17 @@ public class AcademicCalendarController {
|
||||
activityType.getAllowSchedule()
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
|
||||
Subject subject = calendarSubject.getSubject();
|
||||
return new AcademicCalendarSubjectDto(
|
||||
calendarSubject.getId(),
|
||||
calendarSubject.getAcademicCalendar().getId(),
|
||||
calendarSubject.getSemesterNumber(),
|
||||
subject.getId(),
|
||||
subject.getName(),
|
||||
subject.getCode(),
|
||||
subject.getDepartmentId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.CreateTeacherCreationRequest;
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.dto.SubjectCommentDto;
|
||||
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.SubjectCommentRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -26,21 +31,29 @@ import java.util.Objects;
|
||||
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
|
||||
public class DepartmentWorkspaceController {
|
||||
|
||||
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
|
||||
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SubjectCommentRepository subjectCommentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.subjectCommentRepository = subjectCommentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
}
|
||||
|
||||
@@ -127,6 +140,7 @@ public class DepartmentWorkspaceController {
|
||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||
@@ -136,6 +150,90 @@ public class DepartmentWorkspaceController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/teachers/{teacherId}/assignments")
|
||||
@Transactional
|
||||
public ResponseEntity<?> addTeacherToDepartment(@PathVariable Long teacherId,
|
||||
@RequestBody(required = false) Map<String, Object> request) {
|
||||
Long requestedDepartmentId = asLong(request == null ? null : request.get("departmentId"));
|
||||
Long departmentId = effectiveDepartmentId(requestedDepartmentId);
|
||||
if (departmentId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(departmentId).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
User teacher = userRepository.findById(teacherId).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
||||
}
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(today);
|
||||
assignment.setPrimaryAssignment(false);
|
||||
assignment.setComment(trimToNull(request == null ? null : request.get("comment")));
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
return ResponseEntity.ok(toAssignmentMap(assignment));
|
||||
}
|
||||
|
||||
@GetMapping("/teacher-requests")
|
||||
public List<TeacherCreationRequestResponse> getTeacherRequests(@RequestParam(required = false) Long departmentId) {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
if (effectiveDepartmentId == null) {
|
||||
return teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(this::toTeacherCreationRequestResponse)
|
||||
.toList();
|
||||
}
|
||||
return teacherCreationRequestRepository.findByDepartment_IdOrderByCreatedAtDesc(effectiveDepartmentId).stream()
|
||||
.map(this::toTeacherCreationRequestResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/teacher-requests")
|
||||
public ResponseEntity<?> createTeacherRequest(@RequestBody CreateTeacherCreationRequest request) {
|
||||
if (request == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Данные заявки обязательны"));
|
||||
}
|
||||
Long departmentId = effectiveDepartmentId(request == null ? null : request.getDepartmentId());
|
||||
if (departmentId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(departmentId).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
String username = trimToNull(request.getUsername());
|
||||
String fullName = trimToNull(request.getFullName());
|
||||
String jobTitle = trimToNull(request.getJobTitle());
|
||||
if (username == null || fullName == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Логин и ФИО преподавателя обязательны"));
|
||||
}
|
||||
if (username.length() > 50 || fullName.length() > 255 || (jobTitle != null && jobTitle.length() > 255)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей заявки"));
|
||||
}
|
||||
if (userRepository.findByUsername(username).isPresent()
|
||||
|| teacherCreationRequestRepository.existsByUsernameAndStatus(username, TeacherCreationRequestStatus.PENDING)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Логин уже занят или ожидает одобрения"));
|
||||
}
|
||||
|
||||
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
||||
teacherRequest.setDepartment(department);
|
||||
teacherRequest.setUsername(username);
|
||||
teacherRequest.setFullName(fullName);
|
||||
teacherRequest.setJobTitle(jobTitle == null ? "Не указано" : jobTitle);
|
||||
teacherRequest.setComment(trimToNull(request.getComment()));
|
||||
teacherRequest.setRequestedBy(AuthContext.currentUserId());
|
||||
return ResponseEntity.ok(toTeacherCreationRequestResponse(teacherCreationRequestRepository.save(teacherRequest)));
|
||||
}
|
||||
|
||||
@GetMapping("/schedule")
|
||||
public ResponseEntity<?> getSchedule(
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@@ -172,10 +270,64 @@ public class DepartmentWorkspaceController {
|
||||
return response;
|
||||
}
|
||||
|
||||
private TeacherCreationRequestResponse toTeacherCreationRequestResponse(TeacherCreationRequest request) {
|
||||
Department department = request.getDepartment();
|
||||
return new TeacherCreationRequestResponse(
|
||||
request.getId(),
|
||||
department == null ? null : department.getId(),
|
||||
department == null ? null : department.getDepartmentName(),
|
||||
request.getUsername(),
|
||||
request.getFullName(),
|
||||
request.getJobTitle(),
|
||||
request.getComment(),
|
||||
request.getStatus().name(),
|
||||
request.getRequestedBy(),
|
||||
request.getCreatedAt(),
|
||||
request.getReviewedBy(),
|
||||
request.getReviewedAt(),
|
||||
request.getReviewComment(),
|
||||
request.getCreatedTeacherId()
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> toAssignmentMap(TeacherDepartmentAssignment assignment) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("id", assignment.getId());
|
||||
response.put("teacherId", assignment.getTeacher().getId());
|
||||
response.put("teacherName", assignment.getTeacher().getFullName());
|
||||
response.put("departmentId", assignment.getDepartment().getId());
|
||||
response.put("departmentName", assignment.getDepartment().getDepartmentName());
|
||||
response.put("validFrom", assignment.getValidFrom());
|
||||
response.put("primaryAssignment", assignment.getPrimaryAssignment());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Long asLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var user = AuthContext.getCurrentUser();
|
||||
if (user != null && user.role() == Role.DEPARTMENT) {
|
||||
return user.departmentId();
|
||||
return user.departmentId() == null ? NO_DEPARTMENT_ID : user.departmentId();
|
||||
}
|
||||
return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
@@ -24,9 +27,12 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/groups")
|
||||
@@ -41,6 +47,7 @@ public class GroupController {
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
@@ -51,6 +58,7 @@ public class GroupController {
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
@@ -60,6 +68,7 @@ public class GroupController {
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
@@ -303,8 +312,10 @@ public class GroupController {
|
||||
if (!groupRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||
.map(this::toAssignmentDto)
|
||||
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
|
||||
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
|
||||
return ResponseEntity.ok(assignments.stream()
|
||||
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -345,7 +356,13 @@ public class GroupController {
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment)));
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
|
||||
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||
.findByCalendarIdWithSubject(calendar.getId())
|
||||
.stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList();
|
||||
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
|
||||
@@ -360,7 +377,31 @@ public class GroupController {
|
||||
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
|
||||
}
|
||||
|
||||
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) {
|
||||
private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
|
||||
List<StudentGroupCalendarAssignment> assignments) {
|
||||
Set<Long> calendarIds = assignments.stream()
|
||||
.map(assignment -> assignment.getAcademicCalendar().getId())
|
||||
.collect(Collectors.toSet());
|
||||
if (calendarIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
calendarSubject -> calendarSubject.getAcademicCalendar().getId(),
|
||||
Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList())
|
||||
));
|
||||
}
|
||||
|
||||
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
|
||||
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
|
||||
return toAssignmentDto(
|
||||
assignment,
|
||||
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
|
||||
);
|
||||
}
|
||||
|
||||
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
|
||||
List<AcademicCalendarSubjectDto> subjects) {
|
||||
return new GroupCalendarAssignmentDto(
|
||||
assignment.getId(),
|
||||
assignment.getStudentGroup().getId(),
|
||||
@@ -368,7 +409,21 @@ public class GroupController {
|
||||
assignment.getAcademicYear().getId(),
|
||||
assignment.getAcademicYear().getTitle(),
|
||||
assignment.getAcademicCalendar().getId(),
|
||||
assignment.getAcademicCalendar().getTitle()
|
||||
assignment.getAcademicCalendar().getTitle(),
|
||||
subjects
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
|
||||
Subject subject = calendarSubject.getSubject();
|
||||
return new AcademicCalendarSubjectDto(
|
||||
calendarSubject.getId(),
|
||||
calendarSubject.getAcademicCalendar().getId(),
|
||||
calendarSubject.getSemesterNumber(),
|
||||
subject.getId(),
|
||||
subject.getName(),
|
||||
subject.getCode(),
|
||||
subject.getDepartmentId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@@ -19,6 +21,12 @@ import java.util.*;
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
private static final Set<ScheduleParity> VALID_SLOT_PARITIES = Set.of(
|
||||
ScheduleParity.BOTH,
|
||||
ScheduleParity.ODD,
|
||||
ScheduleParity.EVEN
|
||||
);
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
|
||||
try {
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
applyRule(rule, request);
|
||||
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
|
||||
if (conflict != null) {
|
||||
return conflictResponse(conflict);
|
||||
}
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
|
||||
try {
|
||||
ScheduleRule candidate = new ScheduleRule();
|
||||
applyRule(candidate, request);
|
||||
ScheduleRuleConflict conflict = findConflict(request, candidate.getSlots(), rule.getId());
|
||||
if (conflict != null) {
|
||||
return conflictResponse(conflict);
|
||||
}
|
||||
applyRule(rule, request);
|
||||
ScheduleRule saved = scheduleRuleRepository.save(rule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
|
||||
rule.getSlots().addAll(slots);
|
||||
}
|
||||
|
||||
private ResponseEntity<?> conflictResponse(ScheduleRuleConflict conflict) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
|
||||
"message", "Невозможно сохранить правило: слот занят",
|
||||
"conflictRule", toDto(conflict.rule()),
|
||||
"conflictFields", conflict.fields().stream().toList(),
|
||||
"conflictReasons", conflict.fields().stream().map(this::conflictFieldLabel).toList()
|
||||
));
|
||||
}
|
||||
|
||||
private String conflictFieldLabel(String field) {
|
||||
return switch (field) {
|
||||
case "teacher" -> "Преподаватель";
|
||||
case "classroom" -> "Аудитория";
|
||||
case "group" -> "Группа";
|
||||
default -> field;
|
||||
};
|
||||
}
|
||||
|
||||
private ScheduleRuleConflict findConflict(ScheduleRuleDto request, Collection<ScheduleRuleSlot> newSlots, Long excludeRuleId) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks = activeWeeksBySlot(newSlots);
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||
.filter(rule -> rule.getSemester() != null && Objects.equals(rule.getSemester().getId(), request.semesterId()))
|
||||
.map(rule -> toConflict(rule, newSlots, newActiveWeeks, rule.getSlots()))
|
||||
.filter(Objects::nonNull)
|
||||
.min(Comparator.comparing(conflict -> conflict.rule().getId()))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private ScheduleRuleConflict toConflict(ScheduleRule existingRule,
|
||||
Collection<ScheduleRuleSlot> newSlots,
|
||||
Map<ScheduleRuleSlot, Set<Integer>> newActiveWeeks,
|
||||
Collection<ScheduleRuleSlot> existingSlots) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> existingActiveWeeks = activeWeeksBySlot(existingSlots);
|
||||
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||
for (ScheduleRuleSlot newSlot : newSlots) {
|
||||
for (ScheduleRuleSlot existingSlot : existingSlots) {
|
||||
if (sameTimeSlot(newSlot, existingSlot)
|
||||
&& activeWeeksOverlap(newActiveWeeks.get(newSlot), existingActiveWeeks.get(existingSlot))) {
|
||||
fields.addAll(conflictFields(newSlot, existingSlot));
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields.isEmpty() ? null : new ScheduleRuleConflict(existingRule, fields);
|
||||
}
|
||||
|
||||
private Set<String> conflictFields(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
LinkedHashSet<String> fields = new LinkedHashSet<>();
|
||||
if (Objects.equals(teacherId(first), teacherId(second))) {
|
||||
fields.add("teacher");
|
||||
}
|
||||
if (Objects.equals(classroomId(first), classroomId(second))) {
|
||||
fields.add("classroom");
|
||||
}
|
||||
if (sameAudience(first, second)) {
|
||||
fields.add("group");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private boolean sameTimeSlot(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
return Objects.equals(first.getDayOfWeek(), second.getDayOfWeek())
|
||||
&& parityOverlaps(first.getParity(), second.getParity())
|
||||
&& Objects.equals(timeSlotId(first), timeSlotId(second));
|
||||
}
|
||||
|
||||
private Long teacherId(ScheduleRuleSlot slot) {
|
||||
return slot.getTeacher() == null ? null : slot.getTeacher().getId();
|
||||
}
|
||||
|
||||
private Long classroomId(ScheduleRuleSlot slot) {
|
||||
return slot.getClassroom() == null ? null : slot.getClassroom().getId();
|
||||
}
|
||||
|
||||
private Long timeSlotId(ScheduleRuleSlot slot) {
|
||||
return slot.getTimeSlot() == null ? null : slot.getTimeSlot().getId();
|
||||
}
|
||||
|
||||
private boolean parityOverlaps(ScheduleParity first, ScheduleParity second) {
|
||||
if (Objects.equals(first, second)) {
|
||||
return true;
|
||||
}
|
||||
return first == ScheduleParity.BOTH || second == ScheduleParity.BOTH;
|
||||
}
|
||||
|
||||
private boolean sameAudience(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
Set<Long> firstGroupIds = groupIds(first);
|
||||
Set<Long> secondGroupIds = groupIds(second);
|
||||
firstGroupIds.retainAll(secondGroupIds);
|
||||
|
||||
for (Long groupId : firstGroupIds) {
|
||||
if (slotAppliesToWholeGroup(first, groupId) || slotAppliesToWholeGroup(second, groupId)) {
|
||||
return true;
|
||||
}
|
||||
Set<Long> firstSubgroupIds = subgroupIdsForGroup(first, groupId);
|
||||
Set<Long> secondSubgroupIds = subgroupIdsForGroup(second, groupId);
|
||||
firstSubgroupIds.retainAll(secondSubgroupIds);
|
||||
if (!firstSubgroupIds.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Set<Long> groupIds(ScheduleRuleSlot slot) {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
if (slot.getScheduleRule() == null) {
|
||||
return ids;
|
||||
}
|
||||
for (StudentGroup group : slot.getScheduleRule().getGroups()) {
|
||||
ids.add(group.getId());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean slotAppliesToWholeGroup(ScheduleRuleSlot slot, Long groupId) {
|
||||
return subgroupIdsForGroup(slot, groupId).isEmpty();
|
||||
}
|
||||
|
||||
private Set<Long> subgroupIdsForGroup(ScheduleRuleSlot slot, Long groupId) {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
for (Subgroup subgroup : slot.getSubgroups()) {
|
||||
if (subgroup.getStudentGroup() != null
|
||||
&& Objects.equals(subgroup.getStudentGroup().getId(), groupId)) {
|
||||
ids.add(subgroup.getId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Map<ScheduleRuleSlot, Set<Integer>> activeWeeksBySlot(Collection<ScheduleRuleSlot> slots) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = new IdentityHashMap<>();
|
||||
slots.forEach(slot -> activeWeeks.put(slot, new HashSet<>()));
|
||||
if (slots.isEmpty()) {
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
ScheduleRule rule = slots.iterator().next().getScheduleRule();
|
||||
if (rule == null || rule.getSemester() == null) {
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
int maxWeeks = maxWeeksForRule(rule);
|
||||
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
|
||||
int totalHours = rule.academicHoursFor(category);
|
||||
if (totalHours <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ScheduleRuleSlot> categorySlots = slots.stream()
|
||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
||||
.sorted(this::compareSlotsForGeneration)
|
||||
.toList();
|
||||
if (categorySlots.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Integer> consumedHours = new HashMap<>();
|
||||
int startWeek = Math.max(1, rule.startWeekFor(category));
|
||||
for (int week = startWeek; week <= maxWeeks; week++) {
|
||||
for (ScheduleRuleSlot slot : categorySlots) {
|
||||
if (!slotAppliesToWeek(slot, week)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (String scopeKey : consumptionScopes(slot, category)) {
|
||||
int consumed = consumedHours.getOrDefault(scopeKey, 0);
|
||||
if (consumed >= totalHours) {
|
||||
continue;
|
||||
}
|
||||
activeWeeks.get(slot).add(week);
|
||||
consumedHours.put(scopeKey, consumed + ACADEMIC_HOURS_PER_SLOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
private boolean activeWeeksOverlap(Set<Integer> first, Set<Integer> second) {
|
||||
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Set<Integer> weeks = new HashSet<>(first);
|
||||
weeks.retainAll(second);
|
||||
return !weeks.isEmpty();
|
||||
}
|
||||
|
||||
private int maxWeeksForRule(ScheduleRule rule) {
|
||||
Semester semester = rule.getSemester();
|
||||
if (semester != null && semester.getStartDate() != null && semester.getEndDate() != null) {
|
||||
long days = Math.max(1, ChronoUnit.DAYS.between(semester.getStartDate(), semester.getEndDate()) + 1);
|
||||
return Math.max(1, (int) Math.ceil(days / 7.0));
|
||||
}
|
||||
return 18;
|
||||
}
|
||||
|
||||
private int compareSlotsForGeneration(ScheduleRuleSlot first, ScheduleRuleSlot second) {
|
||||
int dayCompare = Integer.compare(
|
||||
first.getDayOfWeek() == null ? 0 : first.getDayOfWeek(),
|
||||
second.getDayOfWeek() == null ? 0 : second.getDayOfWeek()
|
||||
);
|
||||
if (dayCompare != 0) {
|
||||
return dayCompare;
|
||||
}
|
||||
int orderCompare = Integer.compare(
|
||||
first.getTimeSlot() == null || first.getTimeSlot().getOrderNumber() == null ? 0 : first.getTimeSlot().getOrderNumber(),
|
||||
second.getTimeSlot() == null || second.getTimeSlot().getOrderNumber() == null ? 0 : second.getTimeSlot().getOrderNumber()
|
||||
);
|
||||
if (orderCompare != 0) {
|
||||
return orderCompare;
|
||||
}
|
||||
return Long.compare(first.getId() == null ? 0L : first.getId(), second.getId() == null ? 0L : second.getId());
|
||||
}
|
||||
|
||||
private boolean slotAppliesToWeek(ScheduleRuleSlot slot, int week) {
|
||||
ScheduleParity parity = slot.getParity();
|
||||
if (parity == null || parity == ScheduleParity.BOTH) {
|
||||
return true;
|
||||
}
|
||||
ScheduleParity weekParity = week % 2 == 0 ? ScheduleParity.EVEN : ScheduleParity.ODD;
|
||||
return parity == weekParity;
|
||||
}
|
||||
|
||||
private List<String> consumptionScopes(ScheduleRuleSlot slot, ScheduleLessonCategory category) {
|
||||
if (category == ScheduleLessonCategory.LABORATORY && !slot.getSubgroups().isEmpty()) {
|
||||
return slot.getSubgroups().stream()
|
||||
.map(subgroup -> "subgroup:" + subgroup.getId())
|
||||
.toList();
|
||||
}
|
||||
if (slot.getScheduleRule() == null || slot.getScheduleRule().getGroups().isEmpty()) {
|
||||
return List.of("rule");
|
||||
}
|
||||
return slot.getScheduleRule().getGroups().stream()
|
||||
.map(group -> "group:" + group.getId())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
||||
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
|
||||
throw new IllegalArgumentException("День недели должен быть от 1 до 7");
|
||||
}
|
||||
if (slotDto.parity() == null) {
|
||||
throw new IllegalArgumentException("Чётность недели обязательна");
|
||||
if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
|
||||
throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
|
||||
.thenComparing(Subgroup::getName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ReviewTeacherCreationRequest;
|
||||
import com.magistr.app.dto.TeacherCreationRequestResponse;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/teacher-requests")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public class TeacherCreationRequestController {
|
||||
|
||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
BCryptPasswordEncoder passwordEncoder) {
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TeacherCreationRequestResponse> getRequests(@RequestParam(required = false) TeacherCreationRequestStatus status) {
|
||||
return (status == null
|
||||
? teacherCreationRequestRepository.findAllByOrderByCreatedAtDesc()
|
||||
: teacherCreationRequestRepository.findByStatusOrderByCreatedAtDesc(status))
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@Transactional
|
||||
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {
|
||||
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
|
||||
if (request == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
|
||||
}
|
||||
if (review == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Данные одобрения обязательны"));
|
||||
}
|
||||
|
||||
String username = firstNotBlank(review.getUsername(), request.getUsername());
|
||||
String fullName = firstNotBlank(review.getFullName(), request.getFullName());
|
||||
String jobTitle = firstNotBlank(review.getJobTitle(), request.getJobTitle(), "Не указано");
|
||||
if (username.length() > 50 || fullName.length() > 255 || jobTitle.length() > 255) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Проверьте длину полей преподавателя"));
|
||||
}
|
||||
if (review.getPassword() == null || review.getPassword().length() < 8) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Пароль преподавателя должен быть не короче 8 символов"));
|
||||
}
|
||||
if (userRepository.findByUsername(username).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь с таким логином уже существует"));
|
||||
}
|
||||
|
||||
Long departmentId = review.getDepartmentId() == null ? request.getDepartment().getId() : review.getDepartmentId();
|
||||
Department department = departmentRepository.findById(departmentId).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
|
||||
User teacher = new User();
|
||||
teacher.setUsername(username);
|
||||
teacher.setPassword(passwordEncoder.encode(review.getPassword()));
|
||||
teacher.setRole(Role.TEACHER);
|
||||
teacher.setFullName(fullName);
|
||||
teacher.setJobTitle(jobTitle);
|
||||
teacher.setDepartmentId(department.getId());
|
||||
userRepository.save(teacher);
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
request.setUsername(username);
|
||||
request.setFullName(fullName);
|
||||
request.setJobTitle(jobTitle);
|
||||
request.setDepartment(department);
|
||||
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewComment(trimToNull(review.getReviewComment()));
|
||||
request.setCreatedTeacherId(teacher.getId());
|
||||
teacherCreationRequestRepository.save(request);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"request", toResponse(request),
|
||||
"teacher", toUserResponse(teacher, department)
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody(required = false) ReviewTeacherCreationRequest review) {
|
||||
TeacherCreationRequest request = teacherCreationRequestRepository.findById(id).orElse(null);
|
||||
if (request == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (request.getStatus() != TeacherCreationRequestStatus.PENDING) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Заявка уже обработана"));
|
||||
}
|
||||
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
||||
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
||||
}
|
||||
|
||||
private TeacherCreationRequestResponse toResponse(TeacherCreationRequest request) {
|
||||
Department department = request.getDepartment();
|
||||
return new TeacherCreationRequestResponse(
|
||||
request.getId(),
|
||||
department == null ? null : department.getId(),
|
||||
department == null ? null : department.getDepartmentName(),
|
||||
request.getUsername(),
|
||||
request.getFullName(),
|
||||
request.getJobTitle(),
|
||||
request.getComment(),
|
||||
request.getStatus().name(),
|
||||
request.getRequestedBy(),
|
||||
request.getCreatedAt(),
|
||||
request.getReviewedBy(),
|
||||
request.getReviewedAt(),
|
||||
request.getReviewComment(),
|
||||
request.getCreatedTeacherId()
|
||||
);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user, Department department) {
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
department.getDepartmentName()
|
||||
);
|
||||
response.setDepartmentId(department.getId());
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
|
||||
private String firstNotBlank(String... values) {
|
||||
for (String value : values) {
|
||||
String normalized = trimToNull(value);
|
||||
if (normalized != null) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@@ -75,12 +77,12 @@ public class UserController {
|
||||
@GetMapping("/teachers/{departmentId}")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||
if (!canAccessDepartment(departmentId)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
List<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||
Role.TEACHER,
|
||||
departmentId,
|
||||
LifecycleEntity.STATUS_ARCHIVED
|
||||
);
|
||||
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -96,6 +98,11 @@ public class UserController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||
if (request == null) {
|
||||
String errorMessage = "Данные пользователя обязательны";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||
|
||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||
@@ -103,8 +110,8 @@ public class UserController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
||||
String errorMessage = "Пароль минимум 4 символа";
|
||||
if (request.getPassword() == null || request.getPassword().length() < 8) {
|
||||
String errorMessage = "Пароль минимум 8 символов";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
@@ -127,6 +134,12 @@ public class UserController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
Department requestedDepartment = departmentRepository.findById(request.getDepartmentId()).orElse(null);
|
||||
if (requestedDepartment == null || requestedDepartment.isArchivedRecord()) {
|
||||
String errorMessage = "Активная кафедра не найдена";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
|
||||
Role role;
|
||||
try {
|
||||
@@ -145,17 +158,14 @@ public class UserController {
|
||||
user.setDepartmentId(request.getDepartmentId());
|
||||
userRepository.save(user);
|
||||
if (role == Role.TEACHER) {
|
||||
Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
|
||||
if (department != null) {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
}
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(requestedDepartment);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
}
|
||||
|
||||
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||
@@ -193,6 +203,13 @@ public class UserController {
|
||||
if (!userRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser != null
|
||||
&& currentUser.role() == Role.DEPARTMENT
|
||||
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
||||
}
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository.findHistoryByTeacherId(id).stream()
|
||||
.map(this::toAssignmentDto)
|
||||
.toList());
|
||||
@@ -247,12 +264,12 @@ public class UserController {
|
||||
public ResponseEntity<?> getTeachersByDepartmentAtDate(@PathVariable Long departmentId,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
if (!canAccessDepartment(departmentId)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentAssignmentRepository
|
||||
.findDepartmentTeachersAtDate(departmentId, effectiveDate)
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(User::isActiveRecord)
|
||||
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList());
|
||||
}
|
||||
@@ -289,4 +306,28 @@ public class UserController {
|
||||
assignment.getComment()
|
||||
);
|
||||
}
|
||||
|
||||
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
|
||||
if (assignments != null) {
|
||||
assignments.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
}
|
||||
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
if (directTeachers != null) {
|
||||
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
}
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
private boolean canAccessDepartment(Long departmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser == null
|
||||
|| currentUser.role() != Role.DEPARTMENT
|
||||
|| (departmentId != null && departmentId.equals(currentUser.departmentId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record GroupCalendarAssignmentDto(
|
||||
Long id,
|
||||
Long groupId,
|
||||
@@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto(
|
||||
Long academicYearId,
|
||||
String academicYearTitle,
|
||||
Long calendarId,
|
||||
String calendarTitle
|
||||
String calendarTitle,
|
||||
List<AcademicCalendarSubjectDto> subjects
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum TeacherCreationRequestStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface AcademicCalendarSubjectRepository extends JpaRepository<AcademicCalendarSubject, Long> {
|
||||
|
||||
@Query("""
|
||||
select calendarSubject
|
||||
from AcademicCalendarSubject calendarSubject
|
||||
join fetch calendarSubject.academicCalendar calendar
|
||||
join fetch calendarSubject.subject subject
|
||||
where calendar.id = :calendarId
|
||||
order by calendarSubject.semesterNumber asc, lower(subject.name) asc
|
||||
""")
|
||||
List<AcademicCalendarSubject> findByCalendarIdWithSubject(@Param("calendarId") Long calendarId);
|
||||
|
||||
@Query("""
|
||||
select calendarSubject
|
||||
from AcademicCalendarSubject calendarSubject
|
||||
join fetch calendarSubject.academicCalendar calendar
|
||||
join fetch calendarSubject.subject subject
|
||||
where calendar.id in :calendarIds
|
||||
order by calendar.id asc, calendarSubject.semesterNumber asc, lower(subject.name) asc
|
||||
""")
|
||||
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||
|
||||
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||
}
|
||||
@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
""")
|
||||
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(subgroup.studentCapacity), 0)
|
||||
from Subgroup subgroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
""")
|
||||
Long sumActiveStudentCapacityByGroupId(@Param("groupId") Long groupId);
|
||||
|
||||
@Query("""
|
||||
select coalesce(sum(subgroup.studentCapacity), 0)
|
||||
from Subgroup subgroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
and subgroup.id <> :excludedId
|
||||
""")
|
||||
Long sumActiveStudentCapacityByGroupIdExcludingId(@Param("groupId") Long groupId,
|
||||
@Param("excludedId") Long excludedId);
|
||||
|
||||
@Query("""
|
||||
select count(subgroup)
|
||||
from Subgroup subgroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and subgroup.status <> 'ARCHIVED'
|
||||
and subgroup.id <> :excludedId
|
||||
""")
|
||||
long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId,
|
||||
@Param("excludedId") Long excludedId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TeacherCreationRequest;
|
||||
import com.magistr.app.model.TeacherCreationRequestStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
|
||||
|
||||
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);
|
||||
|
||||
List<TeacherCreationRequest> findByStatusOrderByCreatedAtDesc(TeacherCreationRequestStatus status);
|
||||
|
||||
List<TeacherCreationRequest> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
boolean existsByUsernameAndStatus(String username, TeacherCreationRequestStatus status);
|
||||
}
|
||||
@@ -38,7 +38,6 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
|
||||
join fetch a.teacher
|
||||
join fetch a.department
|
||||
where a.department.id = :departmentId
|
||||
and a.primaryAssignment = true
|
||||
and a.validFrom <= :date
|
||||
and (a.validTo is null or a.validTo >= :date)
|
||||
order by a.teacher.fullName
|
||||
@@ -47,4 +46,18 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select count(a) > 0
|
||||
from TeacherDepartmentAssignment a
|
||||
where a.teacher.id = :teacherId
|
||||
and a.department.id = :departmentId
|
||||
and a.validFrom <= :date
|
||||
and (a.validTo is null or a.validTo >= :date)
|
||||
""")
|
||||
boolean existsActiveAssignment(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 назначенного календарного графика';
|
||||
|
||||
@@ -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';
|
||||
@@ -11,18 +11,23 @@ import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectCommentRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DepartmentWorkspaceControllerTest {
|
||||
@@ -43,7 +48,9 @@ class DepartmentWorkspaceControllerTest {
|
||||
subjectRepository,
|
||||
mock(SubjectCommentRepository.class),
|
||||
mock(UserRepository.class),
|
||||
mock(DepartmentRepository.class),
|
||||
mock(TeacherDepartmentAssignmentRepository.class),
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||
@@ -67,7 +74,9 @@ class DepartmentWorkspaceControllerTest {
|
||||
mock(SubjectRepository.class),
|
||||
mock(SubjectCommentRepository.class),
|
||||
userRepository,
|
||||
mock(DepartmentRepository.class),
|
||||
assignmentRepository,
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
);
|
||||
|
||||
@@ -77,6 +86,39 @@ class DepartmentWorkspaceControllerTest {
|
||||
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
|
||||
}
|
||||
|
||||
@Test
|
||||
void departmentCanAddExistingTeacherAsSecondaryAssignment() {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
|
||||
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||
User teacher = user(20L, "teacher", "Иванов Иван Иванович", 2L);
|
||||
Department department = new Department(1L, "Кафедра ИБ", 1L);
|
||||
when(userRepository.findById(20L)).thenReturn(Optional.of(teacher));
|
||||
when(departmentRepository.findById(1L)).thenReturn(Optional.of(department));
|
||||
when(assignmentRepository.existsActiveAssignment(20L, 1L, LocalDate.now())).thenReturn(false);
|
||||
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||
mock(SubjectRepository.class),
|
||||
mock(SubjectCommentRepository.class),
|
||||
userRepository,
|
||||
departmentRepository,
|
||||
assignmentRepository,
|
||||
mock(TeacherCreationRequestRepository.class),
|
||||
mock(ScheduleQueryService.class)
|
||||
);
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(3L, "department", Role.DEPARTMENT, 1L));
|
||||
|
||||
var response = controller.addTeacherToDepartment(20L, Map.of("comment", "Совместительство"));
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
ArgumentCaptor<TeacherDepartmentAssignment> captor = ArgumentCaptor.forClass(TeacherDepartmentAssignment.class);
|
||||
verify(assignmentRepository).save(captor.capture());
|
||||
TeacherDepartmentAssignment saved = captor.getValue();
|
||||
assertThat(saved.getTeacher()).isEqualTo(teacher);
|
||||
assertThat(saved.getDepartment()).isEqualTo(department);
|
||||
assertThat(saved.getPrimaryAssignment()).isFalse();
|
||||
assertThat(saved.getCreatedBy()).isEqualTo(3L);
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
|
||||
Department department = new Department(departmentId, "Кафедра", departmentId);
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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` сразу использует новые правила календаря.
|
||||
|
||||
|
||||
123
docs/API.md
123
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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 мог предложить перенос этого правила.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
|
||||
@@ -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-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -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`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `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`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
|
||||
- Вкладка `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)
|
||||
@@ -317,16 +315,27 @@ CSS-переменные позволяют поддерживать светл
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--accent: #6366f1;
|
||||
--accent: #4F46E5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f0f23;
|
||||
--text-primary: #e2e8f0;
|
||||
--accent: #818cf8;
|
||||
--accent: #8B5CF6;
|
||||
}
|
||||
```
|
||||
|
||||
### Система кнопок
|
||||
|
||||
Кнопки админ-панели, отдельной SPA настроек, страницы входа и личных кабинетов используют единую семантическую модель:
|
||||
|
||||
- `btn` — обязательная база для всех кнопок и ссылок-кнопок.
|
||||
- Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий.
|
||||
- Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке.
|
||||
- Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления.
|
||||
- Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`.
|
||||
- Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.
|
||||
|
||||
Переключение — через `theme-toggle.js`.
|
||||
|
||||
---
|
||||
|
||||
@@ -654,32 +654,6 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary {
|
||||
min-height: 42px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.schedule-view-shell .btn-secondary:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.schedule-view-extra-filters {
|
||||
margin-top: 0.9rem;
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
@@ -944,8 +918,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-view-period-buttons .btn-secondary,
|
||||
.schedule-view-result-actions .btn-secondary {
|
||||
.schedule-view-period-buttons .btn,
|
||||
.schedule-view-result-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,15 +25,7 @@
|
||||
}
|
||||
|
||||
.sidebar-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
@@ -112,6 +104,7 @@
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
@@ -120,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);
|
||||
@@ -255,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);
|
||||
@@ -279,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;
|
||||
@@ -306,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;
|
||||
@@ -341,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;
|
||||
@@ -355,12 +425,7 @@
|
||||
/* ===== Mobile Menu Toggle ===== */
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
padding: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-placeholder: #94a3b8;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent: #4F46E5;
|
||||
--accent-hover: #4338CA;
|
||||
--accent-glow: rgba(79, 70, 229, 0.3);
|
||||
--accent-secondary: #d946ef;
|
||||
|
||||
--error: #ef4444;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</svg>
|
||||
<span>Magistr</span>
|
||||
</div>
|
||||
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
@@ -66,6 +66,17 @@
|
||||
</svg>
|
||||
<span>Дашборд</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="teacher-requests">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M8 11h8" />
|
||||
<path d="M8 16h5" />
|
||||
</svg>
|
||||
<span>Заявки</span>
|
||||
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="department-workspace">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -193,7 +204,7 @@
|
||||
Общие настройки
|
||||
</a>
|
||||
<div class="settings-menu-divider"></div>
|
||||
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
@@ -213,7 +224,7 @@
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
@@ -221,7 +232,10 @@
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<div class="topbar-title-group">
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dynamic Content Injected Here -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function hideAlert(elementId) {
|
||||
|
||||
export function applyRippleEffect() {
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-primary, .btn-delete, .btn-logout');
|
||||
const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
|
||||
if (!btn) return;
|
||||
|
||||
const rect = btn.getBoundingClientRect();
|
||||
|
||||
@@ -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')) {
|
||||
@@ -217,12 +238,14 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
academicYearsTbody.innerHTML = academicYears.map(year => `
|
||||
<tr>
|
||||
<td>${escapeHtml(year.title)}</td>
|
||||
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
|
||||
<td>${renderSemesterList(year.semesters || [])}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
|
||||
<td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
|
||||
<td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
|
||||
<td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
|
||||
<td class="academic-year-actions-cell">
|
||||
<div class="academic-year-actions">
|
||||
<button class="btn btn-sm btn-secondary btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-year" data-id="${year.id}">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -230,12 +253,19 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function renderSemesterList(items) {
|
||||
if (!items.length) return '<span class="muted">Нет семестров</span>';
|
||||
return items.map(semester => `
|
||||
<div class="schedule-semester-line">
|
||||
${escapeHtml(semesterLabel(semester))}
|
||||
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
return `<div class="academic-year-semesters">${items.map(semester => {
|
||||
const title = semesterTypeTitle(semester);
|
||||
const period = formatDisplayDateRange(semester.startDate, semester.endDate);
|
||||
return `
|
||||
<div class="academic-year-semester-item">
|
||||
<div class="academic-year-semester-meta">
|
||||
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
|
||||
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function saveAcademicYear(event) {
|
||||
@@ -282,7 +312,7 @@ export async function initAcademicCalendar() {
|
||||
if (editSemesterButton) {
|
||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||
if (!semester) return;
|
||||
semesterFormTitle.textContent = 'Редактирование семестра';
|
||||
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||||
semesterIdInput.value = semester.id;
|
||||
semesterYearSelect.value = semester.academicYearId || '';
|
||||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||||
@@ -352,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();
|
||||
@@ -371,6 +406,7 @@ export async function initAcademicCalendar() {
|
||||
calendars = await api.get('/api/admin/academic-calendars');
|
||||
renderCalendars();
|
||||
populateEditorCalendarSelect();
|
||||
populateSubjectCalendarSelect();
|
||||
} catch (error) {
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
@@ -391,9 +427,10 @@ export async function initAcademicCalendar() {
|
||||
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
|
||||
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||
<button class="btn btn-sm btn-secondary btn-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -431,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 = 'Редактирование календарного графика';
|
||||
@@ -451,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 {
|
||||
@@ -480,6 +529,149 @@ export async function initAcademicCalendar() {
|
||||
hideAlert('academic-calendar-alert');
|
||||
}
|
||||
|
||||
function activateCalendarTab(tabName) {
|
||||
const target = tabName || 'manage';
|
||||
calendarTabButtons.forEach(button => {
|
||||
const active = button.dataset.calendarTab === target;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
calendarTabPanels.forEach(panel => {
|
||||
const active = panel.dataset.calendarTabPanel === target;
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCalendarSubjects() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
populateSubjectSemesterSelect();
|
||||
calendarSubjectRows = [];
|
||||
if (!calendar) {
|
||||
renderCalendarSubjects();
|
||||
return;
|
||||
}
|
||||
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
try {
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||
renderCalendarSubjects();
|
||||
} catch (error) {
|
||||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function addCalendarSubject() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
const semesterNumber = Number(subjectSemesterSelect.value);
|
||||
const subjectId = Number(subjectSelect.value);
|
||||
if (!calendar || !semesterNumber || !subjectId) {
|
||||
showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error');
|
||||
return;
|
||||
}
|
||||
if (calendarSubjectRows.some(row =>
|
||||
Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId
|
||||
)) {
|
||||
showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error');
|
||||
return;
|
||||
}
|
||||
const subject = subjects.find(item => Number(item.id) === subjectId);
|
||||
if (!subject) {
|
||||
showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error');
|
||||
return;
|
||||
}
|
||||
calendarSubjectRows = sortCalendarSubjectRows([
|
||||
...calendarSubjectRows,
|
||||
{
|
||||
id: null,
|
||||
calendarId: calendar.id,
|
||||
semesterNumber,
|
||||
subjectId,
|
||||
subjectName: subject.name,
|
||||
subjectCode: subject.code,
|
||||
departmentId: subject.departmentId
|
||||
}
|
||||
]);
|
||||
renderCalendarSubjects();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
async function saveCalendarSubjects() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (!calendar) {
|
||||
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = calendarSubjectRows.map(row => ({
|
||||
semesterNumber: Number(row.semesterNumber),
|
||||
subjectId: Number(row.subjectId)
|
||||
}));
|
||||
try {
|
||||
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
|
||||
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
|
||||
renderCalendarSubjects();
|
||||
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
|
||||
} catch (error) {
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubjectSummaryClick(event) {
|
||||
const removeButton = event.target.closest('.calendar-subject-remove');
|
||||
if (!removeButton) return;
|
||||
const semesterNumber = Number(removeButton.dataset.semester);
|
||||
const subjectId = Number(removeButton.dataset.subject);
|
||||
calendarSubjectRows = calendarSubjectRows.filter(row =>
|
||||
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
|
||||
);
|
||||
renderCalendarSubjects();
|
||||
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
|
||||
}
|
||||
|
||||
function renderCalendarSubjects() {
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (!calendar) {
|
||||
subjectCount.textContent = '';
|
||||
subjectSummary.innerHTML = '<div class="loading-row">Выберите календарный график</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const semesterCount = calendarSemesterCount(calendar);
|
||||
subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`;
|
||||
subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
|
||||
const semesterNumber = index + 1;
|
||||
const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber);
|
||||
return `
|
||||
<section class="calendar-subject-semester">
|
||||
<div class="calendar-subject-semester-head">
|
||||
<strong>${escapeHtml(semesterOptionLabel(semesterNumber))}</strong>
|
||||
<span>${semesterRows.length} дисциплин</span>
|
||||
</div>
|
||||
${semesterRows.length
|
||||
? `<div class="calendar-subject-chips">${semesterRows.map(renderSubjectChip).join('')}</div>`
|
||||
: '<div class="calendar-subject-empty">Нет дисциплин</div>'}
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSubjectChip(row) {
|
||||
return `
|
||||
<span class="calendar-subject-chip">
|
||||
<span>${escapeHtml(subjectRowLabel(row))}</span>
|
||||
<button type="button"
|
||||
class="calendar-subject-remove"
|
||||
data-semester="${escapeHtml(String(row.semesterNumber))}"
|
||||
data-subject="${escapeHtml(String(row.subjectId))}"
|
||||
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadCalendarGrid() {
|
||||
hideAlert('calendar-editor-alert');
|
||||
const calendar = selectedEditorCalendar();
|
||||
@@ -559,8 +751,8 @@ export async function initAcademicCalendar() {
|
||||
selectedCalendarDayKey = null;
|
||||
selectedCalendarDays = [];
|
||||
const grouped = groupBy(rows, row => row.courseNumber);
|
||||
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => `
|
||||
<details class="calendar-course-block" ${index === 0 ? 'open' : ''}>
|
||||
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
|
||||
<details class="calendar-course-block">
|
||||
<summary class="calendar-course-header">
|
||||
<span class="calendar-course-title">
|
||||
<span class="calendar-course-chevron" aria-hidden="true"></span>
|
||||
@@ -1095,6 +1287,21 @@ export async function initAcademicCalendar() {
|
||||
syncSelects(calendarStudyFormSelect);
|
||||
}
|
||||
|
||||
function populateSubjectSelect() {
|
||||
if (!subjectSelect) return;
|
||||
const current = subjectSelect.value;
|
||||
const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
|
||||
subjectSelect.innerHTML = sortedSubjects.length
|
||||
? '<option value="">Выберите дисциплину</option>' + sortedSubjects.map(subject =>
|
||||
`<option value="${subject.id}">${escapeHtml(subjectLabel(subject))}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет дисциплин</option>';
|
||||
if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) {
|
||||
subjectSelect.value = current;
|
||||
}
|
||||
syncSelects(subjectSelect);
|
||||
}
|
||||
|
||||
function populateActivitySelects() {
|
||||
const activityOptions = '<option value="">Выберите код</option>' +
|
||||
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
||||
@@ -1112,6 +1319,41 @@ export async function initAcademicCalendar() {
|
||||
syncSelects(editorCalendarSelect);
|
||||
}
|
||||
|
||||
function populateSubjectCalendarSelect() {
|
||||
if (!subjectCalendarSelect) return;
|
||||
const current = subjectCalendarSelect.value;
|
||||
subjectCalendarSelect.innerHTML = calendars.length
|
||||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет графиков</option>';
|
||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||
subjectCalendarSelect.value = current;
|
||||
}
|
||||
populateSubjectSemesterSelect();
|
||||
syncSelects(subjectCalendarSelect);
|
||||
}
|
||||
|
||||
function populateSubjectSemesterSelect() {
|
||||
if (!subjectSemesterSelect) return;
|
||||
const calendar = selectedSubjectCalendar();
|
||||
const current = subjectSemesterSelect.value;
|
||||
if (!calendar) {
|
||||
subjectSemesterSelect.innerHTML = '<option value="">Выберите график</option>';
|
||||
syncSelects(subjectSemesterSelect);
|
||||
return;
|
||||
}
|
||||
const semesterCount = calendarSemesterCount(calendar);
|
||||
subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
|
||||
const semesterNumber = index + 1;
|
||||
return `<option value="${semesterNumber}">${escapeHtml(semesterOptionLabel(semesterNumber))}</option>`;
|
||||
}).join('');
|
||||
if (current && Number(current) >= 1 && Number(current) <= semesterCount) {
|
||||
subjectSemesterSelect.value = current;
|
||||
}
|
||||
syncSelects(subjectSemesterSelect);
|
||||
}
|
||||
|
||||
function populateFillCourseSelect() {
|
||||
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
|
||||
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
||||
@@ -1136,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 || '';
|
||||
}
|
||||
@@ -1164,7 +1438,13 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
|
||||
}
|
||||
|
||||
function semesterTypeTitle(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function normalizeSemesterType(type) {
|
||||
@@ -1195,6 +1475,16 @@ export async function initAcademicCalendar() {
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
if (!year || !month || !day) return String(value || '');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
function formatDisplayDateRange(startDate, endDate) {
|
||||
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||||
}
|
||||
|
||||
function dateRangeCaption(rows) {
|
||||
if (!rows.length) return '';
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
@@ -1240,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select
|
||||
}
|
||||
|
||||
container.innerHTML = equipments.map(eq => `
|
||||
<label class="checkbox-container">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
@@ -87,16 +88,16 @@ export async function initClassrooms() {
|
||||
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
|
||||
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
|
||||
</span>
|
||||
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
|
||||
<button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
|
||||
${c.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -125,9 +126,9 @@ export async function initClassrooms() {
|
||||
});
|
||||
|
||||
classroomsTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete');
|
||||
const btnDelete = e.target.closest('.btn-archive-classroom');
|
||||
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
|
||||
const btnEdit = e.target.closest('.btn-edit-classroom');
|
||||
const btnEdit = e.target.closest('.classroom-edit-action');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
@@ -261,7 +262,7 @@ export async function initClassrooms() {
|
||||
<tr>
|
||||
<td>${eq.id}</td>
|
||||
<td>${escapeHtml(eq.name)}</td>
|
||||
<td><button class="btn-delete btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ export async function initDepartmentWorkspace() {
|
||||
const importForm = document.getElementById('department-subject-import-form');
|
||||
const importRowsContainer = document.getElementById('subject-import-rows');
|
||||
const importAddButton = document.getElementById('subject-import-add');
|
||||
const assignTeacherForm = document.getElementById('department-teacher-assign-form');
|
||||
const assignTeacherSelect = document.getElementById('department-teacher-select');
|
||||
const assignTeacherComment = document.getElementById('department-teacher-assign-comment');
|
||||
const teacherRequestForm = document.getElementById('department-teacher-request-form');
|
||||
const teacherRequestsTbody = document.getElementById('department-teacher-requests-tbody');
|
||||
const teacherRequestCount = document.getElementById('department-teacher-request-count');
|
||||
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
||||
const subjectCount = document.getElementById('department-subject-count');
|
||||
const commentsCard = document.getElementById('department-comments-card');
|
||||
@@ -25,6 +31,8 @@ export async function initDepartmentWorkspace() {
|
||||
const commentsCloseButton = document.getElementById('department-comments-close');
|
||||
let lastTeachers = null;
|
||||
let lastWorkload = null;
|
||||
let allTeachers = [];
|
||||
let teacherRequests = null;
|
||||
let teachersErrorMessage = '';
|
||||
let workloadErrorMessage = '';
|
||||
|
||||
@@ -34,6 +42,8 @@ export async function initDepartmentWorkspace() {
|
||||
startInput?.addEventListener('change', handleStartDateChange);
|
||||
endInput?.addEventListener('change', loadWorkload);
|
||||
importForm?.addEventListener('submit', importSubjects);
|
||||
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
|
||||
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
|
||||
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
|
||||
importRowsContainer?.addEventListener('click', (event) => {
|
||||
const removeBtn = event.target.closest('.subject-import-remove');
|
||||
@@ -61,6 +71,7 @@ export async function initDepartmentWorkspace() {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
await loadAllTeachers();
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
@@ -74,7 +85,9 @@ export async function initDepartmentWorkspace() {
|
||||
teachersErrorMessage = '';
|
||||
workloadErrorMessage = '';
|
||||
renderTeachers();
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
|
||||
teacherRequests = null;
|
||||
renderTeacherRequests();
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
@@ -94,7 +107,7 @@ export async function initDepartmentWorkspace() {
|
||||
<td>${escapeHtml(subject.name || '-')}</td>
|
||||
<td>${escapeHtml(subject.description || '-')}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-edit-classroom department-comments-open"
|
||||
<button type="button" class="btn btn-sm btn-secondary department-comments-open"
|
||||
data-subject-id="${escapeHtml(subject.id)}"
|
||||
data-subject-name="${escapeHtml(subject.name || '')}">
|
||||
Открыть
|
||||
@@ -120,7 +133,7 @@ export async function initDepartmentWorkspace() {
|
||||
<label>Название</label>
|
||||
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
|
||||
</div>
|
||||
<button type="button" class="btn-delete subject-import-remove" data-index="${index}">Удалить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -165,6 +178,68 @@ export async function initDepartmentWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
async function assignTeacherToDepartment(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-teacher-assign-alert';
|
||||
hideAlert(alertId);
|
||||
const teacherId = assignTeacherSelect?.value;
|
||||
const departmentId = selectedDepartmentId();
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!teacherId) {
|
||||
showAlert(alertId, 'Выберите преподавателя', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
|
||||
departmentId: Number(departmentId),
|
||||
comment: assignTeacherComment?.value.trim() || null
|
||||
});
|
||||
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
|
||||
if (assignTeacherComment) assignTeacherComment.value = '';
|
||||
await Promise.all([loadTeachers(), loadWorkload()]);
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTeacherRequest(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-teacher-request-alert';
|
||||
hideAlert(alertId);
|
||||
const departmentId = selectedDepartmentId();
|
||||
const username = document.getElementById('teacher-request-username')?.value.trim();
|
||||
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
|
||||
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
|
||||
const comment = document.getElementById('teacher-request-comment')?.value.trim();
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!username || !fullName) {
|
||||
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/department/teacher-requests', {
|
||||
departmentId: Number(departmentId),
|
||||
username,
|
||||
fullName,
|
||||
jobTitle,
|
||||
comment
|
||||
});
|
||||
showAlert(alertId, 'Заявка отправлена администратору', 'success');
|
||||
teacherRequestForm.reset();
|
||||
await loadTeacherRequests();
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function openComments(subjectId, subjectName) {
|
||||
commentsCard.hidden = false;
|
||||
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
|
||||
@@ -215,14 +290,46 @@ export async function initDepartmentWorkspace() {
|
||||
lastTeachers = null;
|
||||
teachersErrorMessage = '';
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
try {
|
||||
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
lastTeachers = Array.isArray(teachers) ? teachers : [];
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
} catch (error) {
|
||||
lastTeachers = [];
|
||||
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
|
||||
renderTeachers();
|
||||
renderTeacherSelect();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllTeachers() {
|
||||
try {
|
||||
const teachers = await api.get('/api/users/teachers');
|
||||
allTeachers = Array.isArray(teachers) ? teachers : [];
|
||||
renderTeacherSelect();
|
||||
} catch (error) {
|
||||
allTeachers = [];
|
||||
if (assignTeacherSelect) {
|
||||
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeacherRequests() {
|
||||
teacherRequests = null;
|
||||
renderTeacherRequests();
|
||||
try {
|
||||
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
teacherRequests = Array.isArray(requests) ? requests : [];
|
||||
renderTeacherRequests();
|
||||
} catch (error) {
|
||||
teacherRequests = [];
|
||||
if (teacherRequestsTbody) {
|
||||
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
|
||||
}
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +426,49 @@ export async function initDepartmentWorkspace() {
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
|
||||
}
|
||||
|
||||
function renderTeacherSelect() {
|
||||
if (!assignTeacherSelect) return;
|
||||
if (lastTeachers === null) {
|
||||
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||||
return;
|
||||
}
|
||||
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
|
||||
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
|
||||
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
availableTeachers.map(teacher => `
|
||||
<option value="${escapeHtml(teacher.id)}">
|
||||
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
|
||||
</option>
|
||||
`).join('');
|
||||
if (!availableTeachers.length) {
|
||||
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeacherRequests() {
|
||||
if (!teacherRequestsTbody) return;
|
||||
if (teacherRequests === null) {
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
|
||||
return;
|
||||
}
|
||||
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
|
||||
if (!teacherRequests.length) {
|
||||
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
|
||||
return;
|
||||
}
|
||||
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
|
||||
<tr>
|
||||
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
|
||||
<td>${escapeHtml(request.username || '-')}</td>
|
||||
<td>${escapeHtml(request.fullName || '-')}</td>
|
||||
<td>${escapeHtml(request.jobTitle || '-')}</td>
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectedDepartmentId() {
|
||||
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
||||
}
|
||||
@@ -366,6 +516,30 @@ function subjectCountLabel(count) {
|
||||
return `${count} дисциплин`;
|
||||
}
|
||||
|
||||
function requestCountLabel(count) {
|
||||
const tail = count % 100;
|
||||
if (tail >= 11 && tail <= 14) return `${count} заявок`;
|
||||
if (count % 10 === 1) return `${count} заявка`;
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
|
||||
return `${count} заявок`;
|
||||
}
|
||||
|
||||
function requestStatusLabel(status) {
|
||||
return {
|
||||
PENDING: 'На рассмотрении',
|
||||
APPROVED: 'Одобрена',
|
||||
REJECTED: 'Отклонена'
|
||||
}[status] || status || '-';
|
||||
}
|
||||
|
||||
function requestStatusClass(status) {
|
||||
return {
|
||||
PENDING: 'badge-ef',
|
||||
APPROVED: 'badge-student',
|
||||
REJECTED: 'badge-admin'
|
||||
}[status] || 'badge-ef';
|
||||
}
|
||||
|
||||
function renderTeacherMetric(item, workloadErrorMessage) {
|
||||
return `
|
||||
<article class="metric-card">
|
||||
|
||||
@@ -10,6 +10,8 @@ async function fetchEducationForms() {
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||||
|
||||
export async function initGroups() {
|
||||
const groupsTbody = document.getElementById('groups-tbody');
|
||||
const createGroupForm = document.getElementById('create-group-form');
|
||||
@@ -17,7 +19,10 @@ export async function initGroups() {
|
||||
const newGroupDepartmentSelect = document.getElementById('new-group-department');
|
||||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||||
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
||||
const filterEfSelect = document.getElementById('filter-ef');
|
||||
const filterEfBox = document.getElementById('filter-ef-box');
|
||||
const filterEfMenu = document.getElementById('filter-ef-menu');
|
||||
const filterEfText = document.getElementById('filter-ef-text');
|
||||
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
|
||||
|
||||
// Модалка группы
|
||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||
@@ -36,8 +41,8 @@ export async function initGroups() {
|
||||
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||||
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||||
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||||
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
|
||||
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
|
||||
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
|
||||
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
|
||||
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||||
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||||
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||||
@@ -61,12 +66,14 @@ export async function initGroups() {
|
||||
let academicYears = [];
|
||||
let calendars = [];
|
||||
let currentAssignments = [];
|
||||
let activeSubgroupGroup = null;
|
||||
|
||||
bindEvents();
|
||||
await loadInitialData();
|
||||
|
||||
function bindEvents() {
|
||||
filterEfSelect.addEventListener('change', applyGroupFilter);
|
||||
initFilterEfDropdown();
|
||||
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
|
||||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||||
|
||||
@@ -75,6 +82,7 @@ export async function initGroups() {
|
||||
|
||||
// Модалка подгрупп
|
||||
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||||
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
|
||||
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||||
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||
|
||||
@@ -101,6 +109,37 @@ export async function initGroups() {
|
||||
});
|
||||
}
|
||||
|
||||
function initFilterEfDropdown() {
|
||||
if (!filterEfBox || !filterEfMenu) return;
|
||||
|
||||
const toggleDropdown = (event) => {
|
||||
event.stopPropagation();
|
||||
const isOpen = filterEfMenu.classList.contains('open');
|
||||
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
|
||||
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
|
||||
|
||||
if (!isOpen) {
|
||||
filterEfMenu.classList.add('open');
|
||||
filterEfBox.classList.add('active');
|
||||
filterEfBox.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
filterEfBox.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
filterEfBox.addEventListener('click', toggleDropdown);
|
||||
filterEfBox.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggleDropdown(event);
|
||||
}
|
||||
});
|
||||
|
||||
filterEfMenu.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
[educationForms, departments, specialties, academicYears, calendars] = await Promise.all([
|
||||
@@ -148,13 +187,63 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
const filterId = filterEfSelect.value;
|
||||
const filtered = filterId
|
||||
? allGroups.filter(group => group.educationFormId == filterId)
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
const filtered = selectedIds.length
|
||||
? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
|
||||
: allGroups;
|
||||
renderGroups(filtered);
|
||||
}
|
||||
|
||||
function handleFilterEfChange(event) {
|
||||
const changedCheckbox = event.target.closest('input[type="checkbox"]');
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
|
||||
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
|
||||
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
|
||||
allCheckbox.checked = false;
|
||||
}
|
||||
|
||||
if (!selectedFilterEducationFormIds().length) {
|
||||
allCheckbox.checked = true;
|
||||
}
|
||||
|
||||
updateFilterEfText();
|
||||
applyGroupFilter();
|
||||
}
|
||||
|
||||
function selectedFilterEducationFormIds() {
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
if (allCheckbox?.checked) return [];
|
||||
|
||||
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
|
||||
.map(checkbox => String(checkbox.value));
|
||||
}
|
||||
|
||||
function updateFilterEfText() {
|
||||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||||
const selectedIds = selectedFilterEducationFormIds();
|
||||
if (allCheckbox?.checked) {
|
||||
filterEfText.textContent = 'Все';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedIds.length) {
|
||||
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.length === 1) {
|
||||
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
|
||||
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
|
||||
return;
|
||||
}
|
||||
|
||||
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
|
||||
}
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
@@ -171,13 +260,15 @@ export async function initGroups() {
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn-primary btn-manage-subgroups" data-id="${group.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Подгруппы</button>
|
||||
<button class="btn-primary btn-manage-calendar" data-id="${group.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Календарь</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архив</button>`}
|
||||
<td class="group-actions-cell">
|
||||
<div class="group-actions">
|
||||
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -186,11 +277,30 @@ export async function initGroups() {
|
||||
function populateEfSelects(forms) {
|
||||
populateEfSelect(newGroupEfSelect, forms);
|
||||
populateEfSelect(editGroupEfSelect, forms);
|
||||
const currentFilter = filterEfSelect.value;
|
||||
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
|
||||
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentFilter) filterEfSelect.value = currentFilter;
|
||||
syncSelects(filterEfSelect);
|
||||
renderFilterEfCheckboxes(forms);
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
const selectedIds = new Set(selectedFilterEducationFormIds());
|
||||
const allChecked = !selectedIds.size;
|
||||
filterEfCheckboxes.innerHTML = `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>Все</span>
|
||||
</label>
|
||||
${forms.map(form => {
|
||||
const id = String(form.id);
|
||||
return `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(form.name)}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('')}
|
||||
`;
|
||||
updateFilterEfText();
|
||||
}
|
||||
|
||||
function populateEfSelect(select, forms) {
|
||||
@@ -345,7 +455,7 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
async function handleGroupTableClick(event) {
|
||||
const btnDelete = event.target.closest('.btn-delete');
|
||||
const btnDelete = event.target.closest('.btn-archive-group');
|
||||
const btnEdit = event.target.closest('.btn-edit-group');
|
||||
const btnRestore = event.target.closest('.btn-restore-group');
|
||||
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
|
||||
@@ -409,6 +519,7 @@ export async function initGroups() {
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
function openSubgroupsModal(group) {
|
||||
activeSubgroupGroup = group;
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
resetSubgroupForm();
|
||||
@@ -422,13 +533,19 @@ export async function initGroups() {
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
const canDeleteSingleSubgroup = list.length === 1;
|
||||
const deleteTitle = canDeleteSingleSubgroup
|
||||
? 'Удалить подгруппу'
|
||||
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
|
||||
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||||
<tr>
|
||||
<td>${escapeHtml(sub.name)}</td>
|
||||
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
|
||||
${canDeleteSingleSubgroup ? '' : 'disabled'}
|
||||
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -436,46 +553,157 @@ export async function initGroups() {
|
||||
|
||||
function resetSubgroupForm() {
|
||||
manageSubgroupIdInput.value = '';
|
||||
manageSubgroupNameInput.value = '';
|
||||
manageSubgroupCapacityInput.value = '';
|
||||
subgroupFormTitle.textContent = 'Создание подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Создать';
|
||||
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
|
||||
const count = [2, 3].includes(current.length) ? current.length : 0;
|
||||
manageSubgroupCountSelect.value = String(count);
|
||||
syncSelects(manageSubgroupCountSelect);
|
||||
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
|
||||
subgroupFormTitle.textContent = 'Настройка подгрупп';
|
||||
btnSaveSubgroup.textContent = 'Применить';
|
||||
btnCancelSubgroupEdit.style.display = 'none';
|
||||
hideAlert('manage-subgroup-alert');
|
||||
}
|
||||
|
||||
function currentGroupSubgroups(groupId) {
|
||||
return subgroups
|
||||
.filter(sub => String(sub.groupId) === String(groupId))
|
||||
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
|
||||
}
|
||||
|
||||
function naturalSubgroupOrder(name) {
|
||||
const number = String(name || '').match(/\d+/)?.[0];
|
||||
return number ? String(number).padStart(3, '0') : String(name || '');
|
||||
}
|
||||
|
||||
function renderSubgroupCapacityFields(count, capacities = []) {
|
||||
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
|
||||
if (!count) {
|
||||
manageSubgroupCapacities.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
|
||||
const suggested = suggestedSubgroupCapacities(groupSize, count);
|
||||
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
|
||||
const value = capacities[index] ?? suggested[index] ?? '';
|
||||
return `
|
||||
<div class="form-group subgroup-capacity-field">
|
||||
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
|
||||
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
|
||||
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function suggestedSubgroupCapacities(groupSize, count) {
|
||||
if (!groupSize || !count) return [];
|
||||
const base = Math.floor(groupSize / count);
|
||||
const remainder = groupSize % count;
|
||||
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
|
||||
}
|
||||
|
||||
async function saveSubgroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
const subgroupId = manageSubgroupIdInput.value;
|
||||
const name = manageSubgroupNameInput.value.trim();
|
||||
const capacity = manageSubgroupCapacityInput.value;
|
||||
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
|
||||
const count = Number(manageSubgroupCountSelect.value);
|
||||
const existingSubgroups = currentGroupSubgroups(groupId);
|
||||
|
||||
if (!name || !capacity) {
|
||||
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
|
||||
if (!group) {
|
||||
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
studentCapacity: Number(capacity)
|
||||
};
|
||||
if (count === 0) {
|
||||
await applyNoSubgroups(groupId, existingSubgroups);
|
||||
return;
|
||||
}
|
||||
|
||||
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
|
||||
.map(input => Number(input.value));
|
||||
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
|
||||
if (validationError) {
|
||||
showAlert('manage-subgroup-alert', validationError, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
|
||||
const saved = subgroupId
|
||||
? await api.put(endpoint, payload)
|
||||
: await api.post(endpoint, payload);
|
||||
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||
resetSubgroupForm();
|
||||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||||
await loadSubgroups();
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubgroupCapacities(capacities, groupSize, count) {
|
||||
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
|
||||
return 'Укажите положительную численность для каждой подгруппы';
|
||||
}
|
||||
|
||||
const total = capacities.reduce((sum, value) => sum + value, 0);
|
||||
if (total !== groupSize) {
|
||||
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function applyNoSubgroups(groupId, existingSubgroups) {
|
||||
try {
|
||||
for (const subgroup of existingSubgroups) {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
|
||||
await loadSubgroups();
|
||||
resetSubgroupForm();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSubgroups(groupId, existingSubgroups, capacities) {
|
||||
const targetCount = capacities.length;
|
||||
const extraSubgroups = existingSubgroups.slice(targetCount);
|
||||
for (const subgroup of extraSubgroups) {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const creates = [];
|
||||
for (let index = 0; index < targetCount; index += 1) {
|
||||
const payload = {
|
||||
name: `Подгруппа ${index + 1}`,
|
||||
studentCapacity: capacities[index]
|
||||
};
|
||||
const existing = existingSubgroups[index];
|
||||
if (existing) {
|
||||
updates.push({ existing, payload });
|
||||
} else {
|
||||
creates.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
updates.sort((left, right) => {
|
||||
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
|
||||
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
|
||||
return Number(leftIncreases) - Number(rightIncreases);
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
|
||||
}
|
||||
|
||||
for (const payload of creates) {
|
||||
await api.post(`/api/groups/${groupId}/subgroups`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubgroupTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-subgroup');
|
||||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||||
@@ -485,17 +713,19 @@ export async function initGroups() {
|
||||
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
|
||||
if (!subgroup) return;
|
||||
|
||||
manageSubgroupIdInput.value = subgroup.id;
|
||||
manageSubgroupNameInput.value = subgroup.name || '';
|
||||
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||
subgroupFormTitle.textContent = 'Редактирование подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Сохранить';
|
||||
btnCancelSubgroupEdit.style.display = 'inline-block';
|
||||
resetSubgroupForm();
|
||||
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
|
||||
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
|
||||
input?.focus();
|
||||
hideAlert('manage-subgroup-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (deleteButton.disabled) {
|
||||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Удалить подгруппу?')) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
@@ -520,31 +750,52 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
async function loadAssignmentsList(groupId) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||
renderAssignmentsList();
|
||||
} catch (error) {
|
||||
manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsList() {
|
||||
if (!currentAssignments.length) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
||||
<td>
|
||||
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderAssignmentSubjects(subjects) {
|
||||
if (!subjects.length) {
|
||||
return '<span class="muted">Не привязаны</span>';
|
||||
}
|
||||
const grouped = subjects.reduce((acc, subject) => {
|
||||
const key = String(subject.semesterNumber || '-');
|
||||
if (!acc.has(key)) acc.set(key, []);
|
||||
acc.get(key).push(subject);
|
||||
return acc;
|
||||
}, new Map());
|
||||
return `<div class="assignment-subjects">${Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([semesterNumber, items]) => `
|
||||
<div class="assignment-subject-semester">
|
||||
<strong>${escapeHtml(semesterNumber)} семестр</strong>
|
||||
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
|
||||
</div>
|
||||
`).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function saveAssignment(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('manage-calendar-alert');
|
||||
@@ -600,6 +851,11 @@ export async function initGroups() {
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
function subjectLabel(subject) {
|
||||
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||||
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||||
}
|
||||
|
||||
function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
|
||||
@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
|
||||
{ parity: 'EVEN', label: 'Чётная' }
|
||||
];
|
||||
const MOBILE_QUERY = '(max-width: 760px)';
|
||||
const DEFAULT_SEMESTER_WEEKS = 22;
|
||||
|
||||
export async function initScheduleView() {
|
||||
const role = localStorage.getItem('role');
|
||||
@@ -621,11 +622,43 @@ export async function initScheduleView() {
|
||||
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
if (startWeek === endWeek) {
|
||||
return `${startWeek} нед.`;
|
||||
}
|
||||
return `с ${startWeek} по ${endWeek} нед.`;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||
}
|
||||
|
||||
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
|
||||
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
|
||||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
|
||||
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
|
||||
return startWeek === endWeek
|
||||
? `(${startWeek} нед.)`
|
||||
: `(с ${startWeek} по ${endWeek} нед.)`;
|
||||
}
|
||||
|
||||
function semesterWeeksForLesson(lesson) {
|
||||
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
|
||||
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||
const start = parseIsoDate(semester.startDate);
|
||||
const end = parseIsoDate(semester.endDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
return Math.max(1, Math.ceil(days / 7));
|
||||
}
|
||||
|
||||
function dateInRange(date, startDate, endDate) {
|
||||
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
|
||||
}
|
||||
|
||||
function firstApplicableSemesterWeek(parity) {
|
||||
return parity === 'EVEN' ? 2 : 1;
|
||||
}
|
||||
|
||||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||
const totalWeeks = Number(maxWeeks || 0);
|
||||
if (!totalWeeks) return 0;
|
||||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||
return totalWeeks;
|
||||
}
|
||||
|
||||
function renderLessonCard(lesson) {
|
||||
@@ -694,13 +727,14 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types')
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/admin/calendar/years').catch(() => [])
|
||||
]);
|
||||
|
||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||
@@ -718,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
departments
|
||||
departments,
|
||||
semesters: (academicYears || []).flatMap(year => year.semesters || [])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,33 @@ export async function initSchedule() {
|
||||
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
|
||||
const visualStage = document.getElementById('schedule-visual-stage');
|
||||
const visualSummary = document.getElementById('schedule-visual-summary');
|
||||
const visualYearSelect = document.getElementById('schedule-visual-year');
|
||||
const visualSemesterSelect = document.getElementById('schedule-visual-semester');
|
||||
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
||||
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||||
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
||||
const conflictModal = document.getElementById('schedule-conflict-modal');
|
||||
const conflictForm = document.getElementById('schedule-conflict-form');
|
||||
const conflictCloseButton = document.getElementById('schedule-conflict-close');
|
||||
const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
|
||||
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
|
||||
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
|
||||
const conflictSummary = document.getElementById('schedule-conflict-summary');
|
||||
const conflictReasons = document.getElementById('schedule-conflict-reasons');
|
||||
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
|
||||
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
|
||||
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
|
||||
const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
|
||||
const visualContextMenu = document.getElementById('schedule-visual-context-menu');
|
||||
const moveSlotModal = document.getElementById('schedule-move-slot-modal');
|
||||
const moveSlotForm = document.getElementById('schedule-move-slot-form');
|
||||
const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
|
||||
const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
|
||||
const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
|
||||
const moveSlotTitle = document.getElementById('schedule-move-slot-title');
|
||||
const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
|
||||
const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
|
||||
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
||||
|
||||
let rules = [];
|
||||
let subjects = [];
|
||||
@@ -94,9 +118,17 @@ export async function initSchedule() {
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let selectedVisualYearId = '';
|
||||
let selectedVisualSemesterId = '';
|
||||
let selectedVisualGroupIds = new Set();
|
||||
let visualGroupFilterTouched = false;
|
||||
let visualPeriodTouched = false;
|
||||
let syncingVisualPeriod = false;
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let pendingSlotMove = null;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -116,19 +148,63 @@ export async function initSchedule() {
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
visualYearSelect?.addEventListener('change', handleVisualYearChange);
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||
conflictCloseButton?.addEventListener('click', closeConflictModal);
|
||||
conflictCancelButton?.addEventListener('click', closeConflictModal);
|
||||
conflictModal?.addEventListener('click', (event) => {
|
||||
if (event.target === conflictModal) closeConflictModal();
|
||||
});
|
||||
conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
|
||||
visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
|
||||
moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
|
||||
moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
|
||||
moveSlotModal?.addEventListener('click', (event) => {
|
||||
if (event.target === moveSlotModal) closeMoveSlotModal();
|
||||
});
|
||||
moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
|
||||
visualUseFormButton?.addEventListener('click', () => {
|
||||
const formSemesterId = ruleSemesterSelect.value;
|
||||
if (formSemesterId) {
|
||||
visualPeriodTouched = true;
|
||||
setVisualPeriodBySemesterId(formSemesterId);
|
||||
}
|
||||
visualGroupFilterTouched = false;
|
||||
selectedVisualGroupIds = new Set();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
visualSelectAllButton?.addEventListener('click', () => {
|
||||
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
|
||||
selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
|
||||
visualGroupFilterTouched = true;
|
||||
renderVisualSchedule();
|
||||
});
|
||||
ruleSemesterSelect?.addEventListener('change', () => {
|
||||
if (visualPeriodTouched) return;
|
||||
selectDefaultVisualPeriod();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeVisualDrawer();
|
||||
if (event.key !== 'Escape') return;
|
||||
if (moveSlotModal?.classList.contains('open')) {
|
||||
closeMoveSlotModal();
|
||||
return;
|
||||
}
|
||||
if (conflictModal?.classList.contains('open')) {
|
||||
closeConflictModal();
|
||||
return;
|
||||
}
|
||||
if (visualContextMenu && !visualContextMenu.hidden) {
|
||||
closeVisualContextMenu();
|
||||
return;
|
||||
}
|
||||
closeVisualDrawer();
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (visualContextMenu?.hidden) return;
|
||||
if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
|
||||
closeVisualContextMenu();
|
||||
});
|
||||
|
||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||
@@ -190,15 +266,19 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
const academicYears = await api.get('/api/admin/calendar/years');
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
populateSemesterSelects();
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
rules = await api.get('/api/admin/schedule-rules');
|
||||
if (!visualPeriodTouched) {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
renderRules();
|
||||
renderVisualSchedule();
|
||||
} catch (error) {
|
||||
@@ -222,8 +302,10 @@ export async function initSchedule() {
|
||||
<td>${renderRuleLoadSummary(rule)}</td>
|
||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||
<div class="schedule-rule-table-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -245,7 +327,6 @@ export async function initSchedule() {
|
||||
visualToggleButton?.classList.add('open');
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||||
if (visualToggleButton) visualToggleButton.textContent = '›';
|
||||
if (visualBackdrop) visualBackdrop.hidden = false;
|
||||
}
|
||||
|
||||
@@ -256,22 +337,37 @@ export async function initSchedule() {
|
||||
visualToggleButton?.classList.remove('open');
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'false');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
|
||||
if (visualToggleButton) visualToggleButton.textContent = '‹';
|
||||
if (visualBackdrop) visualBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
function renderVisualSchedule() {
|
||||
if (!visualStage || !visualSummary) return;
|
||||
if (!selectedVisualSemesterId) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||
return;
|
||||
}
|
||||
if (!rules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Нет сохранённых правил';
|
||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||
return;
|
||||
}
|
||||
|
||||
const allGroups = collectVisualGroups();
|
||||
const visualRules = visualRulesForSelectedPeriod();
|
||||
const periodLabel = visualPeriodLabel();
|
||||
if (!visualRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||
return;
|
||||
}
|
||||
|
||||
const allGroups = collectVisualGroups(visualRules);
|
||||
syncVisualGroupFilter(allGroups);
|
||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules();
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||||
if (!allGroups.length || !slots.length) {
|
||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||
setVisualState('У правил должны быть группы и слоты занятий');
|
||||
@@ -283,15 +379,15 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||
if (!rows.length) {
|
||||
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
|
||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
|
||||
return;
|
||||
}
|
||||
|
||||
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualStage.innerHTML = `
|
||||
<div class="schedule-visual-table-wrap">
|
||||
<table class="schedule-visual-table">
|
||||
@@ -348,10 +444,10 @@ export async function initSchedule() {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildVisualLessonsByCell(allowedGroupIds) {
|
||||
function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
|
||||
const grouped = new Map();
|
||||
const activeWeeks = buildActiveWeeksByRuleSlot();
|
||||
rules.forEach(rule => {
|
||||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
|
||||
visualRules.forEach(rule => {
|
||||
(rule.slots || []).forEach((slot, index) => {
|
||||
const slotKey = visualRuleSlotKey(slot, index);
|
||||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||||
@@ -360,6 +456,11 @@ export async function initSchedule() {
|
||||
if (!weeks.length) return;
|
||||
const lesson = {
|
||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||
ruleId: rule.id,
|
||||
slotId: slot.id || '',
|
||||
slotIndex: index,
|
||||
dayOfWeek: slot.dayOfWeek,
|
||||
timeSlotId: slot.timeSlotId,
|
||||
parity: slot.parity || 'BOTH',
|
||||
subjectName: rule.subjectName || 'Без дисциплины',
|
||||
teacherName: slot.teacherName || '',
|
||||
@@ -367,7 +468,7 @@ export async function initSchedule() {
|
||||
lessonTypeName: slot.lessonTypeName || '',
|
||||
lessonFormat: slot.lessonFormat || '',
|
||||
subgroupNames: slotSubgroupNames(slot),
|
||||
weekText: compactWeekRanges(weeks),
|
||||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
|
||||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
||||
};
|
||||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||||
@@ -380,9 +481,9 @@ export async function initSchedule() {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function buildActiveWeeksByRuleSlot() {
|
||||
function buildActiveWeeksByRuleSlot(visualRules) {
|
||||
const activeWeeks = new Map();
|
||||
rules.forEach(rule => {
|
||||
visualRules.forEach(rule => {
|
||||
const maxWeeks = maxWeeksForRule(rule);
|
||||
(rule.groupIds || []).forEach(groupId => {
|
||||
LESSON_TYPE_LIMITS.forEach(config => {
|
||||
@@ -454,13 +555,20 @@ export async function initSchedule() {
|
||||
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
|
||||
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
|
||||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||
<button type="button"
|
||||
class="schedule-visual-edit-rule"
|
||||
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
|
||||
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
|
||||
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
|
||||
title="Действия со слотом"
|
||||
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">⋮</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function collectVisualGroups() {
|
||||
function collectVisualGroups(visualRules = rules) {
|
||||
const byId = new Map();
|
||||
rules.forEach(rule => {
|
||||
visualRules.forEach(rule => {
|
||||
(rule.groupIds || []).forEach((id, index) => {
|
||||
if (!byId.has(String(id))) {
|
||||
byId.set(String(id), {
|
||||
@@ -473,9 +581,9 @@ export async function initSchedule() {
|
||||
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
|
||||
}
|
||||
|
||||
function collectVisualSlotsFromRules() {
|
||||
function collectVisualSlotsFromRules(visualRules = rules) {
|
||||
const byKey = new Map();
|
||||
rules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
const key = visualSlotKey(slot);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
@@ -511,6 +619,222 @@ export async function initSchedule() {
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualYearChange() {
|
||||
if (syncingVisualPeriod) return;
|
||||
selectedVisualYearId = visualYearSelect?.value || '';
|
||||
selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
|
||||
? String(firstSemesterForYear(selectedVisualYearId).id)
|
||||
: '';
|
||||
visualPeriodTouched = true;
|
||||
visualGroupFilterTouched = false;
|
||||
populateVisualSemesterSelect();
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualSemesterChange() {
|
||||
if (syncingVisualPeriod) return;
|
||||
selectedVisualSemesterId = visualSemesterSelect?.value || '';
|
||||
visualPeriodTouched = true;
|
||||
visualGroupFilterTouched = false;
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualStageClick(event) {
|
||||
const editButton = event.target.closest('.schedule-visual-edit-rule');
|
||||
if (!editButton) return;
|
||||
event.stopPropagation();
|
||||
openVisualContextMenu(editButton);
|
||||
}
|
||||
|
||||
function openVisualContextMenu(button) {
|
||||
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
|
||||
if (!rule) {
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
visualContextTarget = {
|
||||
ruleId: button.dataset.ruleId,
|
||||
slotId: button.dataset.slotId || '',
|
||||
slotIndex: button.dataset.slotIndex || ''
|
||||
};
|
||||
if (!visualContextMenu) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
visualContextMenu.hidden = false;
|
||||
const menuRect = visualContextMenu.getBoundingClientRect();
|
||||
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
|
||||
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
|
||||
visualContextMenu.style.top = `${Math.max(12, top)}px`;
|
||||
visualContextMenu.style.left = `${Math.max(12, left)}px`;
|
||||
visualContextMenu.querySelector('button')?.focus();
|
||||
}
|
||||
|
||||
function closeVisualContextMenu() {
|
||||
visualContextTarget = null;
|
||||
if (!visualContextMenu) return;
|
||||
visualContextMenu.hidden = true;
|
||||
visualContextMenu.style.top = '';
|
||||
visualContextMenu.style.left = '';
|
||||
}
|
||||
|
||||
async function handleVisualContextMenuClick(event) {
|
||||
const actionButton = event.target.closest('button[data-action]');
|
||||
if (!actionButton || !visualContextTarget) return;
|
||||
const action = actionButton.dataset.action;
|
||||
const target = { ...visualContextTarget };
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
if (!rule) {
|
||||
closeVisualContextMenu();
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'edit-rule') {
|
||||
closeVisualContextMenu();
|
||||
fillRuleForm(rule);
|
||||
closeVisualDrawer();
|
||||
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'move-slot') {
|
||||
openMoveSlotModal(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete-rule') {
|
||||
await deleteRuleFromVisual(rule);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuleFromVisual(rule) {
|
||||
closeVisualContextMenu();
|
||||
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + rule.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openMoveSlotModal(target) {
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
const slotIndex = findRuleSlotIndex(rule, target);
|
||||
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
||||
if (!rule || !slot) {
|
||||
closeVisualContextMenu();
|
||||
showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSlotMove = {
|
||||
ruleId: String(rule.id),
|
||||
slotIndex
|
||||
};
|
||||
if (moveSlotTitle) {
|
||||
moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
|
||||
}
|
||||
if (moveSlotSummary) {
|
||||
moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
|
||||
}
|
||||
if (moveSlotDaySelect) {
|
||||
moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
|
||||
}
|
||||
if (moveSlotTimeSelect) {
|
||||
moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
|
||||
}
|
||||
hideAlert('schedule-move-slot-alert');
|
||||
closeVisualContextMenu();
|
||||
moveSlotModal?.classList.add('open');
|
||||
moveSlotModal?.setAttribute('aria-hidden', 'false');
|
||||
moveSlotDaySelect?.focus();
|
||||
}
|
||||
|
||||
function closeMoveSlotModal() {
|
||||
pendingSlotMove = null;
|
||||
moveSlotModal?.classList.remove('open');
|
||||
moveSlotModal?.setAttribute('aria-hidden', 'true');
|
||||
hideAlert('schedule-move-slot-alert');
|
||||
}
|
||||
|
||||
async function saveVisualSlotMove(event) {
|
||||
event.preventDefault();
|
||||
if (!pendingSlotMove) return;
|
||||
hideAlert('schedule-move-slot-alert');
|
||||
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
||||
if (!rule || !slot) {
|
||||
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
const dayOfWeek = Number(moveSlotDaySelect?.value);
|
||||
const timeSlotId = Number(moveSlotTimeSelect?.value);
|
||||
if (!dayOfWeek || !timeSlotId) {
|
||||
showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousButtonText = moveSlotSubmitButton?.textContent || '';
|
||||
if (moveSlotSubmitButton) {
|
||||
moveSlotSubmitButton.disabled = true;
|
||||
moveSlotSubmitButton.textContent = 'Сохраняем...';
|
||||
}
|
||||
try {
|
||||
const slots = (rule.slots || []).map(ruleSlotToPayload);
|
||||
slots[pendingSlotMove.slotIndex] = {
|
||||
...slots[pendingSlotMove.slotIndex],
|
||||
dayOfWeek,
|
||||
timeSlotId
|
||||
};
|
||||
const payload = buildRulePayloadFromRule(rule, slots);
|
||||
const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
|
||||
closeMoveSlotModal();
|
||||
showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
if (isScheduleRuleConflictError(error)) {
|
||||
showAlert(
|
||||
'schedule-move-slot-alert',
|
||||
`Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
|
||||
} finally {
|
||||
if (moveSlotSubmitButton) {
|
||||
moveSlotSubmitButton.disabled = false;
|
||||
moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findRuleSlotIndex(rule, target) {
|
||||
if (!rule?.slots?.length) return -1;
|
||||
if (target.slotId) {
|
||||
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
|
||||
if (byId >= 0) return byId;
|
||||
}
|
||||
const index = Number(target.slotIndex);
|
||||
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
|
||||
}
|
||||
|
||||
function ruleSlotToPayload(slot) {
|
||||
return {
|
||||
id: slot.id || null,
|
||||
dayOfWeek: Number(slot.dayOfWeek),
|
||||
parity: slot.parity || 'BOTH',
|
||||
timeSlotId: Number(slot.timeSlotId),
|
||||
subgroupId: slot.subgroupId || null,
|
||||
subgroupIds: slotSubgroupIds(slot),
|
||||
teacherId: Number(slot.teacherId),
|
||||
classroomId: Number(slot.classroomId),
|
||||
lessonTypeId: Number(slot.lessonTypeId),
|
||||
lessonFormat: slot.lessonFormat || 'Очно'
|
||||
};
|
||||
}
|
||||
|
||||
function syncVisualGroupFilter(allGroups) {
|
||||
ensureVisualGroupSelection(allGroups);
|
||||
if (!visualGroupFilter) return;
|
||||
@@ -547,6 +871,92 @@ export async function initSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultVisualPeriod() {
|
||||
const formSemester = semesterById(ruleSemesterSelect?.value);
|
||||
const ruleSemester = firstSemesterWithRules();
|
||||
const fallbackSemester = semesters[0];
|
||||
const semester = formSemester || ruleSemester || fallbackSemester;
|
||||
setVisualPeriodBySemesterId(semester?.id || '');
|
||||
}
|
||||
|
||||
function setVisualPeriodBySemesterId(semesterId) {
|
||||
const semester = semesterById(semesterId);
|
||||
selectedVisualSemesterId = semester ? String(semester.id) : '';
|
||||
selectedVisualYearId = semester ? String(semester.academicYearId) : '';
|
||||
populateVisualYearSelect();
|
||||
populateVisualSemesterSelect();
|
||||
}
|
||||
|
||||
function populateVisualYearSelect() {
|
||||
if (!visualYearSelect) return;
|
||||
visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
|
||||
visualYearSelect.value = selectedVisualYearId;
|
||||
} else if (academicYears.length) {
|
||||
selectedVisualYearId = String(academicYears[0].id);
|
||||
visualYearSelect.value = selectedVisualYearId;
|
||||
} else {
|
||||
selectedVisualYearId = '';
|
||||
visualYearSelect.value = '';
|
||||
}
|
||||
syncVisualPeriodSelects(visualYearSelect);
|
||||
}
|
||||
|
||||
function populateVisualSemesterSelect() {
|
||||
if (!visualSemesterSelect) return;
|
||||
const yearSemesters = semestersForYear(selectedVisualYearId);
|
||||
visualSemesterSelect.innerHTML = yearSemesters.length
|
||||
? '<option value="">Выберите семестр</option>' + yearSemesters
|
||||
.map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
|
||||
.join('')
|
||||
: '<option value="">Нет семестров</option>';
|
||||
|
||||
if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
|
||||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||
} else if (yearSemesters.length) {
|
||||
selectedVisualSemesterId = String(yearSemesters[0].id);
|
||||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||
} else {
|
||||
selectedVisualSemesterId = '';
|
||||
visualSemesterSelect.value = '';
|
||||
}
|
||||
syncVisualPeriodSelects(visualSemesterSelect);
|
||||
}
|
||||
|
||||
function syncVisualPeriodSelects(...selects) {
|
||||
syncingVisualPeriod = true;
|
||||
syncSelects(...selects);
|
||||
syncingVisualPeriod = false;
|
||||
}
|
||||
|
||||
function visualRulesForSelectedPeriod() {
|
||||
if (!selectedVisualSemesterId) return [];
|
||||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
}
|
||||
|
||||
function firstSemesterWithRules() {
|
||||
const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
|
||||
return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
|
||||
}
|
||||
|
||||
function semesterById(id) {
|
||||
return semesters.find(semester => String(semester.id) === String(id));
|
||||
}
|
||||
|
||||
function semestersForYear(yearId) {
|
||||
return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
|
||||
}
|
||||
|
||||
function firstSemesterForYear(yearId) {
|
||||
return semestersForYear(yearId)[0] || null;
|
||||
}
|
||||
|
||||
function visualPeriodLabel() {
|
||||
const semester = semesterById(selectedVisualSemesterId);
|
||||
return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
|
||||
}
|
||||
|
||||
function renderRuleSlotsSummary(slots) {
|
||||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||||
return slots.map(slot => {
|
||||
@@ -605,20 +1015,302 @@ export async function initSchedule() {
|
||||
async function saveRule(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('schedule-rule-alert');
|
||||
let payload = null;
|
||||
let id = '';
|
||||
try {
|
||||
const payload = buildRulePayload();
|
||||
const id = ruleIdInput.value;
|
||||
const saved = id
|
||||
? await api.put('/api/admin/schedule-rules/' + id, payload)
|
||||
: await api.post('/api/admin/schedule-rules', payload);
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
resetRuleForm();
|
||||
await loadRules();
|
||||
payload = buildRulePayload();
|
||||
id = ruleIdInput.value;
|
||||
const saved = await saveRulePayload(id, payload);
|
||||
await finishSuccessfulRuleSave(saved);
|
||||
} catch (error) {
|
||||
if (payload && isScheduleRuleConflictError(error)) {
|
||||
openConflictModal(error, payload, id);
|
||||
return;
|
||||
}
|
||||
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRulePayload(id, payload) {
|
||||
return id
|
||||
? api.put('/api/admin/schedule-rules/' + id, payload)
|
||||
: api.post('/api/admin/schedule-rules', payload);
|
||||
}
|
||||
|
||||
async function finishSuccessfulRuleSave(saved) {
|
||||
resetRuleForm();
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
await loadRules();
|
||||
}
|
||||
|
||||
function openConflictModal(error, pendingPayload, pendingRuleId) {
|
||||
const conflictRule = error.data.conflictRule;
|
||||
pendingConflictResolution = {
|
||||
conflictRule: clonePlain(conflictRule),
|
||||
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
|
||||
pendingPayload: clonePlain(pendingPayload),
|
||||
pendingRuleId: pendingRuleId || ''
|
||||
};
|
||||
if (conflictSummary) {
|
||||
conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
|
||||
}
|
||||
if (conflictNewRuleTitle) {
|
||||
conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
|
||||
}
|
||||
if (conflictNewRuleMeta) {
|
||||
conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
|
||||
}
|
||||
if (conflictExistingRuleTitle) {
|
||||
conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
|
||||
}
|
||||
if (conflictExistingRuleMeta) {
|
||||
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
|
||||
}
|
||||
renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
|
||||
renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
|
||||
hideAlert('schedule-conflict-alert');
|
||||
conflictModal?.classList.add('open');
|
||||
conflictModal?.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function closeConflictModal() {
|
||||
pendingConflictResolution = null;
|
||||
conflictModal?.classList.remove('open');
|
||||
conflictModal?.setAttribute('aria-hidden', 'true');
|
||||
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
|
||||
if (conflictReasons) {
|
||||
conflictReasons.hidden = true;
|
||||
conflictReasons.innerHTML = '';
|
||||
}
|
||||
hideAlert('schedule-conflict-alert');
|
||||
}
|
||||
|
||||
async function resolveScheduleRuleConflict(event) {
|
||||
event.preventDefault();
|
||||
if (!pendingConflictResolution) return;
|
||||
hideAlert('schedule-conflict-alert');
|
||||
const previousButtonText = conflictSubmitButton?.textContent || '';
|
||||
if (conflictSubmitButton) {
|
||||
conflictSubmitButton.disabled = true;
|
||||
conflictSubmitButton.textContent = 'Сохраняем...';
|
||||
}
|
||||
|
||||
try {
|
||||
const currentResolution = pendingConflictResolution;
|
||||
const conflictPayload = buildRulePayloadFromRule(
|
||||
currentResolution.conflictRule,
|
||||
readConflictSlots(true)
|
||||
);
|
||||
await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);
|
||||
|
||||
try {
|
||||
const saved = await saveRulePayload(
|
||||
currentResolution.pendingRuleId,
|
||||
currentResolution.pendingPayload
|
||||
);
|
||||
closeConflictModal();
|
||||
await finishSuccessfulRuleSave(saved);
|
||||
} catch (retryError) {
|
||||
if (isScheduleRuleConflictError(retryError)) {
|
||||
await loadRules();
|
||||
openConflictModal(
|
||||
retryError,
|
||||
currentResolution.pendingPayload,
|
||||
currentResolution.pendingRuleId
|
||||
);
|
||||
showAlert(
|
||||
'schedule-conflict-alert',
|
||||
'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isScheduleRuleConflictError(error)) {
|
||||
showAlert(
|
||||
'schedule-conflict-alert',
|
||||
`Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
|
||||
} finally {
|
||||
if (conflictSubmitButton) {
|
||||
conflictSubmitButton.disabled = false;
|
||||
conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflictReasons(fields, reasons) {
|
||||
if (!conflictReasons) return;
|
||||
const labels = Array.isArray(reasons) && reasons.length
|
||||
? reasons
|
||||
: (fields || []).map(conflictFieldLabel).filter(Boolean);
|
||||
if (!labels.length) {
|
||||
conflictReasons.hidden = true;
|
||||
conflictReasons.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
conflictReasons.hidden = false;
|
||||
conflictReasons.innerHTML = `
|
||||
<span>Причина:</span>
|
||||
${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConflictSlots(rule, conflictFields = []) {
|
||||
const slots = rule.slots || [];
|
||||
if (!conflictSlotsContainer) return;
|
||||
if (!slots.length) {
|
||||
conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
|
||||
return;
|
||||
}
|
||||
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
|
||||
const fixedParts = [
|
||||
slot.lessonTypeName,
|
||||
slot.lessonFormat,
|
||||
slotSubgroupNames(slot).join(', ')
|
||||
].filter(Boolean);
|
||||
const groupHitClass = conflictFieldClass('group', conflictFields);
|
||||
return `
|
||||
<section class="schedule-conflict-slot-row" data-index="${index}">
|
||||
<div class="schedule-conflict-slot-fixed ${groupHitClass}">
|
||||
<span>Слот ${index + 1}</span>
|
||||
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
|
||||
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
|
||||
</div>
|
||||
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function conflictFieldClass(field, fields) {
|
||||
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
|
||||
}
|
||||
|
||||
function conflictFieldLabel(field) {
|
||||
if (field === 'teacher') return 'Преподаватель';
|
||||
if (field === 'classroom') return 'Аудитория';
|
||||
if (field === 'group') return 'Группа';
|
||||
return field;
|
||||
}
|
||||
|
||||
function readConflictSlots(validate) {
|
||||
const conflictRule = pendingConflictResolution?.conflictRule;
|
||||
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
|
||||
return rows.map((row, index) => {
|
||||
const original = conflictRule?.slots?.[index] || {};
|
||||
const slot = {
|
||||
id: original.id || null,
|
||||
dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
|
||||
parity: row.querySelector('.conflict-slot-parity').value,
|
||||
timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
|
||||
subgroupId: original.subgroupId || null,
|
||||
subgroupIds: slotSubgroupIds(original),
|
||||
teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
|
||||
classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
|
||||
lessonTypeId: Number(original.lessonTypeId),
|
||||
lessonFormat: original.lessonFormat || 'Очно'
|
||||
};
|
||||
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
|
||||
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
|
||||
}
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
|
||||
function buildRulePayloadFromRule(rule, slots) {
|
||||
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
|
||||
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
|
||||
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
|
||||
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
|
||||
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
|
||||
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
|
||||
return {
|
||||
id: Number(rule.id),
|
||||
subjectId: Number(rule.subjectId),
|
||||
semesterId: Number(rule.semesterId),
|
||||
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
||||
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
||||
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
||||
lectureStartWeek: Number(rule.lectureStartWeek || 1),
|
||||
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
|
||||
practiceStartWeek: Number(rule.practiceStartWeek || 1),
|
||||
groupIds,
|
||||
slots
|
||||
};
|
||||
}
|
||||
|
||||
function isScheduleRuleConflictError(error) {
|
||||
return error?.status === 409 && Boolean(error.data?.conflictRule);
|
||||
}
|
||||
|
||||
function clonePlain(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function ruleDraftTitle(payload) {
|
||||
const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
|
||||
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
|
||||
}
|
||||
|
||||
function ruleTitle(rule) {
|
||||
return rule?.subjectName || ruleDraftTitle(rule || {});
|
||||
}
|
||||
|
||||
function ruleDraftMeta(payload) {
|
||||
const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
|
||||
return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function ruleMeta(rule) {
|
||||
const groupNames = (rule.groupNames || []).join(', ')
|
||||
|| (rule.groupIds || []).map(groupNameById).join(', ')
|
||||
|| 'группы не выбраны';
|
||||
return [formatSemester(rule), groupNames, typeLoadText(rule)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function typeLoadText(rule) {
|
||||
return LESSON_TYPE_LIMITS
|
||||
.map(config => {
|
||||
const hours = Number(rule?.[config.hoursField] || 0);
|
||||
const startWeek = Number(rule?.[config.startWeekField] || 1);
|
||||
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function semesterTextById(semesterId) {
|
||||
const semester = semesterById(semesterId);
|
||||
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
|
||||
}
|
||||
|
||||
function conflictSlotCurrentText(slot) {
|
||||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||||
return [
|
||||
slot.dayName,
|
||||
slot.timeSlotLabel,
|
||||
parity,
|
||||
slot.teacherName,
|
||||
slot.classroomName
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function buildRulePayload() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
@@ -687,10 +1379,10 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -1022,6 +1714,23 @@ export async function initSchedule() {
|
||||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||||
}
|
||||
|
||||
function orderedLessonTypes() {
|
||||
return [...lessonTypes].sort(compareLessonTypes);
|
||||
}
|
||||
|
||||
function compareLessonTypes(first, second) {
|
||||
return lessonTypeRank(first) - lessonTypeRank(second)
|
||||
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label);
|
||||
}
|
||||
|
||||
function lessonTypeRank(lessonType) {
|
||||
const key = lessonTypeKey(lessonType);
|
||||
if (key === 'lecture') return 0;
|
||||
if (key === 'laboratory') return 1;
|
||||
if (key === 'practice') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function lessonTypeKeyById(lessonTypeId) {
|
||||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||
@@ -1048,6 +1757,11 @@ export async function initSchedule() {
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
}
|
||||
|
||||
function shortSemesterLabel(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
|
||||
}
|
||||
|
||||
function formatSemester(rule) {
|
||||
const type = normalizeSemesterType(rule.semesterType);
|
||||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||||
@@ -1106,16 +1820,34 @@ export async function initSchedule() {
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function compactWeekRanges(weeks) {
|
||||
function compactWeekRanges(weeks, maxWeeks, parity) {
|
||||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||||
if (!uniqueWeeks.length) return '';
|
||||
return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`;
|
||||
const start = uniqueWeeks[0];
|
||||
const end = uniqueWeeks[uniqueWeeks.length - 1];
|
||||
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
|
||||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
|
||||
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
|
||||
return weekRangeText(start, end);
|
||||
}
|
||||
|
||||
function weekRangeText(start, end) {
|
||||
return start === end
|
||||
? `${start} нед.`
|
||||
: `с ${start} по ${end} нед.`;
|
||||
? `(${start} нед.)`
|
||||
: `(с ${start} по ${end} нед.)`;
|
||||
}
|
||||
|
||||
function firstApplicableSemesterWeek(parity) {
|
||||
return parity === 'EVEN' ? 2 : 1;
|
||||
}
|
||||
|
||||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||||
const totalWeeks = Number(maxWeeks || 0);
|
||||
if (!totalWeeks) return 0;
|
||||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||||
return totalWeeks;
|
||||
}
|
||||
|
||||
function compareSlotItems(first, second) {
|
||||
|
||||
@@ -100,8 +100,8 @@ export async function initSubjects() {
|
||||
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
|
||||
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button class="btn-primary btn-manage-teachers" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Преподаватели</button>
|
||||
<button class="btn-delete btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -175,7 +175,7 @@ export async function initSubjects() {
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
|
||||
<td>
|
||||
<button class="btn-delete btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
136
frontend/admin/js/views/teacher-requests.js
Normal file
136
frontend/admin/js/views/teacher-requests.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||||
|
||||
export async function initTeacherRequests() {
|
||||
const requestsTbody = document.getElementById('teacher-requests-tbody');
|
||||
const refreshButton = document.getElementById('teacher-requests-refresh');
|
||||
const requestsCount = document.getElementById('teacher-requests-count');
|
||||
let departments = [];
|
||||
|
||||
async function loadDepartments() {
|
||||
departments = await api.get('/api/departments');
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
hideAlert('teacher-requests-alert');
|
||||
try {
|
||||
const requests = await api.get('/api/teacher-requests?status=PENDING');
|
||||
const normalizedRequests = Array.isArray(requests) ? requests : [];
|
||||
renderRequests(normalizedRequests);
|
||||
updateCount(normalizedRequests.length);
|
||||
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
|
||||
detail: { count: normalizedRequests.length }
|
||||
}));
|
||||
} catch (error) {
|
||||
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
|
||||
updateCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequests(requests) {
|
||||
if (!requests.length) {
|
||||
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
|
||||
return;
|
||||
}
|
||||
requestsTbody.innerHTML = requests.map(request => `
|
||||
<tr data-request-id="${escapeHtml(request.id)}">
|
||||
<td>
|
||||
<select class="teacher-request-department" data-native-select="true">
|
||||
${departments.map(department => `
|
||||
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
|
||||
${escapeHtml(department.departmentName || department.name || department.id)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
|
||||
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
|
||||
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
|
||||
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
|
||||
<td>${escapeHtml(request.comment || '-')}</td>
|
||||
<td>
|
||||
<div class="teacher-request-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateCount(count) {
|
||||
if (!requestsCount) return;
|
||||
requestsCount.textContent = requestCountLabel(count);
|
||||
}
|
||||
|
||||
function requestCountLabel(count) {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
|
||||
return `${count} заявок`;
|
||||
}
|
||||
|
||||
async function approveRequest(row, requestId) {
|
||||
const payload = {
|
||||
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
|
||||
username: row.querySelector('.teacher-request-username')?.value.trim(),
|
||||
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
|
||||
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
|
||||
password: row.querySelector('.teacher-request-password')?.value
|
||||
};
|
||||
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
|
||||
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
|
||||
return;
|
||||
}
|
||||
if (payload.password.length < 8) {
|
||||
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
|
||||
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectRequest(requestId) {
|
||||
const reviewComment = prompt('Причина отклонения');
|
||||
if (reviewComment === null) return;
|
||||
try {
|
||||
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
|
||||
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
refreshButton?.addEventListener('click', loadRequests);
|
||||
|
||||
requestsTbody.addEventListener('click', async (event) => {
|
||||
const approveButton = event.target.closest('.teacher-request-approve');
|
||||
const rejectButton = event.target.closest('.teacher-request-reject');
|
||||
if (!approveButton && !rejectButton) return;
|
||||
|
||||
const row = event.target.closest('tr[data-request-id]');
|
||||
const requestId = row?.dataset.requestId;
|
||||
if (!requestId) return;
|
||||
|
||||
if (approveButton) {
|
||||
await approveRequest(row, requestId);
|
||||
return;
|
||||
}
|
||||
await rejectRequest(requestId);
|
||||
});
|
||||
|
||||
try {
|
||||
await loadDepartments();
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
@@ -53,41 +53,26 @@ export async function initUniversityStructure() {
|
||||
let allProfiles = [];
|
||||
|
||||
// --- Логика переключения табов ---
|
||||
function deactivateTabs() {
|
||||
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => {
|
||||
if (tab) {
|
||||
tab.classList.remove('active');
|
||||
tab.style.borderBottomColor = 'transparent';
|
||||
tab.style.color = 'var(--text-secondary)';
|
||||
const structureTabs = [
|
||||
{ button: tabDepartments, panel: contentDepartments },
|
||||
{ button: tabSpecialties, panel: contentSpecialties },
|
||||
{ button: tabProfiles, panel: contentProfiles },
|
||||
];
|
||||
|
||||
function activateStructureTab(activeButton) {
|
||||
structureTabs.forEach(({ button, panel }) => {
|
||||
const active = button === activeButton;
|
||||
button?.classList.toggle('active', active);
|
||||
button?.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
if (panel) {
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
}
|
||||
});
|
||||
[contentDepartments, contentSpecialties, contentProfiles].forEach(content => {
|
||||
if (content) content.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
tabDepartments.addEventListener('click', () => {
|
||||
deactivateTabs();
|
||||
tabDepartments.classList.add('active');
|
||||
tabDepartments.style.borderBottomColor = 'var(--primary)';
|
||||
tabDepartments.style.color = 'var(--text-main)';
|
||||
contentDepartments.style.display = 'block';
|
||||
});
|
||||
|
||||
tabSpecialties.addEventListener('click', () => {
|
||||
deactivateTabs();
|
||||
tabSpecialties.classList.add('active');
|
||||
tabSpecialties.style.borderBottomColor = 'var(--primary)';
|
||||
tabSpecialties.style.color = 'var(--text-main)';
|
||||
contentSpecialties.style.display = 'block';
|
||||
});
|
||||
|
||||
tabProfiles.addEventListener('click', () => {
|
||||
deactivateTabs();
|
||||
tabProfiles.classList.add('active');
|
||||
tabProfiles.style.borderBottomColor = 'var(--primary)';
|
||||
tabProfiles.style.color = 'var(--text-main)';
|
||||
contentProfiles.style.display = 'block';
|
||||
structureTabs.forEach(({ button }) => {
|
||||
button?.addEventListener('click', () => activateStructureTab(button));
|
||||
});
|
||||
|
||||
// --- Загрузка данных ---
|
||||
@@ -129,8 +114,8 @@ export async function initUniversityStructure() {
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -147,9 +132,9 @@ export async function initUniversityStructure() {
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||
<button class="btn-primary btn-manage-profiles" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Профили</button>
|
||||
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -338,8 +323,8 @@ export async function initUniversityStructure() {
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-profile" data-id="${p.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -453,8 +438,8 @@ export async function initUniversityStructure() {
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function initUsers() {
|
||||
<td>
|
||||
${u.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -71,6 +71,10 @@ export async function initUsers() {
|
||||
showAlert('create-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/users', {
|
||||
@@ -90,7 +94,7 @@ export async function initUsers() {
|
||||
});
|
||||
|
||||
usersTbody.addEventListener('click', async (e) => {
|
||||
const deleteBtn = e.target.closest('.btn-delete');
|
||||
const deleteBtn = e.target.closest('.btn-archive-user');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
|
||||
|
||||
@@ -25,15 +25,7 @@
|
||||
}
|
||||
|
||||
.sidebar-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
@@ -265,12 +257,7 @@
|
||||
/* ===== Mobile Menu Toggle ===== */
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
padding: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-placeholder: #94a3b8;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent: #4F46E5;
|
||||
--accent-hover: #4338CA;
|
||||
--accent-glow: rgba(79, 70, 229, 0.3);
|
||||
--accent-secondary: #d946ef;
|
||||
--error: #ef4444;
|
||||
--success: #10b981;
|
||||
@@ -77,20 +77,19 @@ body {
|
||||
|
||||
/* ===== Переключатель темы ===== */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--button-radius);
|
||||
background: var(--button-secondary-bg);
|
||||
border: 1px solid var(--button-secondary-border);
|
||||
color: var(--button-secondary-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||
padding: 0;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
||||
z-index: 100;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -102,12 +101,18 @@ body {
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
background: var(--button-secondary-bg-hover);
|
||||
border-color: var(--button-secondary-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--button-focus-ring);
|
||||
}
|
||||
|
||||
.theme-toggle--fixed {
|
||||
@@ -265,63 +270,6 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-delete {
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition), background var(--transition), border-color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
border-color: var(--bg-card-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: rgba(239, 68, 68, 0.13);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-delete {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-delete:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.form-alert {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 0;
|
||||
@@ -614,7 +562,7 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-form .btn-primary {
|
||||
.settings-form .btn {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -630,9 +578,16 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header-row .btn-primary,
|
||||
.card-header-row .btn-secondary,
|
||||
.settings-form .btn-primary {
|
||||
.settings-header-row .btn,
|
||||
.card-header-row .btn,
|
||||
.settings-form .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.settings-modal-close.btn-icon-sm {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-height: 32px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--button-radius) !important;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</svg>
|
||||
<span>Настройки</span>
|
||||
</div>
|
||||
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
@@ -94,7 +94,7 @@
|
||||
<!-- Основная область -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function initDatabase() {
|
||||
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
|
||||
<td>${escapeHtml(t.username || '—')}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td><button class="btn-delete" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export async function initDatabase() {
|
||||
|
||||
// === Удаление тенанта ===
|
||||
tenantsTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete');
|
||||
const btn = e.target.closest('.btn-delete-tenant');
|
||||
if (!btn) return;
|
||||
|
||||
const domain = btn.dataset.domain;
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function initEduForms() {
|
||||
<tr>
|
||||
<td>${ef.id}</td>
|
||||
<td>${escapeHtml(ef.name)}</td>
|
||||
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function initEduForms() {
|
||||
});
|
||||
|
||||
efTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete');
|
||||
const btn = e.target.closest('.btn-delete-edu-form');
|
||||
if (!btn) return;
|
||||
if (!confirm('Удалить форму обучения?')) return;
|
||||
try {
|
||||
|
||||
@@ -143,8 +143,8 @@ export async function initTimeSlotsSettings() {
|
||||
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
||||
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||
<button type="button" class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
@@ -61,10 +61,10 @@
|
||||
<label for="tenant-password">Пароль</label>
|
||||
<input type="password" id="tenant-password" placeholder="••••••••" required>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="btn-test-connection" style="height: fit-content;">
|
||||
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
|
||||
Тест
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" style="height: fit-content;">
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<label for="new-ef-name">Название</label>
|
||||
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-ef-alert" role="alert"></div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h2>Временные слоты</h2>
|
||||
<p>Настройка базовой, субботней и ручных сеток времени.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="time-slots-refresh">Обновить</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="time-slots-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
@@ -12,8 +12,8 @@
|
||||
<div class="card-header-row">
|
||||
<h3>Сетка времени</h3>
|
||||
<div class="scope-header-actions">
|
||||
<button type="button" class="btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
|
||||
<button type="button" class="btn-secondary" id="time-slot-scope-delete">Удалить сетку</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<article class="settings-card">
|
||||
<div class="card-header-row">
|
||||
<h3 id="time-slot-form-title">Новый слот</h3>
|
||||
<button type="button" class="btn-secondary" id="time-slot-reset">Очистить</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="time-slot-reset">Очистить</button>
|
||||
</div>
|
||||
|
||||
<form id="time-slot-form" class="settings-form" novalidate>
|
||||
@@ -73,7 +73,7 @@
|
||||
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary">Сохранить слот</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>
|
||||
<div class="form-alert" id="time-slot-alert" role="alert"></div>
|
||||
</form>
|
||||
</article>
|
||||
@@ -106,7 +106,12 @@
|
||||
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
|
||||
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
|
||||
</div>
|
||||
<button type="button" class="settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">×</button>
|
||||
<button type="button" class="btn btn-icon-sm btn-secondary settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="time-slot-scope-form" class="settings-modal-form" novalidate>
|
||||
@@ -116,8 +121,8 @@
|
||||
</label>
|
||||
|
||||
<div class="settings-modal-actions">
|
||||
<button type="submit" class="btn-primary">Создать сетку</button>
|
||||
<button type="button" class="btn-secondary" id="time-slot-scope-modal-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
|
||||
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
|
||||
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
|
||||
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
|
||||
</div>
|
||||
|
||||
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
|
||||
<div class="card create-card">
|
||||
<h2 id="academic-year-form-title">Учебный год</h2>
|
||||
<form id="academic-year-form" novalidate>
|
||||
@@ -15,8 +22,8 @@
|
||||
<label for="academic-year-end">Окончание</label>
|
||||
<input type="date" id="academic-year-end" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить год</button>
|
||||
<button type="button" class="btn-edit-classroom" id="academic-year-reset">Очистить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-year-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -25,7 +32,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Учебные годы</h2>
|
||||
<button class="btn-primary" id="academic-years-refresh">Обновить</button>
|
||||
<button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@@ -72,8 +79,8 @@
|
||||
<label for="semester-end">Окончание</label>
|
||||
<input type="date" id="semester-end" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить семестр</button>
|
||||
<button type="button" class="btn-edit-classroom" id="semester-reset">Очистить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="semester-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -82,7 +89,7 @@
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
|
||||
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
|
||||
</div>
|
||||
<form id="academic-calendar-form" novalidate>
|
||||
<input type="hidden" id="academic-calendar-id">
|
||||
@@ -119,7 +126,7 @@
|
||||
<label for="academic-calendar-course-count">Курсов</label>
|
||||
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить график</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
|
||||
</div>
|
||||
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -128,7 +135,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Календарные графики</h2>
|
||||
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button>
|
||||
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@@ -152,6 +159,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-grid" data-calendar-tab-panel="grid" role="tabpanel" hidden>
|
||||
<div class="card create-card">
|
||||
<h2>Редактор годового графика</h2>
|
||||
<div class="form-row">
|
||||
@@ -161,8 +171,8 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button>
|
||||
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-editor-load">Загрузить сетку</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-editor-save">Сохранить сетку</button>
|
||||
</div>
|
||||
<div class="calendar-fill-panel">
|
||||
<div class="form-group">
|
||||
@@ -185,12 +195,55 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-fill-apply">Заполнить диапазон</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
|
||||
<div id="calendar-totals" class="calendar-totals"></div>
|
||||
<div id="calendar-grid" class="academic-calendar-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="academic-calendar-tab-panel" id="academic-calendar-tab-subjects" data-calendar-tab-panel="subjects" role="tabpanel" hidden>
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Дисциплины графика</h2>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-calendar">График</label>
|
||||
<select id="calendar-subject-calendar">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-semester">Семестр</label>
|
||||
<select id="calendar-subject-semester">
|
||||
<option value="">Выберите график</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-id">Дисциплина</label>
|
||||
<select id="calendar-subject-id">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="calendar-subject-add">Добавить</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-subject-save">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-subject-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Привязки по семестрам</h2>
|
||||
<span id="calendar-subject-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="calendar-subject-summary" class="calendar-subject-summary">
|
||||
<div class="loading-row">Выберите календарный график</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-overlay" id="calendar-day-dialog">
|
||||
<div class="modal-content card calendar-day-dialog-card">
|
||||
@@ -229,8 +282,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-actions">
|
||||
<button type="button" class="btn-primary" id="calendar-day-dialog-time-save">Применить</button>
|
||||
<button type="button" class="btn-edit-classroom" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
|
||||
<button type="button" class="btn btn-md btn-primary" id="calendar-day-dialog-time-save">Применить</button>
|
||||
<button type="button" class="btn btn-md btn-danger-subtle" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row" style="margin-bottom: 1.5rem;">
|
||||
<h2>Загруженность</h2>
|
||||
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="workload-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="card create-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h2 style="margin: 0;">Новая аудитория</h2>
|
||||
<button type="button" class="btn-primary" id="btn-manage-equipments">Справочник оборудования</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="btn-manage-equipments">Справочник оборудования</button>
|
||||
</div>
|
||||
<form id="create-classroom-form">
|
||||
<div class="form-row">
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary">Добавить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="create-classroom-alert" role="alert"></div>
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
|
||||
@@ -128,7 +128,7 @@
|
||||
<label for="manage-equipment-name">Название оборудования</label>
|
||||
<input type="text" id="manage-equipment-name" placeholder="Проектор" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-equipment-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -152,4 +152,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- ===== Dashboard Page ===== -->
|
||||
<div class="dashboard-container" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<div class="dashboard-container">
|
||||
<!-- Метрики (Информационные карточки) -->
|
||||
<div class="metrics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem;">
|
||||
<div class="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid var(--primary);">
|
||||
<div class="metric-icon" style="background: rgba(99, 102, 241, 0.1); color: var(--primary); padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="dashboard-metrics-grid">
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--groups">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #10b981;">
|
||||
<div class="metric-icon" style="background: rgba(16, 185, 129, 0.1); color: #10b981; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--students">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
@@ -32,8 +32,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #f59e0b;">
|
||||
<div class="metric-icon" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--teachers">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
@@ -45,8 +45,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #3b82f6;">
|
||||
<div class="metric-icon" style="background: rgba(59, 130, 246, 0.1); color: #3b82f6; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card dashboard-metric-card dashboard-metric-card--classrooms">
|
||||
<div class="metric-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
@@ -60,14 +60,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Мониторинг в реальном времени и Быстрый переход -->
|
||||
<div class="dashboard-middle" style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.25rem; align-items: start;">
|
||||
<div class="dashboard-middle">
|
||||
<!-- Идет сейчас -->
|
||||
<div class="card" style="min-height: 250px; display: flex; flex-direction: column;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<div class="card dashboard-monitor-card">
|
||||
<div class="dashboard-card-header">
|
||||
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
|
||||
<div id="current-time-slot" style="background: var(--bg-secondary); padding: 0.35rem 0.75rem; border-radius: 20px; font-size: 0.85rem; font-weight: 600; color: var(--primary);">—</div>
|
||||
<div id="current-time-slot" class="dashboard-time-slot">—</div>
|
||||
</div>
|
||||
<div class="table-wrap" style="flex: 1;">
|
||||
<div class="table-wrap dashboard-monitor-table">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -87,20 +87,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Свободные аудитории и Быстрые действия -->
|
||||
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
|
||||
<div class="dashboard-side-stack">
|
||||
<!-- Свободные аудитории -->
|
||||
<div class="card" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600;">Свободные аудитории (ближайшие)</h3>
|
||||
<div id="free-classrooms-list" style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<div class="card dashboard-compact-card">
|
||||
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
|
||||
<div id="free-classrooms-list" class="dashboard-badge-list">
|
||||
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые действия -->
|
||||
<div class="card" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600;">Быстрый переход</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<a href="#schedule" class="btn-primary" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; text-align: center; font-size: 0.9rem; padding: 0.6rem;">
|
||||
<div class="card dashboard-compact-card">
|
||||
<h3 class="dashboard-card-title">Быстрый переход</h3>
|
||||
<div class="dashboard-actions">
|
||||
<a href="#schedule" class="btn btn-md btn-primary dashboard-action-link">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
@@ -109,7 +109,7 @@
|
||||
</svg>
|
||||
Конструктор расписания
|
||||
</a>
|
||||
<a href="#schedule-view" class="btn-secondary" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; text-align: center; font-size: 0.9rem; padding: 0.6rem;">
|
||||
<a href="#schedule-view" class="btn btn-md btn-secondary dashboard-action-link">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -135,7 +135,7 @@
|
||||
</h2>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-recheck-conflicts" style="font-size: 0.85rem; padding: 0.35rem 0.75rem;">Перепроверить</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts">Перепроверить</button>
|
||||
</div>
|
||||
|
||||
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="card">
|
||||
<div class="card department-workspace-control-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Рабочая область кафедры</h2>
|
||||
<button type="button" class="btn-primary" id="department-workspace-refresh">Обновить</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-view-filter-grid">
|
||||
<div class="department-workspace-filter-grid">
|
||||
<div class="form-group" id="department-workspace-department-field">
|
||||
<label for="department-workspace-department">Кафедра</label>
|
||||
<select id="department-workspace-department" data-native-select="true">
|
||||
@@ -24,8 +24,8 @@
|
||||
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
|
||||
</div>
|
||||
|
||||
<div class="department-workspace-grid">
|
||||
<div class="card create-card">
|
||||
<div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
|
||||
<div class="card create-card department-subject-import-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Загрузка дисциплин</h2>
|
||||
</div>
|
||||
@@ -33,16 +33,62 @@
|
||||
<div class="subject-import-slots-panel">
|
||||
<div class="card-header-row">
|
||||
<label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
|
||||
<button type="button" class="btn-edit-classroom" id="subject-import-add">Добавить строку</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="subject-import-add">Добавить строку</button>
|
||||
</div>
|
||||
<div id="subject-import-rows" class="subject-import-rows-list"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Загрузить</button>
|
||||
<button type="submit" class="btn btn-md btn-secondary">Загрузить</button>
|
||||
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card create-card department-teacher-assign-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Добавить преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-assign-form" class="stack-form">
|
||||
<div class="form-group">
|
||||
<label for="department-teacher-select">Преподаватель</label>
|
||||
<select id="department-teacher-select" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="department-teacher-assign-comment">Комментарий</label>
|
||||
<input type="text" id="department-teacher-assign-comment" placeholder="Например, ведет лабораторные работы">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-secondary">Привязать к кафедре</button>
|
||||
<div class="form-alert" id="department-teacher-assign-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card create-card department-teacher-request-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявка на преподавателя</h2>
|
||||
</div>
|
||||
<form id="department-teacher-request-form" class="stack-form">
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-username">Логин</label>
|
||||
<input type="text" id="teacher-request-username" placeholder="ivanov_teacher" maxlength="50" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-fullname">ФИО</label>
|
||||
<input type="text" id="teacher-request-fullname" placeholder="Иванов Иван Иванович" maxlength="255" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-jobtitle">Должность</label>
|
||||
<input type="text" id="teacher-request-jobtitle" placeholder="Доцент" maxlength="255">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="teacher-request-comment">Комментарий</label>
|
||||
<input type="text" id="teacher-request-comment" placeholder="Что важно проверить администратору">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-md btn-primary">Отправить заявку</button>
|
||||
<div class="form-alert" id="department-teacher-request-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card department-teachers-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Преподаватели и нагрузка</h2>
|
||||
</div>
|
||||
@@ -50,7 +96,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card department-workspace-table-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявки кафедры</h2>
|
||||
<span class="muted" id="department-teacher-request-count">0 заявок</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Статус</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Рассмотрение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="department-teacher-requests-tbody">
|
||||
<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card department-workspace-table-card">
|
||||
<div class="card-header-row">
|
||||
<h2>Дисциплины кафедры</h2>
|
||||
<span class="muted" id="department-subject-count">0 дисциплин</span>
|
||||
@@ -72,10 +142,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="department-comments-card" hidden>
|
||||
<div class="card department-workspace-table-card" id="department-comments-card" hidden>
|
||||
<div class="card-header-row">
|
||||
<h2 id="department-comments-title">Комментарии</h2>
|
||||
<button type="button" class="btn-edit-classroom" id="department-comments-close">Скрыть</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>
|
||||
</div>
|
||||
|
||||
<form id="department-comment-form" class="form-row">
|
||||
@@ -84,7 +154,7 @@
|
||||
<label for="department-comment-text">Новый комментарий</label>
|
||||
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</form>
|
||||
<div class="form-alert" id="department-comment-alert" role="alert"></div>
|
||||
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -49,10 +49,26 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Все группы</h2>
|
||||
<div class="filter-row">
|
||||
<label for="filter-ef">Фильтр:</label>
|
||||
<select id="filter-ef">
|
||||
<option value="">Все формы</option>
|
||||
</select>
|
||||
<label for="filter-ef-box">Фильтр:</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
|
||||
<span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="filter-ef-menu">
|
||||
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="all" checked>
|
||||
<span class="checkmark"></span>
|
||||
<span>Все</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -82,12 +98,12 @@
|
||||
|
||||
<!-- Модальное окно редактирования группы -->
|
||||
<div class="modal-overlay" id="modal-edit-group">
|
||||
<div class="modal-content card">
|
||||
<div class="modal-content card edit-group-modal">
|
||||
<h2>Редактировать группу</h2>
|
||||
<button class="modal-close" id="modal-edit-group-close">×</button>
|
||||
<form id="edit-group-form">
|
||||
<input type="hidden" id="edit-group-id">
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-row edit-group-form-row edit-group-form-row-main">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-name">Название группы</label>
|
||||
<input type="text" id="edit-group-name" required>
|
||||
@@ -103,7 +119,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 1rem;">
|
||||
<div class="form-row edit-group-form-row edit-group-form-row-details">
|
||||
<div class="form-group">
|
||||
<label for="edit-group-department">Кафедра</label>
|
||||
<select id="edit-group-department">
|
||||
@@ -126,8 +142,8 @@
|
||||
<option value="">Выберите специальность</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button>
|
||||
<div class="form-group edit-group-submit">
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-group-alert" role="alert"></div>
|
||||
@@ -152,18 +168,19 @@
|
||||
<form id="manage-subgroup-form">
|
||||
<input type="hidden" id="manage-subgroup-id">
|
||||
<input type="hidden" id="manage-subgroup-group-id">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-name">Название подгруппы</label>
|
||||
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required>
|
||||
<div class="subgroup-config-grid">
|
||||
<div class="form-group subgroup-count-field">
|
||||
<label for="manage-subgroup-count">Количество подгрупп</label>
|
||||
<select id="manage-subgroup-count">
|
||||
<option value="0">Без разделения на подгруппы</option>
|
||||
<option value="2">Две подгруппы</option>
|
||||
<option value="3">Три подгруппы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-capacity">Численность (чел.)</label>
|
||||
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
|
||||
<button type="submit" class="btn-primary" id="btn-save-subgroup">Создать</button>
|
||||
<button type="button" class="btn-secondary" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
|
||||
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
|
||||
<div class="subgroup-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
|
||||
@@ -192,7 +209,7 @@
|
||||
|
||||
<!-- Модальное окно назначения Календарного графика -->
|
||||
<div class="modal-overlay" id="modal-manage-calendar">
|
||||
<div class="modal-content card" style="max-width: 800px; width: 90%;">
|
||||
<div class="modal-content card" style="max-width: 960px; width: 90%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h2>Календарные графики группы</h2>
|
||||
@@ -219,7 +236,7 @@
|
||||
<option value="">Выберите учебный год</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-calendar-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -232,12 +249,13 @@
|
||||
<tr>
|
||||
<th>Учебный год</th>
|
||||
<th>График</th>
|
||||
<th>Дисциплины</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-calendar-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2>Расписания</h2>
|
||||
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button>
|
||||
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-load">Показать</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-view-control-grid">
|
||||
@@ -87,9 +87,17 @@
|
||||
<span class="muted" id="schedule-view-count">0 занятий</span>
|
||||
</div>
|
||||
<div class="schedule-view-result-actions">
|
||||
<button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">‹</button>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="schedule-view-active-label">Нет выбранного расписания</span>
|
||||
<button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">›</button>
|
||||
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="card create-card">
|
||||
<div class="card-header-row">
|
||||
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-rule-reset">Очистить форму</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
|
||||
</div>
|
||||
<form id="schedule-rule-form" novalidate>
|
||||
<input type="hidden" id="schedule-rule-id">
|
||||
@@ -79,22 +79,88 @@
|
||||
<div class="schedule-slots-panel">
|
||||
<div class="card-header-row">
|
||||
<h2>Слоты правила</h2>
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-slot-add">Добавить слот</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-slot-add">Добавить слот</button>
|
||||
</div>
|
||||
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row schedule-rule-actions">
|
||||
<button type="submit" class="btn-primary">Сохранить правило</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить правило</button>
|
||||
</div>
|
||||
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-content schedule-conflict-card">
|
||||
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">×</button>
|
||||
<div class="schedule-conflict-head">
|
||||
<span class="schedule-conflict-kicker">Конфликт слота</span>
|
||||
<h2>Освободите место для нового правила</h2>
|
||||
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
|
||||
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
|
||||
</div>
|
||||
<div class="schedule-conflict-rules">
|
||||
<section class="schedule-conflict-rule-box">
|
||||
<span>Новое правило</span>
|
||||
<strong id="schedule-conflict-new-rule">-</strong>
|
||||
<small id="schedule-conflict-new-rule-meta">-</small>
|
||||
</section>
|
||||
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
|
||||
<span>Нужно изменить</span>
|
||||
<strong id="schedule-conflict-existing-rule">-</strong>
|
||||
<small id="schedule-conflict-existing-rule-meta">-</small>
|
||||
</section>
|
||||
</div>
|
||||
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
|
||||
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
|
||||
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
|
||||
<div class="schedule-conflict-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
|
||||
<button type="button" data-action="edit-rule">Открыть правило</button>
|
||||
<button type="button" data-action="move-slot">Изменить день и пару</button>
|
||||
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-content schedule-move-slot-card">
|
||||
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">×</button>
|
||||
<div class="schedule-conflict-head">
|
||||
<span class="schedule-conflict-kicker">Перенос слота</span>
|
||||
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
|
||||
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
|
||||
</div>
|
||||
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-move-slot-day">День</label>
|
||||
<select id="schedule-move-slot-day" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-move-slot-time">Пара</label>
|
||||
<select id="schedule-move-slot-time" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
|
||||
<div class="schedule-conflict-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Правила динамического расписания</h2>
|
||||
<button class="btn-primary" id="schedule-refresh">Обновить</button>
|
||||
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="schedule-table">
|
||||
@@ -119,10 +185,15 @@
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="schedule-visual-handle"
|
||||
class="btn btn-icon-md btn-secondary schedule-visual-handle"
|
||||
id="schedule-visual-toggle"
|
||||
aria-label="Показать визуальное расписание"
|
||||
aria-expanded="false">‹</button>
|
||||
aria-expanded="false">
|
||||
<svg class="schedule-visual-handle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
|
||||
|
||||
@@ -133,14 +204,28 @@
|
||||
<h2>Визуальное расписание групп</h2>
|
||||
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
|
||||
</div>
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-close">Свернуть</button>
|
||||
</div>
|
||||
<div class="schedule-visual-toolbar">
|
||||
<div class="schedule-visual-period-grid">
|
||||
<div class="form-group">
|
||||
<label for="schedule-visual-year">Учебный год</label>
|
||||
<select id="schedule-visual-year">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-visual-semester">Семестр</label>
|
||||
<select id="schedule-visual-semester">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-visual-filter-head">
|
||||
<span>Группы</span>
|
||||
<div>
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-visual-use-form">Из формы</button>
|
||||
<button type="button" class="btn-edit-classroom" id="schedule-visual-select-all">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-use-form">Из формы</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-select-all">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Добавить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Добавить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-subject-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -69,7 +69,7 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-ts-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -92,4 +92,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
28
frontend/admin/views/teacher-requests.html
Normal file
28
frontend/admin/views/teacher-requests.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Заявки на преподавателей</h2>
|
||||
<div class="teacher-request-toolbar">
|
||||
<span class="muted" id="teacher-requests-count">0 заявок</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Кафедра</th>
|
||||
<th>Логин</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>Пароль</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="teacher-requests-tbody">
|
||||
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
|
||||
<div class="tab-container">
|
||||
<div class="tabs-header-buttons" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||
<button class="btn-tab active" id="btn-tab-departments" style="background: none; border: none; border-bottom: 2px solid var(--primary); color: var(--text-main); font-weight: 600; padding: 0.5rem 1rem; cursor: pointer;">Кафедры</button>
|
||||
<button class="btn-tab" id="btn-tab-specialties" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Специальности</button>
|
||||
<button class="btn-tab" id="btn-tab-profiles" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Профили</button>
|
||||
<div class="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
|
||||
<button type="button" class="university-structure-tab active" id="btn-tab-departments" role="tab" aria-selected="true" aria-controls="tab-content-departments">Кафедры</button>
|
||||
<button type="button" class="university-structure-tab" id="btn-tab-specialties" role="tab" aria-selected="false" aria-controls="tab-content-specialties">Специальности</button>
|
||||
<button type="button" class="university-structure-tab" id="btn-tab-profiles" role="tab" aria-selected="false" aria-controls="tab-content-profiles">Профили</button>
|
||||
</div>
|
||||
|
||||
<!-- Раздел Кафедр -->
|
||||
<div id="tab-content-departments" class="tab-content-section">
|
||||
<div id="tab-content-departments" class="university-structure-tab-panel active" role="tabpanel" aria-labelledby="btn-tab-departments">
|
||||
<div class="card create-card">
|
||||
<h2>Создание кафедры</h2>
|
||||
<form id="create-department-form">
|
||||
@@ -20,7 +20,7 @@
|
||||
<label for="dept-code">Код кафедры</label>
|
||||
<input type="number" id="dept-code" placeholder="Например: 1" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-dept-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Раздел Специальностей -->
|
||||
<div id="tab-content-specialties" class="tab-content-section" style="display: none;">
|
||||
<div id="tab-content-specialties" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-specialties" hidden>
|
||||
<div class="card create-card">
|
||||
<h2>Создание специальности</h2>
|
||||
<form id="create-specialty-form">
|
||||
@@ -64,7 +64,7 @@
|
||||
<label for="spec-code">Код специальности</label>
|
||||
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-spec-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -90,11 +90,12 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Раздел Профилей -->
|
||||
<div id="tab-content-profiles" class="tab-content-section" style="display: none;">
|
||||
<div id="tab-content-profiles" class="university-structure-tab-panel" role="tabpanel" aria-labelledby="btn-tab-profiles" hidden>
|
||||
<div class="card create-card">
|
||||
<h2 id="main-profile-form-title">Создание профиля</h2>
|
||||
<form id="create-profile-tab-form">
|
||||
@@ -115,8 +116,8 @@
|
||||
<input type="text" id="tab-profile-description" placeholder="Необязательно">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" id="btn-tab-save-profile">Создать</button>
|
||||
<button type="button" class="btn-secondary" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-tab-save-profile">Создать</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="tab-profile-alert" role="alert"></div>
|
||||
@@ -165,7 +166,7 @@
|
||||
<label for="edit-dept-code">Код кафедры</label>
|
||||
<input type="number" id="edit-dept-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -188,7 +189,7 @@
|
||||
<label for="edit-spec-code">Код специальности</label>
|
||||
<input type="text" id="edit-spec-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Сохранить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
|
||||
</form>
|
||||
@@ -222,8 +223,8 @@
|
||||
<input type="text" id="manage-profile-description" placeholder="Необязательно">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" id="btn-save-profile">Создать</button>
|
||||
<button type="button" class="btn-secondary" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-profile">Создать</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="manage-profile-alert" role="alert"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- ===== Users Tab ===== -->
|
||||
<div class="card create-card">
|
||||
<div class="card create-card">
|
||||
<h2>Новый пользователь</h2>
|
||||
<form id="create-form">
|
||||
<div class="form-row">
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Пароль</label>
|
||||
<input type="text" id="new-password" placeholder="password" required>
|
||||
<input type="text" id="new-password" placeholder="Минимум 8 символов" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-role">Роль</label>
|
||||
@@ -34,13 +34,13 @@
|
||||
<label for="new-department">ID Кафедры</label>
|
||||
<input type="number" id="new-department" placeholder="ID" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Все пользователи</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="users-table">
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required>
|
||||
<button type="button" class="toggle-password" id="toggle-password" aria-label="Показать пароль">
|
||||
<button type="button" class="btn btn-icon-md btn-secondary toggle-password" id="toggle-password" aria-label="Показать пароль">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
<div class="form-alert stagger-3" id="form-alert" role="alert"></div>
|
||||
|
||||
<button type="submit" class="btn-submit stagger-4" id="btn-submit">
|
||||
<button type="submit" class="btn btn-lg btn-primary btn-submit stagger-4" id="btn-submit">
|
||||
<span class="btn-text">Войти</span>
|
||||
<span class="btn-loader" hidden>
|
||||
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24">
|
||||
|
||||
@@ -163,3 +163,8 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document
|
||||
.querySelector('#login-form button[type="submit"]')
|
||||
?.classList.add('btn', 'btn-lg', 'btn-primary');
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
--panel-border: #dbe3ef;
|
||||
--text: #162033;
|
||||
--muted: #66758f;
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #0b5d57;
|
||||
--accent: #4F46E5;
|
||||
--accent-strong: #4338CA;
|
||||
--blue: #2563eb;
|
||||
--warning: #b45309;
|
||||
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
|
||||
@@ -28,8 +28,8 @@
|
||||
--panel-border: #263449;
|
||||
--text: #edf3fb;
|
||||
--muted: #9cadc5;
|
||||
--accent: #2dd4bf;
|
||||
--accent-strong: #5eead4;
|
||||
--accent: #8B5CF6;
|
||||
--accent-strong: #7C3AED;
|
||||
--blue: #60a5fa;
|
||||
--warning: #f59e0b;
|
||||
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||||
@@ -94,6 +94,7 @@
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@@ -112,6 +113,47 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--panel);
|
||||
border-color: var(--panel-border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
|
||||
}
|
||||
|
||||
.btn-danger-subtle {
|
||||
background: #FEF2F2;
|
||||
border-color: #FECACA;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-danger-subtle {
|
||||
background: rgba(239, 68, 68, 0.13);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: #FECACA;
|
||||
}
|
||||
|
||||
.btn-icon-md {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-icon-md svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto auto;
|
||||
@@ -269,8 +311,12 @@
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему">◐</button>
|
||||
<a class="btn" href="/" id="logout-link">Выйти</a>
|
||||
<button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -279,9 +325,17 @@
|
||||
<option value="">Загрузка групп...</option>
|
||||
</select>
|
||||
<div class="week-nav">
|
||||
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя">‹</button>
|
||||
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя">›</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
--accent-hover: #a78bfa;
|
||||
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||
--accent-secondary: #ec4899;
|
||||
--button-primary-bg: #8B5CF6;
|
||||
--button-primary-bg-hover: #7C3AED;
|
||||
--button-primary-text: #FFFFFF;
|
||||
--button-secondary-bg: rgba(255, 255, 255, 0.04);
|
||||
--button-secondary-bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--button-secondary-border: rgba(255, 255, 255, 0.10);
|
||||
--button-secondary-text: #F8FAFC;
|
||||
--button-ghost-bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--button-danger-bg: #DC2626;
|
||||
--button-danger-bg-hover: #B91C1C;
|
||||
--button-danger-subtle-bg: rgba(239, 68, 68, 0.13);
|
||||
--button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20);
|
||||
--button-danger-subtle-border: rgba(239, 68, 68, 0.25);
|
||||
--button-danger-subtle-text: #FECACA;
|
||||
--button-focus-ring: rgba(139, 92, 246, 0.45);
|
||||
--button-disabled-bg: rgba(148, 163, 184, 0.16);
|
||||
--button-disabled-text: #94A3B8;
|
||||
--button-radius: 8px;
|
||||
|
||||
/* Status Colors */
|
||||
--error: #ef4444;
|
||||
@@ -48,10 +66,27 @@
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-placeholder: #94a3b8;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent: #4F46E5;
|
||||
--accent-hover: #4338CA;
|
||||
--accent-glow: rgba(79, 70, 229, 0.3);
|
||||
--accent-secondary: #d946ef;
|
||||
--button-primary-bg: #4F46E5;
|
||||
--button-primary-bg-hover: #4338CA;
|
||||
--button-primary-text: #FFFFFF;
|
||||
--button-secondary-bg: #FFFFFF;
|
||||
--button-secondary-bg-hover: #F1F5F9;
|
||||
--button-secondary-border: #CBD5E1;
|
||||
--button-secondary-text: #334155;
|
||||
--button-ghost-bg-hover: #F8FAFC;
|
||||
--button-danger-bg: #DC2626;
|
||||
--button-danger-bg-hover: #B91C1C;
|
||||
--button-danger-subtle-bg: #FEF2F2;
|
||||
--button-danger-subtle-bg-hover: #FEE2E2;
|
||||
--button-danger-subtle-border: #FECACA;
|
||||
--button-danger-subtle-text: #B91C1C;
|
||||
--button-focus-ring: rgba(79, 70, 229, 0.35);
|
||||
--button-disabled-bg: #CBD5E1;
|
||||
--button-disabled-text: #64748B;
|
||||
--error: #ef4444;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
@@ -389,59 +424,19 @@ body {
|
||||
|
||||
/* ===== Submit Button ===== */
|
||||
.btn-submit {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 0.95rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition:
|
||||
transform var(--transition),
|
||||
box-shadow var(--transition),
|
||||
opacity var(--transition);
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-submit::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: linear-gradient(rgba(255,255,255,0.2), transparent);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-submit:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-submit:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 10px var(--accent-glow);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--button-disabled-bg);
|
||||
color: var(--button-disabled-text);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ===== Ripple Effect ===== */
|
||||
@@ -480,13 +475,12 @@ body {
|
||||
|
||||
/* ===== Theme Toggle Button ===== */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--button-radius);
|
||||
background: var(--button-secondary-bg);
|
||||
border: 1px solid var(--button-secondary-border);
|
||||
color: var(--button-secondary-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -505,12 +499,13 @@ body {
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
background: var(--button-secondary-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.theme-toggle--fixed {
|
||||
@@ -540,4 +535,95 @@ body {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--button-radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--button-focus-ring);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
min-height: 48px;
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-icon-md {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-icon-md svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--button-primary-bg);
|
||||
border-color: transparent;
|
||||
color: var(--button-primary-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--button-primary-bg-hover);
|
||||
color: var(--button-primary-text);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--button-secondary-bg);
|
||||
border-color: var(--button-secondary-border);
|
||||
color: var(--button-secondary-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--button-secondary-bg-hover);
|
||||
color: var(--button-secondary-text);
|
||||
}
|
||||
#login-form .btn.btn-primary {
|
||||
min-height: 48px !important;
|
||||
padding: 0 20px !important;
|
||||
border-radius: var(--button-radius) !important;
|
||||
background: var(--button-primary-bg) !important;
|
||||
color: var(--button-primary-text) !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#login-form .btn.btn-primary:hover {
|
||||
background: var(--button-primary-bg-hover) !important;
|
||||
}
|
||||
#toggle-password.btn-icon-md {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
min-height: 40px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--button-radius) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
--panel-border: #d8dee8;
|
||||
--text: #172033;
|
||||
--muted: #67758d;
|
||||
--accent: #1d4ed8;
|
||||
--accent-strong: #1e40af;
|
||||
--accent: #4F46E5;
|
||||
--accent-strong: #4338CA;
|
||||
--green: #047857;
|
||||
--orange: #b45309;
|
||||
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
|
||||
@@ -28,8 +28,8 @@
|
||||
--panel-border: #2a374b;
|
||||
--text: #eef4fb;
|
||||
--muted: #a2b0c5;
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #93c5fd;
|
||||
--accent: #8B5CF6;
|
||||
--accent-strong: #7C3AED;
|
||||
--green: #34d399;
|
||||
--orange: #f59e0b;
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||
@@ -107,6 +107,7 @@
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@@ -125,6 +126,47 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--panel);
|
||||
border-color: var(--panel-border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
|
||||
}
|
||||
|
||||
.btn-danger-subtle {
|
||||
background: #FEF2F2;
|
||||
border-color: #FECACA;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-danger-subtle {
|
||||
background: rgba(239, 68, 68, 0.13);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: #FECACA;
|
||||
}
|
||||
|
||||
.btn-icon-md {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-icon-md svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -275,16 +317,28 @@
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему">◐</button>
|
||||
<a class="btn" href="/" id="logout-link">Выйти</a>
|
||||
<button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="week-nav">
|
||||
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя">‹</button>
|
||||
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя">›</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'theme-toggle';
|
||||
btn.className = 'btn btn-icon-md btn-secondary theme-toggle';
|
||||
btn.setAttribute('aria-label', 'Переключить тему');
|
||||
btn.innerHTML = isDark() ? moonSVG : sunSVG;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user