27 Commits

Author SHA1 Message Date
db0218f4ca Merge pull request 'Redesign' (#13) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 14s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 13s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 2m13s
Reviewed-on: #13
2026-06-25 16:43:54 +00:00
9e6e0038c6 Отображение вкладок в структуре вуза 2026-06-04 23:46:26 +03:00
6a03931190 исправлена 500 ошибка, пустое пространство в модалке 2026-06-04 10:50:12 +03:00
dipatrik10
3d50b6b006 Исправил косяки в модалках решения конфликтов правил 2026-06-03 21:22:24 +03:00
dipatrik10
53298d9d07 Добавил возможность решения конкфликтов при создании правил 2026-06-03 13:55:31 +03:00
776728cf83 Структура вуза, визуал 2026-06-03 12:16:37 +03:00
dipatrik10
08b8607f05 Добавил вкладку заявок 2026-06-02 16:07:30 +03:00
6d9f6b7a42 Изменен фильтр в "группы обучения", изменена механика создания подгрупп 2026-06-02 11:21:02 +03:00
4e1985c28e исправил чекбоксы оборудования в аудиторном фонде 2026-06-02 00:29:23 +03:00
dipatrik10
a966648a7e Сделал обработку на ВЕ конфликтных правил 2026-06-02 00:24:57 +03:00
dipatrik10
f3273f6972 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-06-02 00:02:06 +03:00
dipatrik10
4adce0b89c Добавил запрос на создание препода от кафедры 2026-06-02 00:01:14 +03:00
43d72c5235 Добавить дисциплины календарных графиков 2026-06-01 23:27:37 +03:00
Zuev
4b05d4287c поправил поля ввода и вызово боковой панели конструктора правил 2026-06-01 21:14:30 +03:00
Zuev
5a81a911ff devops 2026-06-01 20:50:18 +03:00
57e55298d5 удалены лишние файлы 2026-06-01 17:00:21 +03:00
da7c90fa6a Merge pull request 'Redesign' (#12) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 50s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 14s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m49s
Reviewed-on: #12
2026-06-01 09:04:35 +00:00
3217586657 редизайн кнопок 1st version 2026-06-01 02:55:06 +03:00
dipatrik10
30972db9ee Исправил часть косяков в конструкторе правил 2026-05-31 21:36:23 +03:00
Zuev
f3a9905423 профили 2026-05-31 16:34:05 +03:00
Zuev
7926038bdc обновил дизайн, добавил дашборд 2026-05-31 16:18:27 +03:00
Zuev
e2a3553a09 поправил добавление дисциплин и расписание занятий 2026-05-31 14:42:43 +03:00
e989391c8d Поправил сетку и еще пару багов 2026-05-28 00:52:21 +03:00
5b6ffa5c08 поправил вид сетки и прочие баги 2026-05-28 00:47:51 +03:00
dipatrik10
bbfee068da Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule 2026-05-27 21:26:22 +03:00
dipatrik10
9df67400b8 test 2026-05-27 21:25:33 +03:00
dipatrik10
c19a9b34c7 Добавил визуальную матрицу расписания групп 2026-05-27 21:18:57 +03:00
90 changed files with 9789 additions and 2264 deletions

278
DEVOPS.md Normal file
View File

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

View File

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

View File

@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin/academic-calendars") @RequestMapping("/api/admin/academic-calendars")
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository; private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository; private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository, public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository, SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository, EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarDayRepository = calendarDayRepository;
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository; this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository; this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.scheduleGeneratorService = scheduleGeneratorService; 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) { private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика"); throw new IllegalArgumentException("Передайте данные календарного графика");
@@ -146,6 +185,9 @@ public class AcademicCalendarController {
if (request.courseCount() == null || request.courseCount() <= 0) { if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
} }
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) { || request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
@@ -209,6 +251,53 @@ public class AcademicCalendarController {
throw new IllegalArgumentException("Код активности обязателен"); 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) { private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
AcademicYear year = calendar.getAcademicYear(); AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality(); Speciality speciality = calendar.getSpeciality();
@@ -247,4 +336,17 @@ public class AcademicCalendarController {
activityType.getAllowSchedule() activityType.getAllowSchedule()
); );
} }
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
} }

View File

@@ -2,18 +2,26 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateTeacherCreationRequest;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto; 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.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; 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.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -23,18 +31,29 @@ import java.util.Objects;
@RequireRoles({Role.ADMIN, Role.DEPARTMENT}) @RequireRoles({Role.ADMIN, Role.DEPARTMENT})
public class DepartmentWorkspaceController { public class DepartmentWorkspaceController {
private static final Long NO_DEPARTMENT_ID = Long.MIN_VALUE;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository; private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository, public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository, SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository, UserRepository userRepository,
DepartmentRepository departmentRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
TeacherCreationRequestRepository teacherCreationRequestRepository,
ScheduleQueryService scheduleQueryService) { ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository; this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.departmentRepository = departmentRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
this.scheduleQueryService = scheduleQueryService; this.scheduleQueryService = scheduleQueryService;
} }
@@ -109,9 +128,110 @@ public class DepartmentWorkspaceController {
} }
@GetMapping("/teachers") @GetMapping("/teachers")
public List<?> getTeachers(@RequestParam(required = false) Long departmentId) { public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId); Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.toList();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
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)
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return teachersById.values().stream()
.map(this::toUserResponse)
.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") @GetMapping("/schedule")
@@ -137,10 +257,77 @@ public class DepartmentWorkspaceController {
); );
} }
private UserResponse toUserResponse(User user) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
response.setStatus(user.getStatus());
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) { private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser(); var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) { 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; return requestedDepartmentId == null && user != null ? user.departmentId() : requestedDepartmentId;
} }

View File

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

View File

@@ -6,11 +6,13 @@ import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
@RestController @RestController
@@ -19,6 +21,12 @@ import java.util.*;
public class ScheduleRuleAdminController { public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT"; 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 ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
@@ -81,6 +89,10 @@ public class ScheduleRuleAdminController {
try { try {
ScheduleRule rule = new ScheduleRule(); ScheduleRule rule = new ScheduleRule();
applyRule(rule, request); applyRule(rule, request);
ScheduleRuleConflict conflict = findConflict(request, rule.getSlots(), null);
if (conflict != null) {
return conflictResponse(conflict);
}
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved)); return ResponseEntity.ok(toDto(saved));
@@ -103,6 +115,12 @@ public class ScheduleRuleAdminController {
} }
try { 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); applyRule(rule, request);
ScheduleRule saved = scheduleRuleRepository.save(rule); ScheduleRule saved = scheduleRuleRepository.save(rule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
@@ -181,12 +199,250 @@ public class ScheduleRuleAdminController {
rule.getSlots().addAll(slots); 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) { private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) {
throw new IllegalArgumentException("День недели должен быть от 1 до 7"); throw new IllegalArgumentException("День недели должен быть от 1 до 7");
} }
if (slotDto.parity() == null) { if (slotDto.parity() == null || !VALID_SLOT_PARITIES.contains(slotDto.parity())) {
throw new IllegalArgumentException("Чётность недели обязательна"); throw new IllegalArgumentException("Чётность недели должна быть: каждая, нечётная или чётная");
} }
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
@@ -402,4 +658,7 @@ public class ScheduleRuleAdminController {
.thenComparing(Subgroup::getName)) .thenComparing(Subgroup::getName))
.toList(); .toList();
} }
private record ScheduleRuleConflict(ScheduleRule rule, LinkedHashSet<String> fields) {
}
} }

View File

@@ -193,6 +193,16 @@ public class SpecialityController {
)); ));
} }
@GetMapping("/profiles")
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
.filter(profile -> profile.getSpeciality().isActiveRecord())
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
.map(this::toProfileDto)
.toList();
}
@GetMapping("/{specialtyId}/profiles") @GetMapping("/{specialtyId}/profiles")
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) { public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) { if (!specialtiesRepository.existsById(specialtyId)) {

View File

@@ -66,6 +66,10 @@ public class SubgroupController {
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); 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 subgroup = new Subgroup();
subgroup.setStudentGroup(group); subgroup.setStudentGroup(group);
@@ -95,6 +99,10 @@ public class SubgroupController {
if (duplicateName) { if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); 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.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity()); subgroup.setStudentCapacity(request.studentCapacity());
@@ -113,6 +121,10 @@ public class SubgroupController {
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
} }
String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup);
if (deleteValidationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
}
try { try {
subgroup.archive("Подгруппа архивирована"); subgroup.archive("Подгруппа архивирована");
subgroupRepository.save(subgroup); subgroupRepository.save(subgroup);
@@ -127,8 +139,39 @@ public class SubgroupController {
if (request == null || request.name() == null || request.name().isBlank()) { if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно"; return "Название подгруппы обязательно";
} }
if (request.studentCapacity() != null && request.studentCapacity() < 0) { if (request.studentCapacity() == null) {
return "Численность подгруппы не может быть отрицательной"; 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; return null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ public record RenderedLessonDto(
String activityType, String activityType,
Integer lessonTypeAcademicHours, Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson, Integer consumedLessonTypeAcademicHoursBeforeLesson,
Integer remainingLessonTypeAcademicHoursAfterLesson Integer remainingLessonTypeAcademicHoursAfterLesson,
com.magistr.app.model.ScheduleParity ruleParity
) { ) {
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -406,7 +406,8 @@ public class ScheduleGeneratorService {
"Т", "Т",
lessonTypeAcademicHours, lessonTypeAcademicHours,
consumedBeforeLesson, consumedBeforeLesson,
remainingAfterLesson remainingAfterLesson,
slot.getParity()
); );
} }

View File

@@ -136,7 +136,8 @@ public class ScheduleQueryService {
lesson.activityType(), lesson.activityType(),
lesson.lessonTypeAcademicHours(), lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(), lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
lesson.remainingLessonTypeAcademicHoursAfterLesson() lesson.remainingLessonTypeAcademicHoursAfterLesson(),
lesson.ruleParity()
); );
} }
@@ -273,7 +274,8 @@ public class ScheduleQueryService {
source.activityType(), source.activityType(),
source.lessonTypeAcademicHours(), source.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(), source.consumedLessonTypeAcademicHoursBeforeLesson(),
source.remainingLessonTypeAcademicHoursAfterLesson() source.remainingLessonTypeAcademicHoursAfterLesson(),
source.ruleParity()
); );
} }
} }

View File

@@ -133,6 +133,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
ON teacher_department_assignments(teacher_id) ON teacher_department_assignments(teacher_id)
WHERE is_primary = TRUE AND valid_to IS NULL; 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) 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' SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u FROM users u
@@ -143,6 +147,34 @@ WHERE u.role = 'TEACHER'
WHERE existing.teacher_id = u.id 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, course_count INT NOT NULL DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_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) 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) 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; 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 ( CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, 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_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
COMMENT ON TABLE academic_calendar_days 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 student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания'; COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам'; COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам'; COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар'; COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours 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.day_of_week IS 'День недели ISO';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; 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.group_id IS 'ID учебной группы';
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_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 назначенного календарного графика'; COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';

View File

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

View File

@@ -2,19 +2,32 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser; import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; 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.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; 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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class DepartmentWorkspaceControllerTest { class DepartmentWorkspaceControllerTest {
@@ -35,6 +48,9 @@ class DepartmentWorkspaceControllerTest {
subjectRepository, subjectRepository,
mock(SubjectCommentRepository.class), mock(SubjectCommentRepository.class),
mock(UserRepository.class), mock(UserRepository.class),
mock(DepartmentRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class) mock(ScheduleQueryService.class)
); );
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -43,4 +59,83 @@ class DepartmentWorkspaceControllerTest {
assertThat(response.getStatusCode().value()).isEqualTo(404); assertThat(response.getStatusCode().value()).isEqualTo(404);
} }
@Test
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
UserRepository userRepository = mock(UserRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(directTeacher, assignedTeacher));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
mock(DepartmentRepository.class),
assignmentRepository,
mock(TeacherCreationRequestRepository.class),
mock(ScheduleQueryService.class)
);
List<UserResponse> teachers = controller.getTeachers(1L);
assertThat(teachers).extracting(UserResponse::getFullName)
.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();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
return assignment;
}
private User user(Long id, String username, String fullName, Long departmentId) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setRole(Role.TEACHER);
user.setFullName(fullName);
user.setJobTitle("Доцент");
user.setDepartmentId(departmentId);
return user;
}
} }

View File

@@ -23,13 +23,16 @@ class UserControllerTest {
void teachersUseUnifiedUserResponseMapping() { void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class); UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class); DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController( UserController controller = new UserController(
userRepository, userRepository,
null, null,
departmentRepository, departmentRepository,
mock(TeacherDepartmentAssignmentRepository.class) assignmentRepository
); );
User teacher = user(10L, "teacher", Role.TEACHER, 2L); 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)) when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher)); .thenReturn(List.of(teacher));
when(departmentRepository.findById(2L)) when(departmentRepository.findById(2L))

View File

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

View File

@@ -147,6 +147,8 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`. Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
### `POST /api/users` ### `POST /api/users`
Создание пользователя. Создание пользователя.
@@ -165,7 +167,7 @@ Refresh-токен ротируется при каждом успешном о
**Валидация:** **Валидация:**
- `username` — обязателен и уникален - `username` — обязателен и уникален
- `password` — минимум 4 символа - `password` — минимум 8 символов
- `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT` - `role``ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен - `fullName` — обязателен
- `departmentId` — обязателен - `departmentId` — обязателен
@@ -196,7 +198,40 @@ Refresh-токен ротируется при каждом успешном о
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01` ### `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}` | Удалить график | | `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | | `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
| `PUT` | `/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 ```json
@@ -375,6 +412,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
``` ```
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`.
**Ячейка сетки графика:** **Ячейка сетки графика:**
```json ```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` ### `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` ### `GET /api/lesson-types`
Справочник типов занятий для конструктора правил расписания. Справочник типов занятий для конструктора правил расписания.
@@ -539,8 +607,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины | | `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий | | `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры | | `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/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": "Нужен для дисциплин кафедры"
}
```
--- ---
## Кафедры и специальности ## Кафедры и специальности
@@ -684,6 +777,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп | | `GET` | `/api/subgroups` | Список всех подгрупп |
@@ -727,6 +824,30 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
} }
``` ```
**Ответ назначения:**
```json
{
"id": 12,
"groupId": 1,
"groupName": "ИВТ-21-1",
"academicYearId": 1,
"academicYearTitle": "2025-2026",
"calendarId": 5,
"calendarTitle": "09.03.04 очная форма 2025-2026",
"subjects": [
{
"id": 44,
"calendarId": 5,
"semesterNumber": 1,
"subjectId": 1,
"subjectName": "Высшая математика",
"subjectCode": "Б1.О.01",
"departmentId": 1
}
]
}
```
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа. Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
--- ---

View File

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

View File

@@ -148,6 +148,21 @@ erDiagram
TEXT comment 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 { subject_comments {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT subject_id FK BIGINT subject_id FK
@@ -233,6 +248,14 @@ erDiagram
BIGINT activity_type_id FK 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 { student_group_calendar_assignments {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT group_id FK BIGINT group_id FK
@@ -306,11 +329,14 @@ erDiagram
users ||--o{ auth_refresh_tokens : "user_id" users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id" users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_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{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id" users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id" subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ academic_calendar_subjects : "subject_id"
subjects ||--o{ schedule_rules : "subject_id" subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_rule_slots : "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" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_id" education_forms ||--o{ academic_calendars : "study_form_id"
academic_calendars ||--o{ academic_calendar_days : "calendar_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_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
@@ -443,7 +470,7 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы | | `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов | | `student_capacity` | INT | Количество студентов |
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины #### `subjects` — Дисциплины
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -528,7 +555,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи | | `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод | | `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` — Комментарии к дисциплинам #### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -608,7 +655,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | | `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | | `course_count` | INT CHECK(18) | Количество курсов в сетке |
| `created_at` | TIMESTAMP | Дата создания | | `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления | | `updated_at` | TIMESTAMP | Дата обновления |
@@ -623,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели ISO | | `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | | `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` — Назначения графиков группам #### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -713,13 +771,13 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum. > Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции ### Текущие миграции
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | | `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

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

View File

@@ -576,12 +576,31 @@
gap: 1rem; gap: 1rem;
} }
.schedule-view-control-card {
position: relative;
z-index: 10;
}
.schedule-view-results-card {
position: relative;
z-index: 5;
}
.schedule-view-table-card {
position: relative;
z-index: 1;
}
.schedule-view-control-card, .schedule-view-control-card,
.schedule-view-results-card, .schedule-view-results-card,
.schedule-view-table-card { .schedule-view-table-card {
animation-duration: 0.28s; animation-duration: 0.28s;
} }
.schedule-view-results-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schedule-view-control-header, .schedule-view-control-header,
.schedule-view-results-header, .schedule-view-results-header,
.schedule-view-table-header { .schedule-view-table-header {
@@ -589,6 +608,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.schedule-view-control-header h2, .schedule-view-control-header h2,
@@ -634,32 +654,6 @@
gap: 0.45rem; 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 { .schedule-view-extra-filters {
margin-top: 0.9rem; margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -684,10 +678,11 @@
.schedule-view-result-tabs { .schedule-view-result-tabs {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.55rem; gap: 0.55rem;
margin-top: 1rem; margin-top: 0;
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
overflow-x: auto; overflow-x: visible;
} }
.schedule-view-result-tab { .schedule-view-result-tab {
@@ -923,8 +918,8 @@
width: 100%; width: 100%;
} }
.schedule-view-period-buttons .btn-secondary, .schedule-view-period-buttons .btn,
.schedule-view-result-actions .btn-secondary { .schedule-view-result-actions .btn {
flex: 1; flex: 1;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -25,15 +25,7 @@
} }
.sidebar-close-btn { .sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary); 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); transition: all var(--transition);
} }
@@ -112,6 +104,7 @@
} }
.nav-item svg { .nav-item svg {
flex: 0 0 auto;
transition: transform var(--transition); transition: transform var(--transition);
} }
@@ -120,6 +113,38 @@
transform: scale(1.15) rotate(-5deg); 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 { .sidebar-footer {
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -255,7 +280,7 @@
display: none; display: none;
} }
.sidebar.collapsed .nav-item span, .sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span { .sidebar.collapsed .btn-settings span {
position: absolute; position: absolute;
left: calc(100% + 10px); left: calc(100% + 10px);
@@ -279,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 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 { [data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 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 { .sidebar.collapsed .btn-settings:hover span {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@@ -306,6 +331,16 @@
padding: 0.75rem 0; padding: 0.75rem 0;
overflow: visible; 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 { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
@@ -341,9 +376,44 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
}
.topbar-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1; 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 { .content {
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
display: flex; display: flex;
@@ -355,12 +425,7 @@
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
.menu-toggle { .menu-toggle {
display: none; display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }

View File

@@ -51,9 +51,9 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--error: #ef4444; --error: #ef4444;

View File

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

View File

@@ -16,6 +16,19 @@
<link rel="stylesheet" href="css/modals.css"> <link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css"> <link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css"> <link rel="stylesheet" href="css/auditorium-workload.css">
<style>
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}
</style>
</head> </head>
<body> <body>
@@ -36,79 +49,35 @@
</svg> </svg>
<span>Magistr</span> <span>Magistr</span>
</div> </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"> <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" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="users"> <!-- Главная -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <a href="#" class="nav-item" data-tab="dashboard">
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a>
<a href="#" class="nav-item" data-tab="departments-data">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path> <rect x="3" y="3" width="7" height="9" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect> <rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg> </svg>
<span>Создание кафедры/специальности</span> <span>Дашборд</span>
</a> </a>
<a href="#" class="nav-item" data-tab="profiles">
<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"> <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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <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" />
<circle cx="12" cy="7" r="4"></circle> <rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 11.5 18 14l3-5"></path> <path d="M8 11h8" />
<path d="M8 16h5" />
</svg> </svg>
<span>Профили обучения</span> <span>Заявки</span>
</a> <span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы</span>
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="equipments">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
<span>Оборудование</span>
</a>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудитории</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a> </a>
<a href="#" class="nav-item" data-tab="department-workspace"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -120,14 +89,11 @@
</svg> </svg>
<span>Кабинет кафедры</span> <span>Кабинет кафедры</span>
</a> </a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <!-- Раздел: Расписание -->
stroke-linecap="round" stroke-linejoin="round"> <div class="sidebar-section-divider"></div>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /> <div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="schedule"> <a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -135,7 +101,7 @@
<line x1="8" y1="2" x2="8" y2="6"></line> <line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line> <line x1="3" y1="10" x2="21" y2="10"></line>
</svg> </svg>
<span>Расписание занятий</span> <span>Конструктор правил</span>
</a> </a>
<a href="#" class="nav-item" data-tab="academic-calendar"> <a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -155,13 +121,63 @@
</svg> </svg>
<span>Загруженность</span> <span>Загруженность</span>
</a> </a>
<a href="#" class="nav-item" data-tab="database"> <a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse> stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path> <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path> <circle cx="12" cy="12" r="3" />
</svg> </svg>
<span>База данных</span> <span>Просмотр расписаний</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудиторный фонд</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="university-structure">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
<span>Структура вуза</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a>
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -188,7 +204,7 @@
Общие настройки Общие настройки
</a> </a>
<div class="settings-menu-divider"></div> <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" <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -208,7 +224,7 @@
<!-- Main --> <!-- Main -->
<main class="main"> <main class="main">
<header class="topbar"> <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" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />
@@ -216,7 +232,10 @@
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </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> </header>
<!-- Dynamic Content Injected Here --> <!-- Dynamic Content Injected Here -->

View File

@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
} }
if (!response.ok) { 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; return data;
@@ -92,6 +95,49 @@ export async function refreshAccessToken() {
return refreshPromise; return refreshPromise;
} }
const cache = new Map();
const CACHABLE_PREFIXES = [
'/api/departments',
'/api/specialties',
'/api/education-forms',
'/api/users/teachers',
'/api/equipments',
'/api/classrooms'
];
function invalidateCache(url) {
const cacheKeysToClear = [
'/api/departments',
'/api/specialties',
'/api/education-forms',
'/api/users',
'/api/teacher-requests',
'/api/department/teacher-requests',
'/api/department/teachers',
'/api/equipments',
'/api/classrooms'
];
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);
}
}
}
}
export function clearCache() {
cache.clear();
}
export async function logout() { export async function logout() {
try { try {
await fetch('/api/auth/logout', { await fetch('/api/auth/logout', {
@@ -102,6 +148,7 @@ export async function logout() {
console.warn('Не удалось завершить серверную сессию:', e.message); console.warn('Не удалось завершить серверную сессию:', e.message);
} finally { } finally {
clearAuthState(); clearAuthState();
clearCache();
} }
} }
@@ -121,8 +168,26 @@ export function clearAuthState() {
} }
export const api = { export const api = {
get: (url) => apiFetch(url, 'GET'), get: async (url) => {
post: (url, body) => apiFetch(url, 'POST', body), const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
put: (url, body) => apiFetch(url, 'PUT', body), if (isCachable) {
delete: (url, body = null) => apiFetch(url, 'DELETE', body) if (!cache.has(url)) {
cache.set(url, await apiFetch(url, 'GET'));
}
return cache.get(url);
}
return apiFetch(url, 'GET');
},
post: async (url, body) => {
invalidateCache(url);
return apiFetch(url, 'POST', body);
},
put: async (url, body) => {
invalidateCache(url);
return apiFetch(url, 'PUT', body);
},
delete: async (url, body = null) => {
invalidateCache(url);
return apiFetch(url, 'DELETE', body);
}
}; };

View File

@@ -99,6 +99,35 @@ export class CustomSelect {
this.menu.innerHTML = ''; this.menu.innerHTML = '';
const options = Array.from(this.originalSelect.options); const options = Array.from(this.originalSelect.options);
// Show search input if more than 6 options exist
if (options.length > 6) {
const searchLi = document.createElement('li');
searchLi.className = 'custom-select-search-container';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Поиск...';
searchInput.className = 'custom-select-search-input';
searchInput.addEventListener('click', (e) => e.stopPropagation());
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (item.classList.contains('placeholder-item')) return;
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
});
searchLi.appendChild(searchInput);
this.menu.appendChild(searchLi);
this.searchInput = searchInput;
} else {
this.searchInput = null;
}
if (options.length === 0) { if (options.length === 0) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'custom-select-item disabled'; li.className = 'custom-select-item disabled';
@@ -171,7 +200,22 @@ export class CustomSelect {
if (!isOpen) { if (!isOpen) {
this.layerParent?.classList.add('select-layer-active'); this.layerParent?.classList.add('select-layer-active');
this.originalSelect.closest('.form-group, .form-field')?.classList.add('select-layer-active');
this.wrapper.classList.add('open'); this.wrapper.classList.add('open');
if (this.searchInput) {
this.searchInput.value = '';
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (!item.classList.contains('placeholder-item')) {
item.style.display = '';
}
});
setTimeout(() => {
this.searchInput.focus();
}, 50);
}
// Scroll selected item into view // Scroll selected item into view
const selectedItem = this.menu.querySelector('.selected'); const selectedItem = this.menu.querySelector('.selected');
if (selectedItem) { if (selectedItem) {
@@ -194,6 +238,7 @@ export class CustomSelect {
close() { close() {
this.wrapper.classList.remove('open'); this.wrapper.classList.remove('open');
this.layerParent?.classList.remove('select-layer-active'); this.layerParent?.classList.remove('select-layer-active');
this.originalSelect.closest('.form-group, .form-field')?.classList.remove('select-layer-active');
} }
handleItemClick(e, index) { handleItemClick(e, index) {

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); import('./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 { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -20,47 +20,114 @@ document.addEventListener('DOMContentLoaded', () => {
startDropdownAutoObserver(); startDropdownAutoObserver();
}); });
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
import { initEduForms } from './views/edu-forms.js';
import { initEquipments } from './views/equipments.js';
import { initClassrooms } from './views/classrooms.js';
import { initSubjects } from './views/subjects.js';
import {initSchedule} from "./views/schedule.js";
import {initDatabase} from "./views/database.js";
import {initDepartmentsData} from "./views/departments-data.js";
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
import {initProfiles} from "./views/profiles.js";
import {initAcademicCalendar} from "./views/academic-calendar.js";
import {initScheduleView} from "./views/schedule-view.js";
import {initDepartmentWorkspace} from "./views/department-workspace.js";
// Configuration // Configuration
const ROUTES = { const ROUTES = {
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers }, dashboard: {
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups }, title: 'Дашборд',
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms }, file: '/admin/views/dashboard.html',
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments }, init: async () => {
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms }, const module = await import('./views/dashboard.js');
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects }, await module.initDashboard();
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace }, }
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView }, },
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule }, users: {
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar }, title: 'Управление пользователями',
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload }, file: '/admin/views/users.html',
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase }, init: async () => {
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData }, const module = await import('./views/users.js');
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles }, await module.initUsers();
}
},
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
init: async () => {
const module = await import('./views/groups.js');
await module.initGroups();
}
},
classrooms: {
title: 'Аудиторный фонд',
file: '/admin/views/classrooms.html',
init: async () => {
const module = await import('./views/classrooms.js');
await module.initClassrooms();
}
},
subjects: {
title: 'Дисциплины',
file: '/admin/views/subjects.html',
init: async () => {
const module = await import('./views/subjects.js');
await module.initSubjects();
}
},
'university-structure': {
title: 'Структура вуза',
file: '/admin/views/university-structure.html',
init: async () => {
const module = await import('./views/university-structure.js');
await module.initUniversityStructure();
}
},
'department-workspace': {
title: 'Кабинет кафедры',
file: '/admin/views/department-workspace.html',
init: async () => {
const module = await import('./views/department-workspace.js');
await module.initDepartmentWorkspace();
}
},
'schedule-view': {
title: 'Просмотр расписаний',
file: '/admin/views/schedule-view.html',
init: async () => {
const module = await import('./views/schedule-view.js');
await module.initScheduleView();
}
},
schedule: {
title: 'Расписание занятий',
file: '/admin/views/schedule.html',
init: async () => {
const module = await import('./views/schedule.js');
await module.initSchedule();
}
},
'academic-calendar': {
title: 'Календарный учебный график',
file: '/admin/views/academic-calendar.html',
init: async () => {
const module = await import('./views/academic-calendar.js');
await module.initAcademicCalendar();
}
},
'auditorium-workload': {
title: 'Загруженность',
file: '/admin/views/auditorium-workload.html',
init: async () => {
const module = await import('./views/auditorium-workload.js');
await module.initAuditoriumWorkload();
}
},
}; };
const ROLE_NAVIGATION = { const ROLE_NAVIGATION = {
ADMIN: { ADMIN: {
defaultTab: 'users', defaultTab: 'dashboard',
tabs: Object.keys(ROUTES) tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
}, },
EDUCATION_OFFICE: { EDUCATION_OFFICE: {
defaultTab: 'schedule-view', defaultTab: 'dashboard',
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments'] tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
}, },
DEPARTMENT: { DEPARTMENT: {
defaultTab: 'department-workspace', defaultTab: 'department-workspace',
@@ -84,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); 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 roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs); const allowedTabs = new Set(roleNavigation.tabs);
@@ -91,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
configureRoleInterface(); configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -148,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/'; window.location.href = '/';
}); });
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation // Navigation
navItems.forEach(item => { navItems.forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
@@ -176,10 +250,13 @@ async function switchTab(tab) {
// Initialize logic for the tab // Initialize logic for the tab
if (ROUTES[tab].init) { if (ROUTES[tab].init) {
ROUTES[tab].init(); await ROUTES[tab].init();
} }
currentTab = tab; currentTab = tab;
if (window.location.hash !== `#${tab}`) {
window.location.hash = tab;
}
} catch (e) { } catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`; appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e); console.error(e);
@@ -209,5 +286,48 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle; 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('#', ''); const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
switchTab(hashTab);
}
});

View File

@@ -27,7 +27,7 @@ export function hideAlert(elementId) {
export function applyRippleEffect() { export function applyRippleEffect() {
document.addEventListener('click', function (e) { 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; if (!btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();

View File

@@ -70,6 +70,16 @@ export async function initAcademicCalendar() {
const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope'); const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save'); const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear'); 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 academicYears = [];
let semesters = []; let semesters = [];
@@ -78,6 +88,8 @@ export async function initAcademicCalendar() {
let studyForms = []; let studyForms = [];
let activityTypes = []; let activityTypes = [];
let calendars = []; let calendars = [];
let subjects = [];
let calendarSubjectRows = [];
let timeSlotScopes = []; let timeSlotScopes = [];
let timeSlotDateAssignments = []; let timeSlotDateAssignments = [];
let activeCalendarDay = null; let activeCalendarDay = null;
@@ -95,6 +107,7 @@ export async function initAcademicCalendar() {
await Promise.all([ await Promise.all([
loadBaseLists(), loadBaseLists(),
loadYears(), loadYears(),
loadSubjects(),
loadStudyForms(), loadStudyForms(),
loadActivityTypes(), loadActivityTypes(),
loadTimeSlotScopes(), loadTimeSlotScopes(),
@@ -106,6 +119,9 @@ export async function initAcademicCalendar() {
} }
function bindEvents() { function bindEvents() {
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
});
academicYearsRefreshButton?.addEventListener('click', loadYears); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
@@ -115,6 +131,11 @@ export async function initAcademicCalendar() {
editorLoadButton?.addEventListener('click', loadCalendarGrid); editorLoadButton?.addEventListener('click', loadCalendarGrid);
editorSaveButton?.addEventListener('click', saveCalendarGrid); editorSaveButton?.addEventListener('click', saveCalendarGrid);
fillApplyButton?.addEventListener('click', applyCalendarFill); 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) => { calendarGrid?.addEventListener('change', (event) => {
if (event.target.matches('.calendar-day-activity')) { if (event.target.matches('.calendar-day-activity')) {
@@ -217,12 +238,14 @@ export async function initAcademicCalendar() {
} }
academicYearsTbody.innerHTML = academicYears.map(year => ` academicYearsTbody.innerHTML = academicYears.map(year => `
<tr> <tr>
<td>${escapeHtml(year.title)}</td> <td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td> <td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
<td>${renderSemesterList(year.semesters || [])}</td> <td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
<td> <td class="academic-year-actions-cell">
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button> <div class="academic-year-actions">
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button> <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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -230,12 +253,19 @@ export async function initAcademicCalendar() {
function renderSemesterList(items) { function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>'; if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => ` return `<div class="academic-year-semesters">${items.map(semester => {
<div class="schedule-semester-line"> const title = semesterTypeTitle(semester);
${escapeHtml(semesterLabel(semester))} const period = formatDisplayDateRange(semester.startDate, semester.endDate);
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button> return `
</div> <div class="academic-year-semester-item">
`).join(''); <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) { async function saveAcademicYear(event) {
@@ -282,7 +312,7 @@ export async function initAcademicCalendar() {
if (editSemesterButton) { if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return; if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра'; semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
semesterIdInput.value = semester.id; semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || ''; semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
@@ -352,6 +382,11 @@ export async function initAcademicCalendar() {
populateStudyFormSelect(); populateStudyFormSelect();
} }
async function loadSubjects() {
subjects = await api.get('/api/subjects');
populateSubjectSelect();
}
async function loadActivityTypes() { async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types'); activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects(); populateActivitySelects();
@@ -371,6 +406,7 @@ export async function initAcademicCalendar() {
calendars = await api.get('/api/admin/academic-calendars'); calendars = await api.get('/api/admin/academic-calendars');
renderCalendars(); renderCalendars();
populateEditorCalendarSelect(); populateEditorCalendarSelect();
populateSubjectCalendarSelect();
} catch (error) { } catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
@@ -391,9 +427,10 @@ export async function initAcademicCalendar() {
<td>${escapeHtml(calendar.studyFormName || '-')}</td> <td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td> <td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button> <button class="btn btn-sm btn-secondary 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 btn-sm btn-secondary 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-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -431,9 +468,11 @@ export async function initAcademicCalendar() {
async function handleCalendarTableClick(event) { async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar'); const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid'); 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'); const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) { if (editButton) {
activateCalendarTab('manage');
const calendar = calendars.find(item => item.id == editButton.dataset.id); const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return; if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика'; calendarFormTitle.textContent = 'Редактирование календарного графика';
@@ -451,13 +490,23 @@ export async function initAcademicCalendar() {
} }
if (gridButton) { if (gridButton) {
activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id; editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect); refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid(); await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
if (subjectsButton) {
activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id;
refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) { if (deleteButton) {
if (!confirm('Удалить календарный график?')) return; if (!confirm('Удалить календарный график?')) return;
try { try {
@@ -480,6 +529,149 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert'); 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() { async function loadCalendarGrid() {
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
@@ -559,8 +751,8 @@ export async function initAcademicCalendar() {
selectedCalendarDayKey = null; selectedCalendarDayKey = null;
selectedCalendarDays = []; selectedCalendarDays = [];
const grouped = groupBy(rows, row => row.courseNumber); const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<details class="calendar-course-block" ${index === 0 ? 'open' : ''}> <details class="calendar-course-block">
<summary class="calendar-course-header"> <summary class="calendar-course-header">
<span class="calendar-course-title"> <span class="calendar-course-title">
<span class="calendar-course-chevron" aria-hidden="true"></span> <span class="calendar-course-chevron" aria-hidden="true"></span>
@@ -580,39 +772,56 @@ export async function initAcademicCalendar() {
function renderCourseSemesters(course, courseRows) { function renderCourseSemesters(course, courseRows) {
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
const maxWeekColumns = Math.max(
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
1
);
return semestersForCourse.map(block => ` return semestersForCourse.map(block => `
<section class="calendar-semester-block"> <section class="calendar-semester-block">
<div class="calendar-semester-header"> <div class="calendar-semester-header">
<h4>${block.semesterNumber} семестр</h4> <h4>${block.semesterNumber} семестр</h4>
<span>${escapeHtml(block.caption)}</span> <span>${escapeHtml(block.caption)}</span>
</div> </div>
${renderSemesterTable(block.rows)} ${renderSemesterTable(block.rows, maxWeekColumns)}
</section> </section>
`).join(''); `).join('');
} }
function renderSemesterTable(rows) { function renderSemesterTable(rows, targetWeekColumns = 0) {
if (!rows.length) { if (!rows.length) {
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>'; return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
} }
const rowsByWeek = groupBy(rows, row => row.weekNumber); const rowsByWeek = groupBy(rows, row => row.weekNumber);
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
const weekSlots = [...weeks];
while (weekSlots.length < targetWeekColumns) {
weekSlots.push(null);
}
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return ` return `
<div class="calendar-semester-table-wrap"> <div class="calendar-semester-table-wrap">
<table class="calendar-semester-table"> <table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
</colgroup>
<thead> <thead>
<tr> <tr>
<th class="calendar-day-head">День</th> <th class="calendar-day-head">День</th>
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')} ${weekSlots.map(week => week === null
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${DAY_OPTIONS.map(day => ` ${DAY_OPTIONS.map(day => `
<tr> <tr>
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th> <th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
${weeks.map(week => { ${weekSlots.map(week => {
if (week === null) {
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`); const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>'; return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
}).join('')} }).join('')}
@@ -624,6 +833,10 @@ export async function initAcademicCalendar() {
`; `;
} }
function uniqueWeekNumbers(rows) {
return Array.from(new Set(rows.map(row => row.weekNumber)));
}
function renderCalendarDay(row) { function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = shortDayLabel(row.dayOfWeek); const dayName = shortDayLabel(row.dayOfWeek);
@@ -870,7 +1083,7 @@ export async function initAcademicCalendar() {
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
return ` return `
<div class="calendar-day-tooltip-head"> <div class="calendar-day-tooltip-head">
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}">${escapeHtml(code)}</span> <span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
<div> <div>
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong> <strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span> <span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
@@ -1074,6 +1287,21 @@ export async function initAcademicCalendar() {
syncSelects(calendarStudyFormSelect); 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() { function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' + const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join(''); activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
@@ -1091,6 +1319,41 @@ export async function initAcademicCalendar() {
syncSelects(editorCalendarSelect); 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() { function populateFillCourseSelect() {
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' + fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
@@ -1115,6 +1378,38 @@ export async function initAcademicCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); 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() { function theoryActivityId() {
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
} }
@@ -1143,7 +1438,13 @@ export async function initAcademicCalendar() {
function semesterLabel(semester) { function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType); 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) { function normalizeSemesterType(type) {
@@ -1174,6 +1475,16 @@ export async function initAcademicCalendar() {
return `${day}.${month}.${year}`; 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) { function dateRangeCaption(rows) {
if (!rows.length) return ''; if (!rows.length) return '';
const dates = rows.map(row => row.date).sort(); const dates = rows.map(row => row.date).sort();
@@ -1219,7 +1530,16 @@ export async function initAcademicCalendar() {
function syncSelects(...selects) { function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
refreshSelectVisuals(select);
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
}); });
} }
function refreshSelectVisuals(...selects) {
selects.filter(Boolean).forEach(select => {
if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu();
}
});
}
} }

View File

@@ -68,13 +68,6 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'EVEN', label: 'Чётная' } { parity: 'EVEN', label: 'Чётная' }
]; ];
const CAPACITY_BUCKETS = [
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
];
export async function initAuditoriumWorkload() { export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date'); const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh'); const refreshButton = document.getElementById('workload-refresh');
@@ -89,6 +82,7 @@ export async function initAuditoriumWorkload() {
const roomTables = document.getElementById('room-workload-tables'); const roomTables = document.getElementById('room-workload-tables');
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter')); const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label'); const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
const minCapacityInput = document.getElementById('workload-min-capacity');
let classrooms = []; let classrooms = [];
let teachers = []; let teachers = [];
@@ -117,7 +111,6 @@ export async function initAuditoriumWorkload() {
function initFilters() { function initFilters() {
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
} }
@@ -142,7 +135,6 @@ export async function initAuditoriumWorkload() {
[ [
['building-checkboxes', 'building-text', 'Все корпуса'], ['building-checkboxes', 'building-text', 'Все корпуса'],
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
['equipment-checkboxes', 'equipment-text', 'Любое оборудование'] ['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
].forEach(([containerId, textId, emptyText]) => { ].forEach(([containerId, textId, emptyText]) => {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -153,6 +145,12 @@ export async function initAuditoriumWorkload() {
} }
}); });
}); });
minCapacityInput?.addEventListener('input', () => {
if (currentView === DISPLAY_ALL) {
renderGrid();
}
});
} }
async function loadInitialData() { async function loadInitialData() {
@@ -345,12 +343,6 @@ export async function initAuditoriumWorkload() {
item => item.value, item => item.value,
item => item.label item => item.label
); );
renderCheckboxes(
'capacity-checkboxes',
CAPACITY_BUCKETS,
item => item.id,
item => item.name
);
renderCheckboxes( renderCheckboxes(
'equipment-checkboxes', 'equipment-checkboxes',
equipmentOptions(), equipmentOptions(),
@@ -359,7 +351,6 @@ export async function initAuditoriumWorkload() {
); );
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса'); updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование'); updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
} }
@@ -934,14 +925,12 @@ export async function initAuditoriumWorkload() {
function filteredClassrooms() { function filteredClassrooms() {
const selectedBuildings = selectedValues('building-checkboxes'); const selectedBuildings = selectedValues('building-checkboxes');
const selectedCapacities = selectedValues('capacity-checkboxes'); const minCapacity = parseInt(minCapacityInput?.value || '0', 10) || 0;
const selectedEquipment = selectedValues('equipment-checkboxes'); const selectedEquipment = selectedValues('equipment-checkboxes');
return classrooms.filter(room => { return classrooms.filter(room => {
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room)); const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
.filter(bucket => selectedCapacities.includes(bucket.id))
.some(bucket => bucket.match(room.capacity));
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id => const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
(room.equipments || []).some(item => String(item.id) === String(id)) (room.equipments || []).some(item => String(item.id) === String(id))
); );

View File

@@ -1,6 +1,35 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js'; import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
updateSelectText(containerId, selectTextId);
return;
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(eq.name)}</span>
</label>
`).join('');
updateSelectText(containerId, selectTextId);
}
export async function initClassrooms() { export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody'); const classroomsTbody = document.getElementById('classrooms-tbody');
@@ -59,16 +88,16 @@ export async function initClassrooms() {
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}"> <span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span> </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> <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> </button>
</div> </div>
</td> </td>
<td style="text-align: right;"> <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' ${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>` ? `<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> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -97,9 +126,9 @@ export async function initClassrooms() {
}); });
classroomsTbody.addEventListener('click', async (e) => { 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 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 (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
@@ -191,5 +220,82 @@ export async function initClassrooms() {
} }
}); });
// Элементы модалки оборудования
const btnManageEquipments = document.getElementById('btn-manage-equipments');
const modalManageEquipments = document.getElementById('modal-manage-equipments');
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
const manageEquipmentForm = document.getElementById('manage-equipment-form');
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
modalManageEquipments.addEventListener('click', (e) => {
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
});
async function openManageEquipmentsModal() {
hideAlert('manage-equipment-alert');
manageEquipmentForm.reset();
modalManageEquipments.classList.add('open');
await loadManageEquipments();
}
async function loadManageEquipments() {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
allEquipments = await fetchEquipments();
renderManageEquipmentsTable();
// Обновим чекбоксы на основной странице
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
} catch (e) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderManageEquipmentsTable() {
if (!allEquipments || !allEquipments.length) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr>
`).join('');
}
manageEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-equipment-alert');
const name = manageEquipmentNameInput.value.trim();
if (!name) {
showAlert('manage-equipment-alert', 'Введите название', 'error');
return;
}
try {
const data = await api.post('/api/equipments', { name });
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
manageEquipmentForm.reset();
await loadManageEquipments();
} catch (err) {
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
}
});
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
alert(err.message || 'Ошибка удаления');
}
});
loadInitialData(); loadInitialData();
} }

View File

@@ -0,0 +1,322 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDashboard() {
// DOM Элементы
const metricGroups = document.getElementById('metric-groups');
const metricStudents = document.getElementById('metric-students');
const metricTeachers = document.getElementById('metric-teachers');
const metricClassrooms = document.getElementById('metric-classrooms');
const currentTimeSlot = document.getElementById('current-time-slot');
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
const freeClassroomsList = document.getElementById('free-classrooms-list');
const conflictsContainer = document.getElementById('conflicts-container');
const btnRecheck = document.getElementById('btn-recheck-conflicts');
let departments = [];
let classrooms = [];
let groups = [];
let teachers = [];
let todayLessons = [];
let timeSlots = [];
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.textContent = '...';
await checkConflicts();
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
});
async function loadData() {
try {
// Загружаем базовые справочники
[departments, classrooms, groups, teachers] = await Promise.all([
api.get('/api/departments'),
api.get('/api/classrooms?includeArchived=false'),
api.get('/api/groups?includeArchived=false'),
api.get('/api/users/teachers')
]);
// Выводим метрики
metricGroups.textContent = groups.length;
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = new Date().toISOString().split('T')[0];
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
);
const lessonsArrays = await Promise.all(lessonsPromises);
todayLessons = lessonsArrays.flat();
try {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
} catch (e) {
// Если эндпоинт effective не поддерживается, используем базовые слоты
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
}
// Сортируем слоты по времени
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
// Посчитаем занятость аудиторий сегодня
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
const classPercent = activeClassrooms.length
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
: 0;
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
await checkConflicts();
} catch (e) {
console.error("Dashboard data load error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
}
}
function updateRealtimeMonitoring() {
const now = new Date();
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
// Находим текущий временной слот
let currentSlot = null;
for (const slot of timeSlots) {
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
currentSlot = slot;
break;
}
}
if (currentSlot) {
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} ${currentSlot.endTime.substring(0, 5)})`;
renderCurrentLessons(currentSlot.orderNumber);
renderFreeClassrooms(currentSlot.orderNumber);
} else {
currentTimeSlot.textContent = 'Вне сетки пар';
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
renderFreeClassrooms(null);
}
}
function renderCurrentLessons(slotOrder) {
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
if (!activeLessons.length) {
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
return;
}
currentLessonsTbody.innerHTML = activeLessons.map(l => `
<tr>
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
</tr>
`).join('');
}
function renderFreeClassrooms(slotOrder) {
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
let occupiedIds = new Set();
if (slotOrder !== null) {
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
}
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
if (!free.length) {
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
return;
}
freeClassroomsList.innerHTML = free.map(c => `
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
${escapeHtml(c.name)} (${c.capacity} м.)
</span>
`).join('');
}
// --- Анализ конфликтов (Red Zone) ---
async function checkConflicts() {
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
// Вычисляем даты начала и конца текущей недели
const curr = new Date();
const day = curr.getDay();
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
const monday = new Date(curr.setDate(diff));
const sunday = new Date(curr.setDate(diff + 6));
const mondayStr = monday.toISOString().split('T')[0];
const sundayStr = sunday.toISOString().split('T')[0];
try {
// Загружаем расписание на всю неделю по кафедрам
const weekLessonsPromises = departments.map(d =>
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
);
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
const weekLessons = weekLessonsArrays.flat();
const conflicts = [];
// Мапы для быстрого поиска характеристик групп и аудиторий
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
// 1. Проверка накладок преподавателей
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
const teacherSlots = new Map();
weekLessons.forEach(l => {
if (!l.teacherId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
if (!teacherSlots.has(key)) {
teacherSlots.set(key, []);
}
teacherSlots.get(key).push(l);
});
teacherSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
const classroomIds = new Set(lessons.map(l => l.classroomId));
if (classroomIds.size > 1) {
const first = lessons[0];
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
conflicts.push({
type: 'TEACHER_COLLISION',
title: 'Накладка в расписании преподавателя',
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 2. Проверка накладок аудиторий
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
const classroomSlots = new Map();
weekLessons.forEach(l => {
if (!l.classroomId) return;
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
if (!classroomSlots.has(key)) {
classroomSlots.set(key, []);
}
classroomSlots.get(key).push(l);
});
classroomSlots.forEach((lessons, key) => {
if (lessons.length > 1) {
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
const teacherIds = new Set(lessons.map(l => l.teacherId));
const subjectIds = new Set(lessons.map(l => l.subjectId));
if (teacherIds.size > 1 || subjectIds.size > 1) {
const first = lessons[0];
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
conflicts.push({
type: 'CLASSROOM_COLLISION',
title: 'Накладка в аудитории',
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'error'
});
}
}
});
// 3. Проверка превышения вместимости аудитории
// Считаем сумму студентов для каждого уникального занятия в аудитории
classroomSlots.forEach((lessons, key) => {
if (!lessons.length) return;
const first = lessons[0];
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
const groupIds = new Set();
lessons.forEach(l => {
if (l.groupIds) {
l.groupIds.forEach(id => groupIds.add(id));
}
});
let totalStudents = 0;
groupIds.forEach(id => {
totalStudents += groupSizeMap.get(id) || 0;
});
if (totalStudents > capacity) {
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
conflicts.push({
type: 'CAPACITY_OVERFLOW',
title: 'Превышение вместимости аудитории',
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
severity: 'warning'
});
}
});
renderConflicts(conflicts);
} catch (e) {
console.error("Conflicts check error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
}
}
function renderConflicts(conflicts) {
if (!conflicts.length) {
conflictsContainer.innerHTML = `
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div>
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
</div>
</div>
`;
return;
}
conflictsContainer.innerHTML = conflicts.map(c => {
const isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
<div style="flex: 1;">
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
</div>
</div>
`;
}).join('');
}
// Загружаем данные при старте
await loadData();
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
const realtimeTimer = setInterval(() => {
if (document.getElementById('current-time-slot')) {
updateRealtimeMonitoring();
} else {
clearInterval(realtimeTimer);
}
}, 30000);
}

View File

@@ -12,6 +12,14 @@ export async function initDepartmentWorkspace() {
const endInput = document.getElementById('department-workspace-end'); const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh'); const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form'); 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 subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count'); const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card'); const commentsCard = document.getElementById('department-comments-card');
@@ -21,13 +29,30 @@ export async function initDepartmentWorkspace() {
const commentsSubjectInput = document.getElementById('department-comment-subject-id'); const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text'); const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close'); const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let allTeachers = [];
let teacherRequests = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
setDefaultDates(startInput, endInput); setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace); refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace); departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', loadWorkload); startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload); endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects); importForm?.addEventListener('submit', importSubjects);
assignTeacherForm?.addEventListener('submit', assignTeacherToDepartment);
teacherRequestForm?.addEventListener('submit', submitTeacherRequest);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
if (removeBtn) {
const rows = readImportRows();
rows.splice(Number(removeBtn.dataset.index), 1);
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
}
});
commentsForm?.addEventListener('submit', saveComment); commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => { commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true; commentsCard.hidden = true;
@@ -46,6 +71,8 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); await loadWorkspace();
} catch (error) { } catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error'); showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
@@ -53,7 +80,14 @@ export async function initDepartmentWorkspace() {
async function loadWorkspace() { async function loadWorkspace() {
hideAlert('department-workspace-alert'); hideAlert('department-workspace-alert');
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); lastTeachers = null;
lastWorkload = null;
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
teacherRequests = null;
renderTeacherRequests();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload(), loadTeacherRequests()]);
} }
async function loadSubjects() { async function loadSubjects() {
@@ -73,7 +107,7 @@ export async function initDepartmentWorkspace() {
<td>${escapeHtml(subject.name || '-')}</td> <td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td> <td>${escapeHtml(subject.description || '-')}</td>
<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-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}"> data-subject-name="${escapeHtml(subject.name || '')}">
Открыть Открыть
@@ -87,44 +121,125 @@ export async function initDepartmentWorkspace() {
} }
} }
function renderImportRows(rows) {
if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}">
<div class="form-group">
<label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div>
<div class="form-group">
<label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div>
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readImportRows() {
if (!importRowsContainer) return [];
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
name: (row.querySelector('.subject-import-name')?.value || '').trim()
}));
}
async function importSubjects(event) { async function importSubjects(event) {
event.preventDefault(); event.preventDefault();
const alertId = 'department-subject-import-alert'; const alertId = 'department-subject-import-alert';
hideAlert(alertId); hideAlert(alertId);
const departmentId = selectedDepartmentId(); const departmentId = selectedDepartmentId();
const rows = document.getElementById('department-subject-import-text').value const rows = readImportRows()
.split('\n') .filter(item => item.name)
.map(row => row.trim()) .map(item => ({
.filter(Boolean) code: item.code,
.map(row => { name: item.name,
const [code, ...nameParts] = row.split(';'); departmentId: Number(departmentId)
return { }));
code: (code || '').trim(),
name: nameParts.join(';').trim(),
departmentId: Number(departmentId)
};
})
.filter(item => item.name);
if (!departmentId) { if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error'); showAlert(alertId, 'Выберите кафедру', 'error');
return; return;
} }
if (!rows.length) { if (!rows.length) {
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error'); showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
return; return;
} }
try { try {
const result = await api.post('/api/department/subjects/import', rows); const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success'); showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
document.getElementById('department-subject-import-text').value = ''; renderImportRows([{ code: '', name: '' }]);
await loadSubjects(); await loadSubjects();
} catch (error) { } catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error'); showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
} }
} }
async function assignTeacherToDepartment(event) {
event.preventDefault();
const alertId = 'department-teacher-assign-alert';
hideAlert(alertId);
const teacherId = assignTeacherSelect?.value;
const departmentId = selectedDepartmentId();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!teacherId) {
showAlert(alertId, 'Выберите преподавателя', 'error');
return;
}
try {
await api.post(`/api/department/teachers/${encodeURIComponent(teacherId)}/assignments`, {
departmentId: Number(departmentId),
comment: assignTeacherComment?.value.trim() || null
});
showAlert(alertId, 'Преподаватель привязан к кафедре', 'success');
if (assignTeacherComment) assignTeacherComment.value = '';
await Promise.all([loadTeachers(), loadWorkload()]);
} catch (error) {
showAlert(alertId, error.message || 'Ошибка привязки преподавателя', 'error');
}
}
async function submitTeacherRequest(event) {
event.preventDefault();
const alertId = 'department-teacher-request-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const username = document.getElementById('teacher-request-username')?.value.trim();
const fullName = document.getElementById('teacher-request-fullname')?.value.trim();
const jobTitle = document.getElementById('teacher-request-jobtitle')?.value.trim();
const comment = document.getElementById('teacher-request-comment')?.value.trim();
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!username || !fullName) {
showAlert(alertId, 'Заполните логин и ФИО преподавателя', 'error');
return;
}
try {
await api.post('/api/department/teacher-requests', {
departmentId: Number(departmentId),
username,
fullName,
jobTitle,
comment
});
showAlert(alertId, 'Заявка отправлена администратору', 'success');
teacherRequestForm.reset();
await loadTeacherRequests();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка отправки заявки', 'error');
}
}
async function openComments(subjectId, subjectName) { async function openComments(subjectId, subjectName) {
commentsCard.hidden = false; commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`; commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
@@ -172,25 +287,60 @@ export async function initDepartmentWorkspace() {
} }
async function loadTeachers() { async function loadTeachers() {
const container = document.getElementById('department-teachers-list'); lastTeachers = null;
container.textContent = 'Загрузка...'; teachersErrorMessage = '';
renderTeachers();
renderTeacherSelect();
try { try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
container.innerHTML = teachers.length lastTeachers = Array.isArray(teachers) ? teachers : [];
? teachers.map(teacher => ` renderTeachers();
<article class="metric-card"> renderTeacherSelect();
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`; lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
renderTeacherSelect();
}
}
async function loadAllTeachers() {
try {
const teachers = await api.get('/api/users/teachers');
allTeachers = Array.isArray(teachers) ? teachers : [];
renderTeacherSelect();
} catch (error) {
allTeachers = [];
if (assignTeacherSelect) {
assignTeacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</option>`;
}
}
}
async function loadTeacherRequests() {
teacherRequests = null;
renderTeacherRequests();
try {
const requests = await api.get(`/api/department/teacher-requests?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
teacherRequests = Array.isArray(requests) ? requests : [];
renderTeacherRequests();
} catch (error) {
teacherRequests = [];
if (teacherRequestsTbody) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
if (teacherRequestCount) teacherRequestCount.textContent = '0 заявок';
} }
} }
async function loadWorkload() { async function loadWorkload() {
const container = document.getElementById('department-workload-list'); if (!startInput.value || !endInput.value) {
lastWorkload = [];
workloadErrorMessage = 'Выберите начало и окончание периода';
renderTeachers();
return;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: startInput.value, startDate: startInput.value,
endDate: endInput.value endDate: endInput.value
@@ -198,23 +348,127 @@ export async function initDepartmentWorkspace() {
if (selectedDepartmentId()) { if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId()); params.set('departmentId', selectedDepartmentId());
} }
container.textContent = 'Загрузка...'; lastWorkload = null;
workloadErrorMessage = '';
renderTeachers();
try { try {
const workload = await api.get(`/api/workload/teachers?${params}`); const workload = await api.get(`/api/workload/teachers?${params}`);
container.innerHTML = workload.length lastWorkload = Array.isArray(workload) ? workload : [];
? workload.map(item => ` renderTeachers();
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`; lastWorkload = [];
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
renderTeachers();
} }
} }
async function handleStartDateChange() {
if (startInput.value && endInput.value && startInput.value > endInput.value) {
endInput.value = '';
}
await loadWorkload();
}
function renderTeachers() {
const container = document.getElementById('department-teachers-list');
if (!container) return;
if (lastTeachers === null || lastWorkload === null) {
container.textContent = 'Загрузка...';
return;
}
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
workload
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = teachers.map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return renderTeacherMetric({
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
}, workloadErrorMessage);
});
workload
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push(renderTeacherMetric({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
}, workloadErrorMessage));
});
if (rows.length) {
container.innerHTML = rows.join('');
return;
}
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function renderTeacherSelect() {
if (!assignTeacherSelect) return;
if (lastTeachers === null) {
assignTeacherSelect.innerHTML = '<option value="">Загрузка...</option>';
return;
}
const assignedIds = new Set((lastTeachers || []).map(teacher => String(teacher.id)));
const availableTeachers = allTeachers.filter(teacher => !assignedIds.has(String(teacher.id)));
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(teacher => `
<option value="${escapeHtml(teacher.id)}">
${escapeHtml(teacher.fullName || teacher.username || `ID ${teacher.id}`)}
</option>
`).join('');
if (!availableTeachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет доступных преподавателей</option>';
}
}
function renderTeacherRequests() {
if (!teacherRequestsTbody) return;
if (teacherRequests === null) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
if (teacherRequestCount) teacherRequestCount.textContent = 'Загрузка...';
return;
}
if (teacherRequestCount) teacherRequestCount.textContent = requestCountLabel(teacherRequests.length);
if (!teacherRequests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Заявок пока нет</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = teacherRequests.map(request => `
<tr>
<td><span class="badge ${requestStatusClass(request.status)}">${escapeHtml(requestStatusLabel(request.status))}</span></td>
<td>${escapeHtml(request.username || '-')}</td>
<td>${escapeHtml(request.fullName || '-')}</td>
<td>${escapeHtml(request.jobTitle || '-')}</td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>${escapeHtml(request.reviewComment || formatDateTime(request.reviewedAt) || '-')}</td>
</tr>
`).join('');
}
function selectedDepartmentId() { function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
} }
@@ -261,3 +515,60 @@ function subjectCountLabel(count) {
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`; return `${count} дисциплин`;
} }
function requestCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} заявок`;
if (count % 10 === 1) return `${count} заявка`;
if ([2, 3, 4].includes(count % 10)) return `${count} заявки`;
return `${count} заявок`;
}
function requestStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Одобрена',
REJECTED: 'Отклонена'
}[status] || status || '-';
}
function requestStatusClass(status) {
return {
PENDING: 'badge-ef',
APPROVED: 'badge-student',
REJECTED: 'badge-admin'
}[status] || 'badge-ef';
}
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
function workloadLabel(item, workloadErrorMessage) {
if (workloadErrorMessage) {
return workloadErrorMessage;
}
if (!item) {
return '0 ак. ч., 0 занятий за период';
}
const hours = String(item.academicHours ?? 0);
const lessons = String(item.lessonCount ?? 0);
return `${hours} ак. ч., ${lessons} занятий за период`;
}
function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}

View File

@@ -1,226 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDepartmentsData() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
let departments = [];
let specialties = [];
async function loadData() {
// Load Departments
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
// Load Specialties
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
await loadData();
}

View File

@@ -1,90 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
export let allEquipments = [];
export async function fetchEquipments() {
try {
allEquipments = await api.get('/api/equipments');
return allEquipments;
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
container.innerHTML = equipments.map(eq => {
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}>
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label>
`}).join('');
updateSelectText(containerId, textId);
}
export async function initEquipments() {
const equipmentsTbody = document.getElementById('equipments-tbody');
const createEquipmentForm = document.getElementById('create-equipment-form');
async function loadEquipments() {
try {
const equipments = await fetchEquipments();
renderEquipments(equipments);
} catch (e) {
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEquipments(equipments) {
if (!equipments || !equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
createEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-equipment-alert');
const name = document.getElementById('new-equipment-name').value.trim();
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/equipments', { name });
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
createEquipmentForm.reset();
loadEquipments();
} catch (e) {
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
}
});
equipmentsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить оборудование?')) return;
try {
await api.delete('/api/equipments/' + btn.dataset.id);
loadEquipments();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEquipments();
}

View File

@@ -1,6 +1,16 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { fetchEducationForms } from './edu-forms.js';
async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error("Failed to fetch education forms", e);
return [];
}
}
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
export async function initGroups() { export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody'); const groupsTbody = document.getElementById('groups-tbody');
@@ -9,7 +19,12 @@ export async function initGroups() {
const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupDepartmentSelect = document.getElementById('new-group-department');
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile'); 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'); const modalEditGroup = document.getElementById('modal-edit-group');
const modalEditGroupClose = document.getElementById('modal-edit-group-close'); const modalEditGroupClose = document.getElementById('modal-edit-group-close');
const editGroupForm = document.getElementById('edit-group-form'); const editGroupForm = document.getElementById('edit-group-form');
@@ -17,19 +32,30 @@ export async function initGroups() {
const editGroupDepartmentSelect = document.getElementById('edit-group-department'); const editGroupDepartmentSelect = document.getElementById('edit-group-department');
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
const editGroupProfileSelect = document.getElementById('edit-group-profile'); const editGroupProfileSelect = document.getElementById('edit-group-profile');
const calendarForm = document.getElementById('group-calendar-form');
const calendarGroupSelect = document.getElementById('calendar-group'); // Модалка подгрупп
const calendarYearSelect = document.getElementById('calendar-year'); const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
const calendarSelect = document.getElementById('calendar-id'); const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); const subgroupsGroupContext = document.getElementById('subgroups-group-context');
const subgroupForm = document.getElementById('subgroup-form'); const subgroupFormTitle = document.getElementById('subgroup-form-title');
const subgroupIdInput = document.getElementById('subgroup-id'); const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const subgroupGroupSelect = document.getElementById('subgroup-group'); const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const subgroupNameInput = document.getElementById('subgroup-name'); const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const subgroupCapacityInput = document.getElementById('subgroup-capacity'); const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
const subgroupSubmitButton = document.getElementById('subgroup-submit'); const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
const subgroupResetButton = document.getElementById('subgroup-reset'); const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const subgroupsTbody = document.getElementById('subgroups-tbody'); const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
// Модалка календарей
const modalManageCalendar = document.getElementById('modal-manage-calendar');
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
const calendarGroupContext = document.getElementById('calendar-group-context');
const manageCalendarForm = document.getElementById('manage-calendar-form');
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
const manageCalendarSelect = document.getElementById('manage-calendar-id');
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
let allGroups = []; let allGroups = [];
let subgroups = []; let subgroups = [];
@@ -40,33 +66,77 @@ export async function initGroups() {
let academicYears = []; let academicYears = [];
let calendars = []; let calendars = [];
let currentAssignments = []; let currentAssignments = [];
let activeSubgroupGroup = null;
bindEvents(); bindEvents();
await loadInitialData(); await loadInitialData();
function bindEvents() { function bindEvents() {
filterEfSelect.addEventListener('change', applyGroupFilter); initFilterEfDropdown();
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
calendarGroupSelect.addEventListener('change', () => {
populateCalendarSelect();
loadAssignments();
});
calendarYearSelect.addEventListener('change', populateCalendarSelect);
createGroupForm.addEventListener('submit', createGroup); createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup); editGroupForm.addEventListener('submit', updateGroup);
calendarForm.addEventListener('submit', saveAssignment);
subgroupForm.addEventListener('submit', saveSubgroup); // Модалка подгрупп
subgroupGroupSelect.addEventListener('change', renderSubgroups); manageSubgroupForm.addEventListener('submit', saveSubgroup);
subgroupResetButton.addEventListener('click', resetSubgroupForm); manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
groupsTbody.addEventListener('click', handleGroupTableClick); groupsTbody.addEventListener('click', handleGroupTableClick);
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); // Закрытие модалок
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => { modalEditGroup.addEventListener('click', (event) => {
if (event.target === modalEditGroup) { if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
modalEditGroup.classList.remove('open'); });
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
modalManageSubgroups.addEventListener('click', (event) => {
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
});
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
modalManageCalendar.addEventListener('click', (event) => {
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
});
}
function initFilterEfDropdown() {
if (!filterEfBox || !filterEfMenu) return;
const toggleDropdown = (event) => {
event.stopPropagation();
const isOpen = filterEfMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (!isOpen) {
filterEfMenu.classList.add('open');
filterEfBox.classList.add('active');
filterEfBox.setAttribute('aria-expanded', 'true');
} else {
filterEfBox.setAttribute('aria-expanded', 'false');
} }
};
filterEfBox.addEventListener('click', toggleDropdown);
filterEfBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleDropdown(event);
}
});
filterEfMenu.addEventListener('click', (event) => {
event.stopPropagation();
}); });
} }
@@ -84,12 +154,10 @@ export async function initGroups() {
populateDepartmentSelects(departments); populateDepartmentSelects(departments);
populateSpecialitySelects(specialties); populateSpecialitySelects(specialties);
populateYearSelect(); populateYearSelect();
populateCalendarSelect();
await loadGroups(); await loadGroups();
await loadSubgroups(); await loadSubgroups();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -105,9 +173,6 @@ export async function initGroups() {
try { try {
allGroups = await api.get('/api/groups?includeArchived=true'); allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter(); applyGroupFilter();
populateGroupSelect();
populateSubgroupGroupSelect();
await loadAssignments();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
} }
@@ -116,20 +181,69 @@ export async function initGroups() {
async function loadSubgroups() { async function loadSubgroups() {
try { try {
subgroups = await api.get('/api/subgroups'); subgroups = await api.get('/api/subgroups');
renderSubgroups();
} catch (error) { } catch (error) {
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; console.error("Failed to load subgroups", error);
} }
} }
function applyGroupFilter() { function applyGroupFilter() {
const filterId = filterEfSelect.value; const selectedIds = selectedFilterEducationFormIds();
const filtered = filterId const filtered = selectedIds.length
? allGroups.filter(group => group.educationFormId == filterId) ? allGroups.filter(group => selectedIds.includes(String(group.educationFormId)))
: allGroups; : allGroups;
renderGroups(filtered); 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) { function renderGroups(groups) {
if (!groups || !groups.length) { if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
@@ -138,7 +252,7 @@ export async function initGroups() {
groupsTbody.innerHTML = groups.map(group => ` groupsTbody.innerHTML = groups.map(group => `
<tr> <tr>
<td>${group.id}</td> <td>${group.id}</td>
<td>${escapeHtml(group.name)}</td> <td><strong>${escapeHtml(group.name)}</strong></td>
<td>${escapeHtml(String(group.groupSize))}</td> <td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td> <td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td> <td>${escapeHtml(departmentLabel(group.departmentId))}</td>
@@ -146,11 +260,15 @@ export async function initGroups() {
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td> <td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td> <td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td> <td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td style="text-align: right;"> <td class="group-actions-cell">
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button> <div class="group-actions">
${group.status === 'ARCHIVED' <button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>` <button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -159,11 +277,30 @@ export async function initGroups() {
function populateEfSelects(forms) { function populateEfSelects(forms) {
populateEfSelect(newGroupEfSelect, forms); populateEfSelect(newGroupEfSelect, forms);
populateEfSelect(editGroupEfSelect, forms); populateEfSelect(editGroupEfSelect, forms);
const currentFilter = filterEfSelect.value; renderFilterEfCheckboxes(forms);
filterEfSelect.innerHTML = '<option value="">Все формы</option>' + }
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentFilter) filterEfSelect.value = currentFilter; function renderFilterEfCheckboxes(forms) {
syncSelects(filterEfSelect); 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) { function populateEfSelect(select, forms) {
@@ -225,43 +362,18 @@ export async function initGroups() {
} }
function populateYearSelect() { function populateYearSelect() {
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' + manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join(''); academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (!calendarYearSelect.value && academicYears.length) { if (academicYears.length) {
calendarYearSelect.value = String(academicYears[0].id); manageCalendarYearSelect.value = String(academicYears[0].id);
} }
syncSelects(calendarYearSelect); syncSelects(manageCalendarYearSelect);
}
function populateGroupSelect() {
const current = calendarGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && availableGroups.some(group => String(group.id) === String(current))) {
calendarGroupSelect.value = current;
} else if (availableGroups.length) {
calendarGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(calendarGroupSelect);
}
function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && availableGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current;
} else if (availableGroups.length) {
subgroupGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(subgroupGroupSelect);
} }
function populateCalendarSelect() { function populateCalendarSelect() {
const group = selectedCalendarGroup(); const groupId = manageCalendarGroupIdInput.value;
const yearId = calendarYearSelect.value; const group = allGroups.find(g => g.id == groupId);
const yearId = manageCalendarYearSelect.value;
const matching = group && yearId const matching = group && yearId
? calendars.filter(calendar => ? calendars.filter(calendar =>
String(calendar.academicYearId) === String(yearId) String(calendar.academicYearId) === String(yearId)
@@ -270,12 +382,12 @@ export async function initGroups() {
&& String(calendar.studyFormId) === String(group.educationFormId) && String(calendar.studyFormId) === String(group.educationFormId)
) )
: []; : [];
calendarSelect.innerHTML = matching.length manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar => ? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('') ).join('')
: '<option value="">Нет подходящих графиков</option>'; : '<option value="">Нет подходящих графиков</option>';
syncSelects(calendarSelect); syncSelects(manageCalendarSelect);
} }
async function createGroup(event) { async function createGroup(event) {
@@ -290,7 +402,6 @@ export async function initGroups() {
createGroupForm.reset(); createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
} }
@@ -308,7 +419,6 @@ export async function initGroups() {
modalEditGroup.classList.remove('open'); modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
} }
@@ -345,16 +455,17 @@ export async function initGroups() {
} }
async function handleGroupTableClick(event) { 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 btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group'); const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) { if (btnDelete) {
if (!confirm('Архивировать группу? История расписания сохранится.')) return; if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try { try {
await api.delete('/api/groups/' + btnDelete.dataset.id); await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка архивирования'); alert(error.message || 'Ошибка архивирования');
} }
@@ -364,7 +475,6 @@ export async function initGroups() {
try { try {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {}); await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка восстановления'); alert(error.message || 'Ошибка восстановления');
} }
@@ -373,6 +483,18 @@ export async function initGroups() {
if (btnEdit) { if (btnEdit) {
openEditGroupModal(btnEdit.dataset.id); openEditGroupModal(btnEdit.dataset.id);
} }
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openSubgroupsModal(group);
}
if (btnCalendar) {
const groupId = btnCalendar.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openCalendarModal(group);
}
} }
function openEditGroupModal(id) { function openEditGroupModal(id) {
@@ -395,109 +517,294 @@ export async function initGroups() {
modalEditGroup.classList.add('open'); modalEditGroup.classList.add('open');
} }
async function saveSubgroup(event) { // --- Управление подгруппами ---
event.preventDefault(); function openSubgroupsModal(group) {
hideAlert('subgroup-alert'); activeSubgroupGroup = group;
const groupId = subgroupGroupSelect.value; subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
const name = subgroupNameInput.value.trim(); manageSubgroupGroupIdInput.value = group.id;
const capacity = subgroupCapacityInput.value; resetSubgroupForm();
const subgroupId = subgroupIdInput.value; modalManageSubgroups.classList.add('open');
renderSubgroupsList(group.id);
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
const payload = {
name,
studentCapacity: capacity ? Number(capacity) : null
};
try {
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
const saved = subgroupId
? await api.put(endpoint, payload)
: await api.post(endpoint, payload);
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm();
await loadSubgroups();
} catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
} }
function renderSubgroups() { function renderSubgroupsList(groupId) {
const groupId = subgroupGroupSelect.value; const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
const visibleSubgroups = groupId if (!list.length) {
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId)) manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
: subgroups;
if (!visibleSubgroups.length) {
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
return; return;
} }
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => ` const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr> <tr>
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td> <td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(subgroup.name)}</td> <td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
${canDeleteSingleSubgroup ? '' : 'disabled'}
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
} }
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
const count = [2, 3].includes(current.length) ? current.length : 0;
manageSubgroupCountSelect.value = String(count);
syncSelects(manageSubgroupCountSelect);
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.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 group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
const count = Number(manageSubgroupCountSelect.value);
const existingSubgroups = currentGroupSubgroups(groupId);
if (!group) {
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
return;
}
if (count === 0) {
await applyNoSubgroups(groupId, existingSubgroups);
return;
}
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
.map(input => Number(input.value));
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
if (validationError) {
showAlert('manage-subgroup-alert', validationError, 'error');
return;
}
try {
await syncSubgroups(groupId, existingSubgroups, capacities);
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
await loadSubgroups();
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) { async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup'); const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup'); const deleteButton = event.target.closest('.btn-delete-subgroup');
const groupId = manageSubgroupGroupIdInput.value;
if (editButton) { if (editButton) {
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id)); const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return; if (!subgroup) return;
subgroupIdInput.value = subgroup.id;
subgroupGroupSelect.value = subgroup.groupId; resetSubgroupForm();
subgroupGroupSelect.disabled = true; const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
subgroupNameInput.value = subgroup.name || ''; const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
subgroupCapacityInput.value = subgroup.studentCapacity ?? ''; input?.focus();
subgroupSubmitButton.textContent = 'Сохранить подгруппу'; hideAlert('manage-subgroup-alert');
syncSelects(subgroupGroupSelect);
hideAlert('subgroup-alert');
return; return;
} }
if (deleteButton) { if (deleteButton) {
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id)); if (deleteButton.disabled) {
if (!subgroup || !confirm('Удалить подгруппу?')) return; showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
return;
}
if (!confirm('Удалить подгруппу?')) return;
try { try {
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`); await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('subgroup-alert', 'Подгруппа удалена', 'success'); showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm(); resetSubgroupForm();
await loadSubgroups(); await loadSubgroups();
renderSubgroupsList(groupId);
} catch (error) { } catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error'); showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
} }
} }
} }
function resetSubgroupForm() { // --- Управление календарными графиками ---
subgroupIdInput.value = ''; async function openCalendarModal(group) {
subgroupNameInput.value = ''; calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
subgroupCapacityInput.value = ''; manageCalendarGroupIdInput.value = group.id;
subgroupGroupSelect.disabled = false; hideAlert('manage-calendar-alert');
subgroupSubmitButton.textContent = 'Создать подгруппу'; populateCalendarSelect();
syncSelects(subgroupGroupSelect); modalManageCalendar.classList.add('open');
hideAlert('subgroup-alert'); await loadAssignmentsList(group.id);
}
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignmentsList();
} catch (error) {
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignmentsList() {
if (!currentAssignments.length) {
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
return;
}
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</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) { async function saveAssignment(event) {
event.preventDefault(); event.preventDefault();
hideAlert('group-calendar-alert'); hideAlert('manage-calendar-alert');
const groupId = calendarGroupSelect.value; const groupId = manageCalendarGroupIdInput.value;
const academicYearId = calendarYearSelect.value; const academicYearId = manageCalendarYearSelect.value;
const calendarId = calendarSelect.value; const calendarId = manageCalendarSelect.value;
if (!groupId || !academicYearId || !calendarId) { if (!academicYearId || !calendarId) {
showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error'); showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
return; return;
} }
@@ -506,74 +813,36 @@ export async function initGroups() {
academicYearId: Number(academicYearId), academicYearId: Number(academicYearId),
calendarId: Number(calendarId) calendarId: Number(calendarId)
}); });
showAlert('group-calendar-alert', 'Календарный график назначен', 'success'); showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
await loadAssignments(); await loadAssignmentsList(groupId);
} catch (error) { } catch (error) {
showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error'); showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
} }
} }
async function loadAssignments() { manageCalendarTbody.addEventListener('click', async (event) => {
const groupId = calendarGroupSelect.value;
if (!groupId) {
currentAssignments = [];
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
return;
}
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignments();
} catch (error) {
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignments() {
if (!currentAssignments.length) {
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
return;
}
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleAssignmentTableClick(event) {
const deleteButton = event.target.closest('.btn-delete-assignment'); const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return; if (!deleteButton) return;
if (!confirm('Удалить назначение графика?')) return; if (!confirm('Удалить назначение графика?')) return;
const groupId = manageCalendarGroupIdInput.value;
try { try {
await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
showAlert('group-calendar-alert', 'Назначение удалено', 'success'); showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
await loadAssignments(); await loadAssignmentsList(groupId);
} catch (error) { } catch (error) {
showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
} }
} });
function selectedCalendarGroup() {
return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value));
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) { function departmentLabel(departmentId) {
const department = departments.find(item => item.id == departmentId); const department = departments.find(item => item.id == departmentId);
if (!department) return departmentId || '-'; if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId; return department.departmentName || department.name || departmentId;
} }
function groupName(groupId) {
const group = allGroups.find(item => String(item.id) === String(groupId));
return group ? group.name : groupId || '-';
}
function specialityLabel(specialityId) { function specialityLabel(specialityId) {
const speciality = specialties.find(item => item.id == specialityId); const speciality = specialties.find(item => item.id == specialityId);
if (!speciality) return specialityId || '-'; if (!speciality) return specialityId || '-';
@@ -582,6 +851,11 @@ export async function initGroups() {
return name ? `${code}${name}` : code; return name ? `${code}${name}` : code;
} }
function subjectLabel(subject) {
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
}
function statusLabel(group) { function statusLabel(group) {
if (group.status === 'ARCHIVED') return 'Архив'; if (group.status === 'ARCHIVED') return 'Архив';
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна'); return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');

View File

@@ -1,192 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initProfiles() {
const createProfileForm = document.getElementById('create-profile-form');
const profileSpecialtySelect = document.getElementById('profile-specialty');
const profileNameInput = document.getElementById('profile-name');
const profileDescriptionInput = document.getElementById('profile-description');
const profilesTbody = document.getElementById('profiles-tbody');
const profilesContext = document.getElementById('profiles-context');
const refreshButton = document.getElementById('profiles-refresh');
const editProfileForm = document.getElementById('edit-profile-form');
const editProfileModal = document.getElementById('modal-edit-profile');
const editProfileClose = document.getElementById('modal-edit-profile-close');
let specialties = [];
let profiles = [];
await loadSpecialties();
profileSpecialtySelect.addEventListener('change', loadProfiles);
refreshButton.addEventListener('click', loadProfiles);
createProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('create-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const name = profileNameInput.value.trim();
const description = profileDescriptionInput.value.trim();
if (!specialtyId || !name) {
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
return;
}
try {
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
createProfileForm.reset();
profileSpecialtySelect.value = specialtyId;
syncSelect(profileSpecialtySelect);
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (event) => {
const editButton = event.target.closest('.btn-edit-profile');
const deleteButton = event.target.closest('.btn-delete-profile');
if (editButton) {
const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id));
if (!profile) return;
document.getElementById('edit-profile-id').value = profile.id;
document.getElementById('edit-profile-name').value = profile.name || '';
document.getElementById('edit-profile-description').value = profile.description || '';
hideAlert('edit-profile-alert');
editProfileModal.classList.add('open');
return;
}
if (deleteButton) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`);
showAlert('create-profile-alert', 'Профиль удалён', 'success');
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
editProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('edit-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const id = document.getElementById('edit-profile-id').value;
const name = document.getElementById('edit-profile-name').value.trim();
const description = document.getElementById('edit-profile-description').value.trim();
if (!specialtyId || !id || !name) {
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
return;
}
try {
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
editProfileModal.classList.remove('open');
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
await loadProfiles();
} catch (error) {
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
}
});
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
editProfileModal.addEventListener('click', (event) => {
if (event.target === editProfileModal) {
editProfileModal.classList.remove('open');
}
});
async function loadSpecialties() {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
specialties = await api.get('/api/specialties');
renderSpecialtySelect();
await loadProfiles();
} catch (error) {
profileSpecialtySelect.innerHTML = '<option value="">Не удалось загрузить специальности</option>';
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
syncSelect(profileSpecialtySelect);
}
}
function renderSpecialtySelect() {
if (!specialties.length) {
profileSpecialtySelect.innerHTML = '<option value="">Сначала создайте специальность</option>';
syncSelect(profileSpecialtySelect);
return;
}
const current = profileSpecialtySelect.value;
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(specialty => {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `<option value="${specialty.id}">${escapeHtml(code)}${escapeHtml(name)}</option>`;
}).join('');
if (current && specialties.some(specialty => String(specialty.id) === String(current))) {
profileSpecialtySelect.value = current;
} else {
profileSpecialtySelect.value = String(specialties[0].id);
}
syncSelect(profileSpecialtySelect);
}
async function loadProfiles() {
const specialtyId = profileSpecialtySelect.value;
const specialty = specialties.find(item => String(item.id) === String(specialtyId));
profilesContext.textContent = specialty ? specialtyLabel(specialty) : '';
if (!specialtyId) {
profiles = [];
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
return;
}
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
renderProfiles();
} catch (error) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderProfiles() {
if (!profiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = profiles.map(profile => `
<tr>
<td>${profile.id}</td>
<td>${escapeHtml(profile.name)}</td>
<td>${escapeHtml(profile.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function specialtyLabel(specialty) {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `${code} · ${name}`;
}
function syncSelect(select) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}

View File

@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'EVEN', label: 'Чётная' } { parity: 'EVEN', label: 'Чётная' }
]; ];
const MOBILE_QUERY = '(max-width: 760px)'; const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
export async function initScheduleView() { export async function initScheduleView() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
@@ -451,9 +452,6 @@ export async function initScheduleView() {
<th class="room-day-cell"> <th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong> <strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span> <span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
<div class="room-day-parity-dates">
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')}
</div>
</th> </th>
`).join(''); `).join('');
const rows = slots.map(slot => ` const rows = slots.map(slot => `
@@ -597,6 +595,72 @@ export async function initScheduleView() {
.join(','); .join(',');
} }
function getLessonWeeksText(lesson) {
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return '';
}
const totalHours = lesson.lessonTypeAcademicHours;
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
// Каждое занятие - это 2 часа
const totalLessons = Math.ceil(totalHours / 2);
if (totalLessons <= 0) return '';
const parity = lesson.ruleParity || lesson.parity;
let startWeek, endWeek;
if (parity === 'EVEN' || parity === 'ODD') {
// Раз в две недели
startWeek = lesson.weekNumber - consumedBefore;
endWeek = startWeek + (totalLessons - 1) * 2;
} else {
// Каждую неделю
const steps = Math.floor(consumedBefore / 2);
startWeek = lesson.weekNumber - steps;
endWeek = startWeek + totalLessons - 1;
}
if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek, 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) { function renderLessonCard(lesson) {
const subgroupText = (lesson.subgroupNames || []).join(', '); const subgroupText = (lesson.subgroupNames || []).join(', ');
const meta = [ const meta = [
@@ -605,9 +669,15 @@ export async function initScheduleView() {
PARITY_LABELS[lesson.parity] || lesson.parity PARITY_LABELS[lesson.parity] || lesson.parity
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
const weeksText = getLessonWeeksText(lesson);
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
return ` return `
<div class="lesson-card schedule-view-lesson-card"> <div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div> <div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
${weeksBadge}
</div>
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div> <div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''} ${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div> <div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
@@ -657,13 +727,14 @@ export async function initScheduleView() {
} }
async function loadDictionaries(role, userDepartmentId) { 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/groups'),
api.get('/api/users/teachers'), api.get('/api/users/teachers'),
api.get('/api/classrooms'), api.get('/api/classrooms'),
api.get('/api/departments'), api.get('/api/departments'),
api.get('/api/subjects'), 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 const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
@@ -681,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
groups, groups,
teachers, teachers,
classrooms, classrooms,
departments departments,
semesters: (academicYears || []).flatMap(year => year.semesters || [])
}; };
} }
@@ -895,26 +967,15 @@ function parityForDate(date, lessons) {
} }
function combinedWeekCaption(block) { function combinedWeekCaption(block) {
return CELL_PARITY_LAYOUT return 'Нечётная и чётная недели';
.map(({ parity, label }) => {
const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean);
if (!dates.length) return `${label}: дат нет`;
return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`;
})
.join('; ');
} }
function mobileDayCaption(datesByParity) { function mobileDayCaption(datesByParity) {
return CELL_PARITY_LAYOUT return 'Нечётная / Чётная';
.map(({ parity, label }) => {
const date = datesByParity?.[parity];
return `${label}: ${date ? formatDate(date) : 'даты нет'}`;
})
.join(' · ');
} }
function periodCaption(range) { function periodCaption(range) {
return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`; return 'Учебное расписание';
} }
function lessonSlotKey(lesson) { function lessonSlotKey(lesson) {

File diff suppressed because it is too large Load Diff

View File

@@ -4,160 +4,260 @@ import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initSubjects() { export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody'); const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form'); const createSubjectForm = document.getElementById('create-subject-form');
const newSubjectDeptSelect = document.getElementById('new-subject-department');
const assignTeacherForm = document.getElementById('assign-teacher-form'); // Модалка управления преподавателями
const assignTeacherSelect = document.getElementById('assign-teacher-select'); const modalManageTeachers = document.getElementById('modal-manage-teachers');
const assignSubjectSelect = document.getElementById('assign-subject-select'); const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody'); const subjectTeachersContext = document.getElementById('subject-teachers-context');
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = []; let allSubjects = [];
let allTeachers = []; let allTeachers = [];
let allDepartments = [];
let teacherSubjects = [];
bindEvents();
await loadInitialData();
function bindEvents() {
createSubjectForm.addEventListener('submit', createSubject);
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
});
}
async function loadInitialData() { async function loadInitialData() {
await Promise.all([loadSubjects(), loadTeachers()]); try {
await loadTeacherSubjects(); [allTeachers, allDepartments] = await Promise.all([
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
populateDepartmentsSelect();
await loadSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
}
}
function populateDepartmentsSelect() {
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
allDepartments.map(d => {
const name = d.departmentName || d.name || 'Без названия';
const code = d.departmentCode || d.code || d.id;
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
} }
async function loadSubjects() { async function loadSubjects() {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try { try {
allSubjects = await api.get('/api/subjects'); [allSubjects, teacherSubjects] = await Promise.all([
renderSubjects(allSubjects); api.get('/api/subjects'),
populateSubjectSelect(allSubjects); api.get('/api/teacher-subjects')
]);
renderSubjects();
} catch (e) { } catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>'; subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
} }
} }
function renderSubjects(subjects) { function renderSubjects() {
if (!subjects || !subjects.length) { if (!allSubjects || !allSubjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>'; subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
return; return;
} }
subjectsTbody.innerHTML = subjects.map(s => `
<tr> // Группируем преподавателей по дисциплинам
<td>${s.id}</td> const teachersBySubject = new Map();
<td>${escapeHtml(s.name)}</td> teacherSubjects.forEach(ts => {
<td>${escapeHtml(s.code || '-')}</td> if (!teachersBySubject.has(ts.subjectId)) {
<td>${s.departmentId || '-'}</td> teachersBySubject.set(ts.subjectId, []);
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td> }
</tr>`).join(''); teachersBySubject.get(ts.subjectId).push(ts);
});
subjectsTbody.innerHTML = allSubjects.map(s => {
const teachers = teachersBySubject.get(s.id) || [];
const teachersHtml = teachers.length
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
return `
<tr>
<td>${s.id}</td>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.code || '-')}</td>
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
<td style="text-align: right; white-space: nowrap;">
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
</td>
</tr>
`;
}).join('');
} }
function populateSubjectSelect(subjects) { async function createSubject(e) {
if (!assignSubjectSelect) return;
const currentVal = assignSubjectSelect.value;
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (currentVal && subjects.find(s => s.id == currentVal)) {
assignSubjectSelect.value = currentVal;
}
}
async function loadTeachers() {
try {
allTeachers = await api.get('/api/users/teachers');
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers || !teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const tsData = await api.get('/api/teacher-subjects');
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray || !tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
hideAlert('create-subject-alert'); hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim(); const name = document.getElementById('new-subject-name').value.trim();
const code = document.getElementById('new-subject-code').value.trim(); const code = document.getElementById('new-subject-code').value.trim();
const departmentId = document.getElementById('new-subject-department').value; const departmentId = newSubjectDeptSelect.value;
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; } if (!name || !code || !departmentId) {
if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; } showAlert('create-subject-alert', 'Заполните все поля', 'error');
return;
}
try { try {
const data = await api.post('/api/subjects', { const data = await api.post('/api/subjects', {
name, name,
code, code,
departmentId: Number(departmentId) departmentId: Number(departmentId)
}); });
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success'); showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
createSubjectForm.reset(); createSubjectForm.reset();
loadSubjects(); await loadSubjects();
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); } } catch (error) {
}); showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
}
}
subjectsTbody.addEventListener('click', async (e) => { async function handleSubjectsTableClick(e) {
const btn = e.target.closest('.btn-delete'); const btnDelete = e.target.closest('.btn-delete-subject');
if (!btn) return; const btnTeachers = e.target.closest('.btn-manage-teachers');
if (!confirm('Удалить дисциплину?')) return;
try {
await api.delete('/api/subjects/' + btn.dataset.id);
loadSubjects();
loadTeacherSubjects(); // May have deleted a subject that was assigned
} catch (e) { alert(e.message || 'Ошибка удаления'); }
});
assignTeacherForm.addEventListener('submit', async (e) => { if (btnDelete) {
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
try {
await api.delete('/api/subjects/' + btnDelete.dataset.id);
await loadSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления');
}
}
if (btnTeachers) {
const subjectId = btnTeachers.dataset.id;
const subject = allSubjects.find(s => s.id == subjectId);
if (subject) openTeachersModal(subject);
}
}
// --- Управление привязками преподавателей к дисциплине ---
function openTeachersModal(subject) {
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
manageTsSubjectIdInput.value = subject.id;
hideAlert('manage-ts-alert');
modalManageTeachers.classList.add('open');
renderSubjectTeachers(subject.id);
populateTeachersSelect(subject.id);
}
function renderSubjectTeachers(subjectId) {
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
if (!list.length) {
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
return;
}
manageTsTbody.innerHTML = list.map(ts => `
<tr>
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
<td>
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
</td>
</tr>
`).join('');
}
function populateTeachersSelect(subjectId) {
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
const assignedUserIds = new Set(
teacherSubjects
.filter(ts => String(ts.subjectId) === String(subjectId))
.map(ts => String(ts.userId))
);
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
if (!availableTeachers.length) {
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
return;
}
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
}
async function saveTeacherAssignment(e) {
e.preventDefault(); e.preventDefault();
hideAlert('assign-teacher-alert'); hideAlert('manage-ts-alert');
const userId = assignTeacherSelect.value; const subjectId = manageTsSubjectIdInput.value;
const subjectId = assignSubjectSelect.value; const userId = manageTsTeacherSelect.value;
if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; } if (!userId) {
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
return;
}
try { try {
await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) }); await api.post('/api/teacher-subjects', {
showAlert('assign-teacher-alert', 'Привязка создана', 'success'); userId: Number(userId),
loadTeacherSubjects(); subjectId: Number(subjectId)
} catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); } });
}); showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс
teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Также обновим главную таблицу
renderSubjects();
} catch (error) {
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
}
}
async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return;
if (!confirm('Удалить привязку преподавателя?')) return;
const subjectId = manageTsSubjectIdInput.value;
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try { try {
await api.delete('/api/teacher-subjects', { await api.delete('/api/teacher-subjects', {
userId: Number(btn.dataset.userId), userId: Number(btnDelete.dataset.userId),
subjectId: Number(btn.dataset.subjectId) subjectId: Number(btnDelete.dataset.subjectId)
}); });
loadTeacherSubjects();
} catch (e) { alert(e.message || 'Ошибка удаления'); } // Перезагрузим привязки
}); teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Обновим главную таблицу
renderSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления привязки');
}
}
loadInitialData(); // --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = allDepartments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
} }

View File

@@ -0,0 +1,136 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
}
}

View File

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

View File

@@ -50,7 +50,7 @@ export async function initUsers() {
<td> <td>
${u.status === 'ARCHIVED' ${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>` ? `<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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -71,6 +71,10 @@ export async function initUsers() {
showAlert('create-alert', 'Заполните все поля', 'error'); showAlert('create-alert', 'Заполните все поля', 'error');
return; return;
} }
if (password.length < 8) {
showAlert('create-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try { try {
const data = await api.post('/api/users', { const data = await api.post('/api/users', {
@@ -90,7 +94,7 @@ export async function initUsers() {
}); });
usersTbody.addEventListener('click', async (e) => { usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-delete'); const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return; if (!deleteBtn) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return; if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;

View File

@@ -25,15 +25,7 @@
} }
.sidebar-close-btn { .sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary); 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); transition: all var(--transition);
} }
@@ -265,12 +257,7 @@
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
.menu-toggle { .menu-toggle {
display: none; display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }

View File

@@ -46,9 +46,9 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--error: #ef4444; --error: #ef4444;
--success: #10b981; --success: #10b981;
@@ -77,20 +77,19 @@ body {
/* ===== Переключатель темы ===== */ /* ===== Переключатель темы ===== */
.theme-toggle { .theme-toggle {
width: 42px; width: 40px;
height: 42px; height: 40px;
border: none; border-radius: var(--button-radius);
border-radius: 50%; background: var(--button-secondary-bg);
background: var(--bg-card); border: 1px solid var(--button-secondary-border);
border: 1px solid var(--bg-card-border); color: var(--button-secondary-text);
color: var(--text-primary); display: inline-flex;
cursor: pointer;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-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; z-index: 100;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -102,12 +101,18 @@ body {
} }
.theme-toggle:hover { .theme-toggle:hover {
transform: scale(1.1); background: var(--button-secondary-bg-hover);
box-shadow: 0 4px 16px var(--accent-glow); border-color: var(--button-secondary-border);
transform: translateY(-1px);
} }
.theme-toggle:active { .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 { .theme-toggle--fixed {
@@ -265,63 +270,6 @@ body {
cursor: pointer; 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 { .form-alert {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 0; min-height: 0;
@@ -444,8 +392,12 @@ body {
} }
.select-layer-active { .select-layer-active {
position: relative; position: relative !important;
z-index: 300; z-index: 10001 !important;
}
.custom-select-wrapper.open {
z-index: 10002 !important;
} }
.custom-select-trigger { .custom-select-trigger {
@@ -610,7 +562,7 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-form .btn-primary { .settings-form .btn {
width: fit-content; width: fit-content;
} }
@@ -626,9 +578,16 @@ body {
flex-direction: column; flex-direction: column;
} }
.settings-header-row .btn-primary, .settings-header-row .btn,
.card-header-row .btn-secondary, .card-header-row .btn,
.settings-form .btn-primary { .settings-form .btn {
width: 100%; 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;
}

View File

@@ -9,6 +9,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../css/components.css">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/layout.css">
</head> </head>
@@ -31,7 +32,7 @@
</svg> </svg>
<span>Настройки</span> <span>Настройки</span>
</div> </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"> <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" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
@@ -56,6 +57,24 @@
</svg> </svg>
<span>Временные слоты</span> <span>Временные слоты</span>
</a> </a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span>База данных</span>
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<a href="/admin/" class="btn-back"> <a href="/admin/" class="btn-back">
@@ -75,7 +94,7 @@
<!-- Основная область --> <!-- Основная область -->
<main class="main"> <main class="main">
<header class="topbar"> <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" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />

View File

@@ -26,11 +26,27 @@ const ROUTES = {
await module.initTimeSlotsSettings(); await module.initTimeSlotsSettings();
} }
}, },
database: {
title: 'База данных',
file: 'views/database.html',
init: async () => {
const module = await import('./views/database.js');
await module.initDatabase();
}
},
'edu-forms': {
title: 'Формы обучения',
file: 'views/edu-forms.html',
init: async () => {
const module = await import('./views/edu-forms.js');
await module.initEduForms();
}
},
}; };
const ROLE_TABS = { const ROLE_TABS = {
ADMIN: ['general', 'time-slots'], ADMIN: ['general', 'time-slots', 'database', 'edu-forms'],
EDUCATION_OFFICE: ['time-slots'] EDUCATION_OFFICE: ['time-slots', 'edu-forms']
}; };
const allowedTabs = new Set(ROLE_TABS[role] || []); const allowedTabs = new Set(ROLE_TABS[role] || []);
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general'; const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';

View File

@@ -1,5 +1,5 @@
import { api } from '../api.js'; import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
export async function initDatabase() { export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody'); const tenantsTbody = document.getElementById('tenants-tbody');
@@ -68,7 +68,7 @@ export async function initDatabase() {
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td> <td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
<td>${escapeHtml(t.username || '—')}</td> <td>${escapeHtml(t.username || '—')}</td>
<td>${statusBadge}</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>`; </tr>`;
}).join(''); }).join('');
} }
@@ -136,7 +136,7 @@ export async function initDatabase() {
// === Удаление тенанта === // === Удаление тенанта ===
tenantsTbody.addEventListener('click', async (e) => { tenantsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete'); const btn = e.target.closest('.btn-delete-tenant');
if (!btn) return; if (!btn) return;
const domain = btn.dataset.domain; const domain = btn.dataset.domain;

View File

@@ -1,5 +1,5 @@
import { api } from '../api.js'; import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
export let allEducationForms = []; export let allEducationForms = [];
@@ -35,7 +35,7 @@ export async function initEduForms() {
<tr> <tr>
<td>${ef.id}</td> <td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</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(''); </tr>`).join('');
} }
@@ -56,7 +56,7 @@ export async function initEduForms() {
}); });
efTbody.addEventListener('click', async (e) => { 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 (!btn) return;
if (!confirm('Удалить форму обучения?')) return; if (!confirm('Удалить форму обучения?')) return;
try { try {

View File

@@ -143,8 +143,8 @@ export async function initTimeSlotsSettings() {
<td>${escapeHtml(trimTime(slot.endTime))}</td> <td>${escapeHtml(trimTime(slot.endTime))}</td>
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td> <td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
<td class="actions-cell"> <td class="actions-cell">
<button type="button" class="btn-secondary btn-edit-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-delete btn-delete-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> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -61,10 +61,10 @@
<label for="tenant-password">Пароль</label> <label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" required> <input type="password" id="tenant-password" placeholder="••••••••" required>
</div> </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>
<button type="submit" class="btn-primary" style="height: fit-content;"> <button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
Добавить Добавить
</button> </button>
</div> </div>

View File

@@ -7,7 +7,7 @@
<label for="new-ef-name">Название</label> <label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required> <input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div> </div>
<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-ef-alert" role="alert"></div> <div class="form-alert" id="create-ef-alert" role="alert"></div>
</form> </form>
@@ -31,4 +31,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@
<h2>Временные слоты</h2> <h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p> <p>Настройка базовой, субботней и ручных сеток времени.</p>
</div> </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>
<div class="settings-grid"> <div class="settings-grid">
@@ -12,8 +12,8 @@
<div class="card-header-row"> <div class="card-header-row">
<h3>Сетка времени</h3> <h3>Сетка времени</h3>
<div class="scope-header-actions"> <div class="scope-header-actions">
<button type="button" class="btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button> <button type="button" class="btn btn-md 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-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
</div> </div>
</div> </div>
@@ -47,7 +47,7 @@
<article class="settings-card"> <article class="settings-card">
<div class="card-header-row"> <div class="card-header-row">
<h3 id="time-slot-form-title">Новый слот</h3> <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> </div>
<form id="time-slot-form" class="settings-form" novalidate> <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"> <input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
</label> </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> <div class="form-alert" id="time-slot-alert" role="alert"></div>
</form> </form>
</article> </article>
@@ -106,7 +106,12 @@
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3> <h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p> <p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
</div> </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> </div>
<form id="time-slot-scope-form" class="settings-modal-form" novalidate> <form id="time-slot-scope-form" class="settings-modal-form" novalidate>
@@ -116,8 +121,8 @@
</label> </label>
<div class="settings-modal-actions"> <div class="settings-modal-actions">
<button type="submit" class="btn-primary">Создать сетку</button> <button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
<button type="button" class="btn-secondary" id="time-slot-scope-modal-cancel">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
</div> </div>
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div> <div class="form-alert" id="time-slot-scope-alert" role="alert"></div>

View File

@@ -1,3 +1,10 @@
<div class="academic-calendar-tabs" role="tablist" aria-label="Разделы календарных учебных графиков">
<button type="button" class="academic-calendar-tab active" data-calendar-tab="manage" role="tab" aria-selected="true" aria-controls="academic-calendar-tab-manage">Графики</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="grid" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-grid">Сетки</button>
<button type="button" class="academic-calendar-tab" data-calendar-tab="subjects" role="tab" aria-selected="false" aria-controls="academic-calendar-tab-subjects">Дисциплины</button>
</div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card create-card"> <div class="card create-card">
<h2 id="academic-year-form-title">Учебный год</h2> <h2 id="academic-year-form-title">Учебный год</h2>
<form id="academic-year-form" novalidate> <form id="academic-year-form" novalidate>
@@ -15,8 +22,8 @@
<label for="academic-year-end">Окончание</label> <label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required> <input type="date" id="academic-year-end" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить год</button> <button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn-edit-classroom" id="academic-year-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div> </div>
<div class="form-alert" id="academic-year-alert" role="alert"></div> <div class="form-alert" id="academic-year-alert" role="alert"></div>
</form> </form>
@@ -25,7 +32,7 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Учебные годы</h2> <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>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -72,8 +79,8 @@
<label for="semester-end">Окончание</label> <label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required> <input type="date" id="semester-end" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить семестр</button> <button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn-edit-classroom" id="semester-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
</div> </div>
<div class="form-alert" id="semester-alert" role="alert"></div> <div class="form-alert" id="semester-alert" role="alert"></div>
</form> </form>
@@ -82,7 +89,7 @@
<div class="card create-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2> <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> </div>
<form id="academic-calendar-form" novalidate> <form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id"> <input type="hidden" id="academic-calendar-id">
@@ -119,7 +126,7 @@
<label for="academic-calendar-course-count">Курсов</label> <label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required> <input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить график</button> <button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
</div> </div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div> <div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form> </form>
@@ -128,7 +135,7 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Календарные графики</h2> <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>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -152,6 +159,9 @@
</div> </div>
</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"> <div class="card create-card">
<h2>Редактор годового графика</h2> <h2>Редактор годового графика</h2>
<div class="form-row"> <div class="form-row">
@@ -161,8 +171,8 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button> <button type="button" class="btn btn-md btn-secondary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button> <button type="button" class="btn btn-md btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div> </div>
<div class="calendar-fill-panel"> <div class="calendar-fill-panel">
<div class="form-group"> <div class="form-group">
@@ -185,12 +195,55 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </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>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div> <div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div> <div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div> <div id="calendar-grid" class="academic-calendar-grid"></div>
</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-overlay" id="calendar-day-dialog">
<div class="modal-content card calendar-day-dialog-card"> <div class="modal-content card calendar-day-dialog-card">
@@ -229,8 +282,8 @@
</select> </select>
</div> </div>
<div class="calendar-day-dialog-actions"> <div class="calendar-day-dialog-actions">
<button type="button" class="btn-primary" id="calendar-day-dialog-time-save">Применить</button> <button type="button" class="btn btn-md 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-danger-subtle" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
</div> </div>
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div> <div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="card"> <div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;"> <div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность</h2> <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>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;"> <div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
@@ -16,7 +16,7 @@
<div class="form-group workload-display-control"> <div class="form-group workload-display-control">
<label for="workload-display-select">Отображение</label> <label for="workload-display-select">Отображение</label>
<select id="workload-display-select" class="workload-display-select" data-native-select="true"> <select id="workload-display-select" class="workload-display-select">
<option value="all">Все аудитории</option> <option value="all">Все аудитории</option>
</select> </select>
</div> </div>
@@ -37,18 +37,8 @@
</div> </div>
<div class="form-group workload-overview-filter"> <div class="form-group workload-overview-filter">
<label>Вместимость</label> <label for="workload-min-capacity">Мин. вместимость</label>
<div class="custom-multi-select"> <input type="number" id="workload-min-capacity" placeholder="0" min="0" style="width: 100px;">
<div class="select-box" id="capacity-box">
<span class="select-text" id="capacity-text">Любая вместимость</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="dropdown-menu" id="capacity-menu">
<div id="capacity-checkboxes" class="checkbox-group-vertical"></div>
</div>
</div>
</div> </div>
<div class="form-group workload-overview-filter"> <div class="form-group workload-overview-filter">

View File

@@ -1,6 +1,9 @@
<!-- ===== Classrooms Tab ===== --> <!-- ===== Classrooms Tab ===== -->
<div class="card create-card"> <div class="card create-card">
<h2>Новая аудитория</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Новая аудитория</h2>
<button type="button" class="btn btn-md btn-secondary" id="btn-manage-equipments">Справочник оборудования</button>
</div>
<form id="create-classroom-form"> <form id="create-classroom-form">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@@ -33,7 +36,7 @@
</div> </div>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <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> </div>
<div class="form-alert" id="create-classroom-alert" role="alert"></div> <div class="form-alert" id="create-classroom-alert" role="alert"></div>
@@ -100,10 +103,53 @@
</div> </div>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <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> </div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div> <div class="form-alert" id="edit-classroom-alert" role="alert"></div>
</form> </form>
</div> </div>
</div> </div>
<!-- Модальное окно управления Оборудованием -->
<div class="modal-overlay" id="modal-manage-equipments">
<div class="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Справочник оборудования</h2>
<button class="modal-close" id="modal-manage-equipments-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
</div>
<!-- Форма добавления -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 style="margin-top: 0;">Добавить оборудование</h3>
<form id="manage-equipment-form">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-equipment-name">Название оборудования</label>
<input type="text" id="manage-equipment-name" placeholder="Проектор" required>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
</div>
<div class="form-alert" id="manage-equipment-alert" role="alert"></div>
</form>
</div>
<!-- Таблица оборудования -->
<div class="table-wrap">
<table id="manage-equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-equipments-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

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

View File

@@ -1,10 +1,10 @@
<div class="card"> <div class="card department-workspace-control-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Рабочая область кафедры</h2> <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>
<div class="schedule-view-filter-grid"> <div class="department-workspace-filter-grid">
<div class="form-group" id="department-workspace-department-field"> <div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label> <label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true"> <select id="department-workspace-department" data-native-select="true">
@@ -24,31 +24,103 @@
<div class="form-alert" id="department-workspace-alert" role="alert"></div> <div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div> </div>
<div class="department-workspace-grid"> <div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div class="card create-card"> <div class="card create-card department-subject-import-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Загрузка дисциплин</h2> <h2>Загрузка дисциплин</h2>
</div> </div>
<form id="department-subject-import-form" class="stack-form"> <form id="department-subject-import-form" class="stack-form">
<div class="form-group"> <div class="subject-import-slots-panel">
<label for="department-subject-import-text">Строки в формате «код; название»</label> <div class="card-header-row">
<textarea id="department-subject-import-text" rows="8" placeholder="DB201; Базы данных&#10;AI101; Искусственный интеллект"></textarea> <label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
<button type="button" class="btn btn-sm btn-secondary" id="subject-import-add">Добавить строку</button>
</div>
<div id="subject-import-rows" class="subject-import-rows-list"></div>
</div> </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> <div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form> </form>
</div> </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"> <div class="card-header-row">
<h2>Преподаватели и нагрузка</h2> <h2>Преподаватели и нагрузка</h2>
</div> </div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div> <div class="metric-grid" id="department-teachers-list">Загрузка...</div>
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
</div> </div>
</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"> <div class="card-header-row">
<h2>Дисциплины кафедры</h2> <h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span> <span class="muted" id="department-subject-count">0 дисциплин</span>
@@ -70,10 +142,10 @@
</div> </div>
</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"> <div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2> <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> </div>
<form id="department-comment-form" class="form-row"> <form id="department-comment-form" class="form-row">
@@ -82,7 +154,7 @@
<label for="department-comment-text">Новый комментарий</label> <label for="department-comment-text">Новый комментарий</label>
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине"> <input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
</div> </div>
<button type="submit" class="btn-primary">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</form> </form>
<div class="form-alert" id="department-comment-alert" role="alert"></div> <div class="form-alert" id="department-comment-alert" role="alert"></div>
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div> <div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>

View File

@@ -1,126 +0,0 @@
<!-- ===== Departments and Specialties Tab ===== -->
<div class="card create-card">
<h2>Создание кафедры</h2>
<form id="create-department-form">
<div class="form-row">
<div class="form-group">
<label for="dept-name">Название кафедры</label>
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
</div>
<div class="form-group">
<label for="dept-code">Код кафедры</label>
<input type="number" id="dept-code" placeholder="Например: 1" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-dept-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Кафедры</h2>
</div>
<div class="table-wrap">
<table id="departments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="departments-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
<div class="form-row">
<div class="form-group">
<label for="spec-name">Название специальности</label>
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
</div>
<div class="form-group">
<label for="spec-code">Код специальности</label>
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-spec-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Специальности</h2>
</div>
<div class="table-wrap">
<table id="specialties-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код специальности</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="specialties-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="modal-edit-department">
<div class="modal-content card">
<h2>Редактирование кафедры</h2>
<button class="modal-close" id="modal-edit-department-close">&times;</button>
<form id="edit-department-form">
<input type="hidden" id="edit-dept-id">
<div class="form-row">
<div class="form-group">
<label for="edit-dept-name">Название кафедры</label>
<input type="text" id="edit-dept-name" required>
</div>
<div class="form-group">
<label for="edit-dept-code">Код кафедры</label>
<input type="number" id="edit-dept-code" required>
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
</form>
</div>
</div>
<div class="modal-overlay" id="modal-edit-specialty">
<div class="modal-content card">
<h2>Редактирование специальности</h2>
<button class="modal-close" id="modal-edit-specialty-close">&times;</button>
<form id="edit-specialty-form">
<input type="hidden" id="edit-spec-id">
<div class="form-row">
<div class="form-group">
<label for="edit-spec-name">Название специальности</label>
<input type="text" id="edit-spec-name" required>
</div>
<div class="form-group">
<label for="edit-spec-code">Код специальности</label>
<input type="text" id="edit-spec-code" required>
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
</form>
</div>
</div>

View File

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

View File

@@ -39,7 +39,7 @@
<option value="">Выберите специальность</option> <option value="">Выберите специальность</option>
</select> </select>
</div> </div>
<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-group-alert" role="alert"></div> <div class="form-alert" id="create-group-alert" role="alert"></div>
</form> </form>
@@ -49,10 +49,26 @@
<div class="card-header-row"> <div class="card-header-row">
<h2>Все группы</h2> <h2>Все группы</h2>
<div class="filter-row"> <div class="filter-row">
<label for="filter-ef">Фильтр:</label> <label for="filter-ef-box">Фильтр:</label>
<select id="filter-ef"> <div class="custom-multi-select">
<option value="">Все формы</option> <div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
</select> <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> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -80,101 +96,14 @@
</div> </div>
</div> </div>
<div class="card create-card">
<h2>Подгруппы для лабораторных</h2>
<form id="subgroup-form">
<input type="hidden" id="subgroup-id">
<div class="form-row">
<div class="form-group">
<label for="subgroup-group">Группа</label>
<select id="subgroup-group">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="subgroup-name">Название подгруппы</label>
<input type="text" id="subgroup-name" placeholder="Подгруппа 1" required>
</div>
<div class="form-group">
<label for="subgroup-capacity">Численность</label>
<input type="number" id="subgroup-capacity" min="0" placeholder="12">
</div>
<button type="submit" class="btn-primary" id="subgroup-submit">Создать подгруппу</button>
<button type="button" class="btn-edit-classroom" id="subgroup-reset">Очистить</button>
</div>
<div class="form-alert" id="subgroup-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table>
<thead>
<tr>
<th>Группа</th>
<th>Подгруппа</th>
<th>Численность</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="subgroups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Назначение календарного графика</h2>
<form id="group-calendar-form">
<div class="form-row">
<div class="form-group">
<label for="calendar-group">Группа</label>
<select id="calendar-group">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-year">Учебный год</label>
<select id="calendar-year">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-id">Календарный график</label>
<select id="calendar-id">
<option value="">Выберите группу и год</option>
</select>
</div>
<button type="submit" class="btn-primary">Назначить</button>
</div>
<div class="form-alert" id="group-calendar-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="group-calendar-tbody">
<tr>
<td colspan="3" class="loading-row">Выберите группу</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно редактирования группы --> <!-- Модальное окно редактирования группы -->
<div class="modal-overlay" id="modal-edit-group"> <div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card"> <div class="modal-content card edit-group-modal">
<h2>Редактировать группу</h2> <h2>Редактировать группу</h2>
<button class="modal-close" id="modal-edit-group-close">&times;</button> <button class="modal-close" id="modal-edit-group-close">&times;</button>
<form id="edit-group-form"> <form id="edit-group-form">
<input type="hidden" id="edit-group-id"> <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"> <div class="form-group">
<label for="edit-group-name">Название группы</label> <label for="edit-group-name">Название группы</label>
<input type="text" id="edit-group-name" required> <input type="text" id="edit-group-name" required>
@@ -190,7 +119,7 @@
</select> </select>
</div> </div>
</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"> <div class="form-group">
<label for="edit-group-department">Кафедра</label> <label for="edit-group-department">Кафедра</label>
<select id="edit-group-department"> <select id="edit-group-department">
@@ -213,11 +142,123 @@
<option value="">Выберите специальность</option> <option value="">Выберите специальность</option>
</select> </select>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <div class="form-group edit-group-submit">
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div> </div>
</div> </div>
<div class="form-alert" id="edit-group-alert" role="alert"></div> <div class="form-alert" id="edit-group-alert" role="alert"></div>
</form> </form>
</div> </div>
</div> </div>
<!-- Модальное окно управления Подгруппами -->
<div class="modal-overlay" id="modal-manage-subgroups">
<div class="modal-content card" style="max-width: 700px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Подгруппы группы</h2>
<div id="subgroups-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-subgroups-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
</div>
<!-- Форма -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 id="subgroup-form-title" style="margin-top: 0;">Создание подгруппы</h3>
<form id="manage-subgroup-form">
<input type="hidden" id="manage-subgroup-id">
<input type="hidden" id="manage-subgroup-group-id">
<div class="subgroup-config-grid">
<div class="form-group subgroup-count-field">
<label for="manage-subgroup-count">Количество подгрупп</label>
<select id="manage-subgroup-count">
<option value="0">Без разделения на подгруппы</option>
<option value="2">Две подгруппы</option>
<option value="3">Три подгруппы</option>
</select>
</div>
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
<div class="subgroup-actions">
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
</div>
</div>
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-subgroups-table">
<thead>
<tr>
<th>Подгруппа</th>
<th>Численность</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-subgroups-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Модальное окно назначения Календарного графика -->
<div class="modal-overlay" id="modal-manage-calendar">
<div class="modal-content card" style="max-width: 960px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Календарные графики группы</h2>
<div id="calendar-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-calendar-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
</div>
<!-- Форма -->
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<h3 style="margin-top: 0;">Назначить график</h3>
<form id="manage-calendar-form">
<input type="hidden" id="manage-calendar-group-id">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-calendar-year">Учебный год</label>
<select id="manage-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 2; margin-bottom: 0;">
<label for="manage-calendar-id">Календарный график</label>
<select id="manage-calendar-id" required>
<option value="">Выберите учебный год</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
</div>
<div class="form-alert" id="manage-calendar-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-calendar-table">
<thead>
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Дисциплины</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-calendar-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,72 +0,0 @@
<div class="card create-card">
<div class="card-header-row">
<h2>Создание профиля обучения</h2>
</div>
<form id="create-profile-form">
<div class="form-row">
<div class="form-group">
<label for="profile-specialty">Специальность</label>
<select id="profile-specialty" required>
<option value="">Загрузка специальностей...</option>
</select>
</div>
<div class="form-group">
<label for="profile-name">Название профиля</label>
<input type="text" id="profile-name" placeholder="Безопасность автоматизированных систем" required>
</div>
<div class="form-group">
<label for="profile-description">Описание</label>
<input type="text" id="profile-description" placeholder="Необязательно">
</div>
<button type="submit" class="btn-primary">Создать профиль</button>
</div>
<div class="form-alert" id="create-profile-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Профили выбранной специальности</h2>
<button class="btn-primary" id="profiles-refresh" type="button">Обновить</button>
</div>
<div class="profiles-context" id="profiles-context"></div>
<div class="table-wrap">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Профиль</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="modal-edit-profile">
<div class="modal-content card">
<h2>Редактирование профиля</h2>
<button class="modal-close" id="modal-edit-profile-close">&times;</button>
<form id="edit-profile-form">
<input type="hidden" id="edit-profile-id">
<div class="form-row">
<div class="form-group">
<label for="edit-profile-name">Название профиля</label>
<input type="text" id="edit-profile-name" required>
</div>
<div class="form-group">
<label for="edit-profile-description">Описание</label>
<input type="text" id="edit-profile-description">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -5,13 +5,13 @@
<h2>Расписания</h2> <h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span> <span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div> </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>
<div class="schedule-view-control-grid"> <div class="schedule-view-control-grid">
<div class="form-group"> <div class="form-group">
<label for="schedule-view-target">Что смотреть</label> <label for="schedule-view-target">Что смотреть</label>
<select id="schedule-view-target" data-native-select="true"> <select id="schedule-view-target">
<option value="groups">Группа</option> <option value="groups">Группа</option>
<option value="teachers">Преподаватель</option> <option value="teachers">Преподаватель</option>
<option value="classrooms">Аудитория</option> <option value="classrooms">Аудитория</option>
@@ -21,45 +21,33 @@
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups"> <div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
<label for="schedule-view-group">Группа</label> <label for="schedule-view-group">Группа</label>
<select id="schedule-view-group" data-native-select="true"> <select id="schedule-view-group">
<option value="">Все группы</option> <option value="">Все группы</option>
</select> </select>
</div> </div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden> <div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
<label for="schedule-view-teacher">Преподаватель</label> <label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true"> <select id="schedule-view-teacher">
<option value="">Все преподаватели</option> <option value="">Все преподаватели</option>
</select> </select>
</div> </div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden> <div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
<label for="schedule-view-classroom">Аудитория</label> <label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true"> <select id="schedule-view-classroom">
<option value="">Все аудитории</option> <option value="">Все аудитории</option>
</select> </select>
</div> </div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden> <div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
<label for="schedule-view-department">Кафедра</label> <label for="schedule-view-department">Кафедра</label>
<select id="schedule-view-department" data-native-select="true"> <select id="schedule-view-department">
<option value="">Все кафедры</option> <option value="">Все кафедры</option>
</select> </select>
</div> </div>
<div class="form-group"> <input type="date" id="schedule-view-date" style="display: none;">
<label for="schedule-view-date">Дата в периоде</label>
<input type="date" id="schedule-view-date">
</div>
<div class="form-group schedule-view-period-control">
<span class="form-label">Переход</span>
<div class="schedule-view-period-buttons">
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период"></button>
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период"></button>
</div>
</div>
</div> </div>
<details class="schedule-view-extra-filters"> <details class="schedule-view-extra-filters">
@@ -67,19 +55,19 @@
<div class="schedule-view-extra-grid"> <div class="schedule-view-extra-grid">
<div class="form-group"> <div class="form-group">
<label for="schedule-view-subject">Дисциплина</label> <label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject" data-native-select="true"> <select id="schedule-view-subject">
<option value="">Все дисциплины</option> <option value="">Все дисциплины</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label> <label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type" data-native-select="true"> <select id="schedule-view-lesson-type">
<option value="">Все типы</option> <option value="">Все типы</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="schedule-view-parity">Неделя</label> <label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity" data-native-select="true"> <select id="schedule-view-parity">
<option value="">Любая</option> <option value="">Любая</option>
<option value="BOTH">Каждая</option> <option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option> <option value="ODD">Нечётная</option>
@@ -99,9 +87,17 @@
<span class="muted" id="schedule-view-count">0 занятий</span> <span class="muted" id="schedule-view-count">0 занятий</span>
</div> </div>
<div class="schedule-view-result-actions"> <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> <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> </div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div> <div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>

View File

@@ -1,7 +1,7 @@
<div class="card create-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2 id="schedule-rule-form-title">Новое правило расписания</h2> <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> </div>
<form id="schedule-rule-form" novalidate> <form id="schedule-rule-form" novalidate>
<input type="hidden" id="schedule-rule-id"> <input type="hidden" id="schedule-rule-id">
@@ -79,22 +79,88 @@
<div class="schedule-slots-panel"> <div class="schedule-slots-panel">
<div class="card-header-row"> <div class="card-header-row">
<h2>Слоты правила</h2> <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>
<div id="schedule-rule-slots" class="schedule-slots-list"></div> <div id="schedule-rule-slots" class="schedule-slots-list"></div>
</div> </div>
<div class="form-row"> <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>
<div class="form-alert" id="schedule-rule-alert" role="alert"></div> <div class="form-alert" id="schedule-rule-alert" role="alert"></div>
</form> </form>
</div> </div>
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
<div class="schedule-conflict-reasons" id="schedule-conflict-reasons" hidden></div>
</div>
<div class="schedule-conflict-rules">
<section class="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">-</strong>
<small id="schedule-conflict-new-rule-meta">-</small>
</section>
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">-</strong>
<small id="schedule-conflict-existing-rule-meta">-</small>
</section>
</div>
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
</div>
</form>
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
</div>
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="schedule-move-slot-day">День</label>
<select id="schedule-move-slot-day" required></select>
</div>
<div class="form-group">
<label for="schedule-move-slot-time">Пара</label>
<select id="schedule-move-slot-time" required></select>
</div>
</div>
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
</div>
</form>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Правила динамического расписания</h2> <h2>Правила динамического расписания</h2>
<button class="btn-primary" id="schedule-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table id="schedule-table"> <table id="schedule-table">
@@ -117,3 +183,57 @@
</table> </table>
</div> </div>
</div> </div>
<button type="button"
class="btn btn-icon-md btn-secondary schedule-visual-handle"
id="schedule-visual-toggle"
aria-label="Показать визуальное расписание"
aria-expanded="false">
<svg class="schedule-visual-handle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
<div class="schedule-visual-header">
<div class="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-close">Свернуть</button>
</div>
<div class="schedule-visual-toolbar">
<div class="schedule-visual-period-grid">
<div class="form-group">
<label for="schedule-visual-year">Учебный год</label>
<select id="schedule-visual-year">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="schedule-visual-semester">Семестр</label>
<select id="schedule-visual-semester">
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-select-all">Все</button>
</div>
</div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
<span class="muted">Группы появятся после загрузки правил</span>
</div>
</div>
</div>
<div class="schedule-visual-stage" id="schedule-visual-stage">
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>

View File

@@ -12,37 +12,17 @@
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required> <input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-subject-department">Идентификатор кафедры</label> <label for="new-subject-department">Кафедра</label>
<input type="number" id="new-subject-department" placeholder="ID кафедры" required> <select id="new-subject-department" required>
<option value="">Загрузка...</option>
</select>
</div> </div>
<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-subject-alert" role="alert"></div> <div class="form-alert" id="create-subject-alert" role="alert"></div>
</form> </form>
</div> </div>
<div class="card create-card">
<h2>Привязка преподавателя к дисциплине</h2>
<form id="assign-teacher-form">
<div class="form-row">
<div class="form-group">
<label for="assign-teacher-select">Преподаватель</label>
<select id="assign-teacher-select">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="assign-subject-select">Дисциплина</label>
<select id="assign-subject-select">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-primary">Привязать</button>
</div>
<div class="form-alert" id="assign-teacher-alert" role="alert"></div>
</form>
</div>
<div class="card"> <div class="card">
<h2>Все дисциплины</h2> <h2>Все дисциплины</h2>
<div class="table-wrap"> <div class="table-wrap">
@@ -52,35 +32,64 @@
<th>ID</th> <th>ID</th>
<th>Название</th> <th>Название</th>
<th>Код предмета</th> <th>Код предмета</th>
<th>Кафедра (ID)</th> <th>Кафедра</th>
<th>Действия</th> <th>Преподаватели</th>
<th style="text-align: right;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody id="subjects-tbody"> <tbody id="subjects-tbody">
<tr> <tr>
<td colspan="5" class="loading-row">Загрузка...</td> <td colspan="6" class="loading-row">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card"> <!-- Модальное окно привязки Преподавателей -->
<h2>Привязки преподавателей</h2> <div class="modal-overlay" id="modal-manage-teachers">
<div class="table-wrap"> <div class="modal-content card" style="max-width: 600px; width: 90%;">
<table id="teacher-subjects-table"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<thead> <div>
<tr> <h2>Преподаватели дисциплины</h2>
<th>Преподаватель</th> <div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
<th>Дисциплина</th> </div>
<th>Действия</th> <button class="modal-close" id="modal-manage-teachers-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
</tr> </div>
</thead>
<tbody id="teacher-subjects-tbody"> <!-- Форма -->
<tr> <div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
<td colspan="3" class="loading-row">Загрузка...</td> <h3 style="margin-top: 0;">Добавить привязку</h3>
</tr> <form id="manage-teacher-subject-form">
</tbody> <input type="hidden" id="manage-ts-subject-id">
</table> <div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="manage-ts-teacher-select">Преподаватель</label>
<select id="manage-ts-teacher-select" required>
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
</div>
<div class="form-alert" id="manage-ts-alert" role="alert"></div>
</form>
</div>
<!-- Таблица -->
<div class="table-wrap">
<table id="manage-ts-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="manage-ts-tbody">
<tr>
<td colspan="2" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<!-- ===== Users Tab ===== --> <!-- ===== Users Tab ===== -->
<div class="card create-card"> <div class="card create-card">
<h2>Новый пользователь</h2> <h2>Новый пользователь</h2>
<form id="create-form"> <form id="create-form">
<div class="form-row"> <div class="form-row">
@@ -9,7 +9,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-password">Пароль</label> <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>
<div class="form-group"> <div class="form-group">
<label for="new-role">Роль</label> <label for="new-role">Роль</label>
@@ -34,13 +34,13 @@
<label for="new-department">ID Кафедры</label> <label for="new-department">ID Кафедры</label>
<input type="number" id="new-department" placeholder="ID" required> <input type="number" id="new-department" placeholder="ID" required>
</div> </div>
<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-alert" role="alert"></div> <div class="form-alert" id="create-alert" role="alert"></div>
</form> </form>
</div> </div>
<div class="card"> <div class="card">
<h2>Все пользователи</h2> <h2>Все пользователи</h2>
<div class="table-wrap"> <div class="table-wrap">
<table id="users-table"> <table id="users-table">

View File

@@ -57,7 +57,7 @@
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg> </svg>
<input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required> <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"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>
@@ -69,7 +69,7 @@
<div class="form-alert stagger-3" id="form-alert" role="alert"></div> <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-text">Войти</span>
<span class="btn-loader" hidden> <span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24"> <svg class="spinner" width="20" height="20" viewBox="0 0 24 24">

View File

@@ -163,3 +163,8 @@
} }
}); });
})(); })();
document.addEventListener('DOMContentLoaded', () => {
document
.querySelector('#login-form button[type="submit"]')
?.classList.add('btn', 'btn-lg', 'btn-primary');
});

View File

@@ -15,8 +15,8 @@
--panel-border: #dbe3ef; --panel-border: #dbe3ef;
--text: #162033; --text: #162033;
--muted: #66758f; --muted: #66758f;
--accent: #0f766e; --accent: #4F46E5;
--accent-strong: #0b5d57; --accent-strong: #4338CA;
--blue: #2563eb; --blue: #2563eb;
--warning: #b45309; --warning: #b45309;
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08); --shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
@@ -28,8 +28,8 @@
--panel-border: #263449; --panel-border: #263449;
--text: #edf3fb; --text: #edf3fb;
--muted: #9cadc5; --muted: #9cadc5;
--accent: #2dd4bf; --accent: #8B5CF6;
--accent-strong: #5eead4; --accent-strong: #7C3AED;
--blue: #60a5fa; --blue: #60a5fa;
--warning: #f59e0b; --warning: #f59e0b;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28); --shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
@@ -94,6 +94,7 @@
padding: 0 14px; padding: 0 14px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
} }
.btn:hover { .btn:hover {
@@ -112,6 +113,47 @@
color: #fff; 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 { .toolbar {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto; grid-template-columns: minmax(220px, 1fr) auto auto;
@@ -269,8 +311,12 @@
<span id="week-label">Загрузка периода...</span> <span id="week-label">Загрузка периода...</span>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему"></button> <button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
<a class="btn" href="/" id="logout-link">Выйти</a> <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> </div>
</header> </header>
@@ -279,9 +325,17 @@
<option value="">Загрузка групп...</option> <option value="">Загрузка групп...</option>
</select> </select>
<div class="week-nav"> <div class="week-nav">
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя"></button> <button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя"></button> <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> </div>
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели"> <input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section> </section>

View File

@@ -25,6 +25,24 @@
--accent-hover: #a78bfa; --accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4); --accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899; --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 */ /* Status Colors */
--error: #ef4444; --error: #ef4444;
@@ -48,10 +66,27 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --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; --error: #ef4444;
--success: #10b981; --success: #10b981;
--warning: #f59e0b; --warning: #f59e0b;
@@ -389,59 +424,19 @@ body {
/* ===== Submit Button ===== */ /* ===== Submit Button ===== */
.btn-submit { .btn-submit {
position: relative;
overflow: hidden;
width: 100%; 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; 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 { .btn-submit:active {
transform: translateY(1px); transform: translateY(0);
box-shadow: 0 2px 10px var(--accent-glow);
} }
.btn-submit:disabled { .btn-submit:disabled {
opacity: 0.6; background: var(--button-disabled-bg);
color: var(--button-disabled-text);
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none;
} }
/* ===== Ripple Effect ===== */ /* ===== Ripple Effect ===== */
@@ -480,13 +475,12 @@ body {
/* ===== Theme Toggle Button ===== */ /* ===== Theme Toggle Button ===== */
.theme-toggle { .theme-toggle {
width: 42px; width: 40px;
height: 42px; height: 40px;
border: none; border-radius: var(--button-radius);
border-radius: 50%; background: var(--button-secondary-bg);
background: var(--bg-card); border: 1px solid var(--button-secondary-border);
border: 1px solid var(--bg-card-border); color: var(--button-secondary-text);
color: var(--text-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -505,12 +499,13 @@ body {
} }
.theme-toggle:hover { .theme-toggle:hover {
transform: scale(1.1); background: var(--button-secondary-bg-hover);
box-shadow: 0 4px 16px var(--accent-glow); transform: translateY(-1px);
box-shadow: none;
} }
.theme-toggle:active { .theme-toggle:active {
transform: scale(0.95); transform: translateY(0);
} }
.theme-toggle--fixed { .theme-toggle--fixed {
@@ -540,4 +535,95 @@ body {
width: 18px; width: 18px;
height: 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;
}

View File

@@ -15,8 +15,8 @@
--panel-border: #d8dee8; --panel-border: #d8dee8;
--text: #172033; --text: #172033;
--muted: #67758d; --muted: #67758d;
--accent: #1d4ed8; --accent: #4F46E5;
--accent-strong: #1e40af; --accent-strong: #4338CA;
--green: #047857; --green: #047857;
--orange: #b45309; --orange: #b45309;
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08); --shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
@@ -28,8 +28,8 @@
--panel-border: #2a374b; --panel-border: #2a374b;
--text: #eef4fb; --text: #eef4fb;
--muted: #a2b0c5; --muted: #a2b0c5;
--accent: #60a5fa; --accent: #8B5CF6;
--accent-strong: #93c5fd; --accent-strong: #7C3AED;
--green: #34d399; --green: #34d399;
--orange: #f59e0b; --orange: #f59e0b;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28); --shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
@@ -107,6 +107,7 @@
padding: 0 14px; padding: 0 14px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
} }
.btn:hover { .btn:hover {
@@ -125,6 +126,47 @@
color: #fff; 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 { .date-input {
padding: 0 12px; padding: 0 12px;
} }
@@ -275,16 +317,28 @@
<span id="week-label">Загрузка периода...</span> <span id="week-label">Загрузка периода...</span>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему"></button> <button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
<a class="btn" href="/" id="logout-link">Выйти</a> <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> </div>
</header> </header>
<section class="toolbar"> <section class="toolbar">
<div class="week-nav"> <div class="week-nav">
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя"></button> <button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя"></button> <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> </div>
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели"> <input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section> </section>

View File

@@ -23,7 +23,7 @@
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light'; const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'theme-toggle'; btn.className = 'btn btn-icon-md btn-secondary theme-toggle';
btn.setAttribute('aria-label', 'Переключить тему'); btn.setAttribute('aria-label', 'Переключить тему');
btn.innerHTML = isDark() ? moonSVG : sunSVG; btn.innerHTML = isDark() ? moonSVG : sunSVG;