Merge branch 'department_dev'

This commit is contained in:
dipatrik10
2026-04-09 21:22:44 +03:00
90 changed files with 9117 additions and 737 deletions

View File

@@ -0,0 +1,86 @@
---
name: AutoUpdateDocs
description: Автоматическое обновление документации проекта после изменений в коде
---
# Скилл: Автоматическое обновление документации
## Когда активировать
Этот скилл **ДОЛЖЕН** выполняться автоматически после любых изменений, затрагивающих:
- **Контроллеры** (`backend/src/main/java/com/magistr/app/controller/`) → обновить `docs/API.md`
- **Модели или миграции** (`model/`, `db/migration/`) → обновить `docs/DATABASE.md`
- **Конфигурация тенантов** (`config/tenant/`) → обновить `docs/ARCHITECTURE.md`
- **Бизнес-правила или валидаторы** (`utils/`) → обновить `docs/BUSINESS_LOGIC.md`
- **Frontend** (`frontend/`) → обновить `docs/FRONTEND.md`
- **Docker/Kubernetes** (`compose.yaml`, `Dockerfile`, `../k8s/`) → обновить `docs/INFRASTRUCTURE.md`
- **Code style или структура пакетов** → обновить `docs/DEVELOPMENT.md`
- **Общая структура проекта** → обновить `docs/README.md`
## Карта соответствия «файл → документация»
| Изменённый файл/директория | Файл документации |
|----------------------------|-------------------|
| `controller/*Controller.java` | `docs/API.md` |
| `db/migration/V*__.sql` | `docs/DATABASE.md` |
| `model/*.java` | `docs/DATABASE.md` |
| `dto/*.java` | `docs/API.md` |
| `config/tenant/*.java` | `docs/ARCHITECTURE.md` |
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
| `frontend/admin/settings/**` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` |
## Пошаговая инструкция
### 1. Определить затронутые файлы документации
После выполнения задачи пользователя — проверить по таблице выше, какие файлы документации нужно обновить.
### 2. Прочитать текущую документацию
Открыть соответствующий файл из `docs/` и найти секцию, которую нужно обновить.
### 3. Внести точечные изменения
Обновить **только затронутые секции**, не переписывая весь файл. Примеры:
#### Новый контроллер → `docs/API.md`
Добавить новую секцию с описанием эндпоинтов:
- Метод + URL
- Тело запроса (JSON пример)
- Ответ (JSON пример)
- Валидация
#### Новая миграция → `docs/DATABASE.md`
- Добавить новую таблицу в ER-диаграмму (Mermaid)
- Добавить описание таблицы и колонок
- Добавить запись в таблицу «Текущие миграции»
#### Новый view → `docs/FRONTEND.md`
- Добавить в дерево файлов
- Добавить в таблицу «Разделы админ-панели»
### 4. Обновить AGENTS.md (при необходимости)
Если изменения затрагивают:
- Структуру директорий → обновить дерево в `AGENTS.md`
- Критические правила (Flyway, новые ограничения) → обновить секцию «Критические правила»
### 5. Сообщить пользователю
В конце ответа кратко упомянуть, какие файлы документации были обновлены:
> 📝 Обновлена документация: `docs/API.md` (добавлен эндпоинт `POST /api/absences`)
## Правила
1. **Язык:** Вся документация на русском языке
2. **Формат:** Сохранять существующий стиль оформления файла (заголовки, таблицы, примеры кода)
3. **Не удалять:** Не удалять существующие секции без явного запроса пользователя
4. **Mermaid:** При изменении схемы БД — обязательно обновлять ER-диаграмму в `docs/DATABASE.md`
5. **Минимальные правки:** Не переписывать весь файл ради добавления одной строки — использовать точечные изменения
6. **Консистентность:** Если одно и то же понятие упоминается в нескольких файлах `docs/`, обновить все вхождения

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Создание выразительных, готовых к продакшену frontend-интерфейсов с высоким качеством дизайна. Используйте этот навык, когда пользователь просит разработать веб-компоненты, страницы, артефакты, постеры или приложения (например, сайты, лендинги, дашборды, React-компоненты, HTML/CSS верстку или когда нужно стилизовать/улучшить любой веб-интерфейс). Генерирует креативный, отточенный код и UI-дизайн, избегая шаблонной эстетики ИИ.
license: Полные условия в LICENSE.txt
---
Этот навык направляет создание выразительных, готовых к продакшену frontend-интерфейсов, которые избегают шаблонной "ИИ-эстетики". Создавайте реально работающий код с исключительным вниманием к эстетическим деталям и творческим решениям.
Пользователь предоставляет требования к фронтенду: компонент, страницу, приложение или интерфейс для разработки. Требования могут включать контекст о цели, аудитории или технических ограничениях.
## Дизайн-мышление
Перед написанием кода поймите контекст и примите СМЕЛОЕ эстетическое направление:
- **Цель**: Какую проблему решает этот интерфейс? Кто им пользуется?
- **Тон**: Выберите крайность: брутальный минимализм, максималистский хаос, ретро-футуризм, органический/природный, люксовый/утонченный, игривый/игрушечный, редакционный/журнальный, брутализм/грубый, арт-деко/геометрический, мягкий/пастельный, индустриальный/утилитарный и т.д. Вариантов очень много. Используйте их для вдохновения, но создайте дизайн, верный выбранному эстетическому направлению.
- **Ограничения**: Технические требования (фреймворк, производительность, доступность).
- **Отличительная черта**: Что делает это НЕЗАБЫВАЕМЫМ? Какую единственную вещь кто-то запомнит?
**КРИТИЧЕСКИ ВАЖНО**: Выберите четкое концептуальное направление и выполните его с точностью. Смелый максимализм и утонченный минимализм — оба работают, ключ кроется в осознанности намерений, а не в интенсивности.
Затем реализуйте рабочий код (HTML/CSS/JS, React, Vue и т.д.), который:
- Готов к продакшену и функционален
- Визуально поразителен и легко запоминается
- Согласован с четкой эстетической точкой зрения
- Тщательно проработан в каждой детали
## Руководство по эстетике фронтенда
Сфокусируйтесь на:
- **Типографика**: Выбирайте шрифты, которые красивы, уникальны и интересны. Избегайте общих шрифтов, таких как Arial и Inter; вместо этого делайте выбор в пользу выразительных, неожиданных и характерных вариантов, которые повышают уровень эстетики фронтенда. Сочетайте акцидентный шрифт (display) с утонченным текстовым (body).
- **Цвет и тема**: Придерживайтесь согласованной эстетики. Используйте CSS-переменные для консистентности. Доминирующие цвета с резкими акцентами работают намного лучше, чем робкие, равномерно распределенные палитры.
- **Анимация (Motion)**: Используйте анимации для эффектов и микро-взаимодействий. Отдавайте предпочтение CSS-решениям для HTML. Используйте библиотеки анимаций для React, если они доступны. Фокусируйтесь на моментах с высоким влиянием: одна хорошо срежиссированная загрузка страницы с каскадным появлением элементов (animation-delay) создает больше восторга, чем множество разрозненных микро-взаимодействий. Используйте триггеры при скролле (scroll-triggering) и состояния наведения (hover), которые удивляют.
- **Пространственная композиция**: Неожиданные макеты. Асимметрия. Перекрытие. Диагональное направление. Элементы, ломающие сетку. Обильное негативное пространство ИЛИ контролируемая плотность элементов.
- **Фоны и визуальные детали**: Создавайте атмосферу и глубину вместо использования скучных сплошных цветов по умолчанию. Добавляйте контекстуальные эффекты и текстуры, соответствующие общей эстетике. Применяйте творческие формы: градиентные сетки, шумовые текстуры, геометрические паттерны, слоистые прозрачности, драматичные тени, декоративные рамки, кастомные курсоры и эффекты зернистости (grain).
НИКОГДА не используйте шаблонную сгенерированную ИИ эстетику: заезженные семейства шрифтов (Inter, Roboto, Arial, системные шрифты), клишированные цветовые схемы (особенно фиолетовые градиенты на белом фоне), предсказуемые макеты и паттерны компонентов, а также типовой скучный дизайн без характера, не учитывающий контекст.
Интерпретируйте творчески и делайте неожиданные выборы, которые кажутся действительно разработанными под данный контекст. Ни один дизайн не должен быть шаблонным ("под копирку"). Варьируйте между светлыми и темными темами, разными шрифтами, различной эстетикой. НИКОГДА не сходитесь к общим выборам (например, Space Grotesk) в разных генерациях кода.
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.

3
.gitignore vendored
View File

@@ -7,8 +7,7 @@ backend/build/
frontend/node_modules/
frontend/dist/
.agents
.idea/
.vscode/
*.DS_Store
GEMINI.md
skills-lock.json

172
AGENTS.md
View File

@@ -26,176 +26,64 @@ magistr/
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта
├── compose.yaml # Docker Compose конфигурация
└── .env # Переменные окружения
```
**Внешние зависимости (родительская директория)**
На уровень выше расположен `../caddy-proxy/`. Это реверс-прокси, обрабатывающий трафик для `magistr.zuev.company`. Если возникают проблемы с доменом или внешним доступом, проверяйте `Caddyfile` там.
так же на уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
---
## Команды сборки и запуска
### Docker Compose (основной способ)
Сборка и запуск всех сервисов (backend, frontend, PostgreSQL) выполняется через Docker Compose.
## Быстрый справочник команд
```bash
# Сборка и запуск всех сервисов
# Сборка и запуск
docker compose up -d --build
# Остановка всех сервисов
docker compose down
# Полный сброс БД
docker compose down -v && docker compose up -d
# Просмотр логов всех сервисов
docker compose logs -f
# Просмотр логов конкретного сервиса
# Логи конкретного сервиса
docker compose logs -f backend
# Пересоздать контейнер базы данных (полный сброс данных и повтор миграций Flyway)
docker compose down -v
docker compose up -d db
```
### Frontend
Статические файлы обслуживаются через Apache (httpd:alpine). Изменения в файлах frontend требуют пересборки контейнера.
Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md).
---
## Соглашения о коде (Code Style)
## Критические правила для агентов
### Java (Backend)
**Именование:**
- Классы: PascalCase (например, `LessonsController`, `LessonResponse`)
- Методы и переменные: camelCase
- Константы: UPPER_SNAKE_CASE
- Пакеты: lowercase (например, `com.magistr.app.controller`)
**Импорты:**
- Группировка: static imports, затем external packages, затем internal
- Используйте wildcard imports для пакетов того же модуля: `import com.magistr.app.model.*;`
- Порядок: java.*, javax.*, external.*, internal.*
**Форматирование:**
- Отступы: 4 пробела (стандарт Java)
- Фигурные скобки: K&R style (открывающая на той же строке)
- Длина строки: до 120 символов
- Всегда используйте фигурные скобки для if/for/while
**Типы и аннотации:**
- Используйте явные типы вместо `var` для возвращаемых значений публичных методов
- Аннотации JPA: `@Entity`, `@Table`, `@Id`, `@GeneratedValue`, `@Column`
- Используйте `@JsonInclude(JsonInclude.Include.NON_NULL)` для DTO
- Для логгирования используйте SLF4J: `LoggerFactory.getLogger(ClassName.class)`
**Обработка ошибок:**
- Возвращайте `ResponseEntity<?>` с соответствующим HTTP статусом
- Логируйте ошибки с полным стектрейсом: `logger.error("msg: {}", e.getMessage(), e)`
- Для валидации используйте отдельные классы-валидаторы (см. `DayAndWeekValidator`)
**Архитектура контроллеров:**
- Используйте constructor injection для зависимостей
- Все endpoints имеют префикс `/api/`
- Возвращайте понятные сообщения об ошибках на русском языке
### Frontend (JavaScript)
**Именование:**
- Файлы: kebab-case (например, `main.js`, `schedule-view.js`)
- Функции и переменные: camelCase
- Константы: UPPER_SNAKE_CASE
**Модули:**
- Используйте ES6 modules с `import`/`export`
- Всегда указывайте расширение при импорте: `import { x } from './api.js';`
**Форматирование:**
- Отступы: 4 пробела
- Используйте template literals вместо конкатенации строк
- Предпочитайте `const` переменные, используйте `let` только при необходимости переприсваивания
**Лучшие практики:**
- Используйте `async/await` для асинхронных операций
- Всегда обрабатывайте ошибки в блоках `catch`
- Используйте деструктуризацию объектов
- Кешируйте DOM-элементы в переменные
---
## Работа с базой данных и мультитенантностью
**Мультитенантность:**
- Приложение поддерживает множество клиентов (университетов). Каждый клиент имеет свою изолированную базу данных PostgreSQL.
- Маршрутизация к нужной БД происходит динамически на основе поддомена (`TenantInterceptor` -> `TenantContext` -> `TenantRoutingDataSource`).
- Список клиентов хранится в Kubernetes `ConfigMap` (`tenants-config`), который монтируется в под бэкенда как `/config/tenants.json`.
- Локально список берётся из файла `backend/tenants.json`.
- При добавлении нового клиента в интерфейсе `DatabaseController` через K8s API обновляет `ConfigMap`. Все реплики бэкенда заметят изменения и в фоне инициализируют новый пул соединений (`TenantConfigWatcher`).
**Миграции схемы (Flyway):**
- Автогенерация Hibernate ОТКЛЮЧЕНА (`ddl-auto=none`). Структура баз данных управляется строго через **Flyway**.
- Все изменения схемы БД вносятся путем создания новых файлов в `backend/src/main/resources/db/migration/` (название строго `V2__add_new_table.sql` и т.д.).
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`), которые уже закоммичены. Это сломает контрольные суммы Flyway.
- Flyway запускается программно при первом обращении к базе тенанта. Чтобы запустить Flyway для уже существующих тенантов (накатить V2), необходимо перезапустить бэкенд: `kubectl rollout restart deployment backend -n magistr`.
- Для локального сброса базы до изначального состояния: `docker compose down -v && docker compose up -d`.
**Сущности и связи:**
- Foreign keys с `ON DELETE CASCADE` для поддержания целостности
- Используйте расширение `pgcrypto` для хеширования паролей (bcrypt)
---
## Функциональные требования к системе (Бизнес-логика)
### 1. Ролевая модель
- **Администратор (Деканат)**: Полный доступ, настройка топологии университета, управление аудиторным фондом, подтверждение переносов, регистрация инцидентов.
- **Преподаватель**: Просмотр своего расписания, подача заявок на перенос, отметка о своём отсутствии.
- **Студент**: Только просмотр расписания (Read-only).
### 2. Управление ресурсами и топология
- **Управление аудиториями**:
- Указание вместимости.
- Привязка доступного оборудования (через сущность Equipments: Проектор, ПК, Лаборатория).
- Установка статуса "Не доступно" (блокирует назначение пар в этот период).
- **Управление группами**:
- Управление списком студентов (и возможность деления на подгруппы).
- **Управление дисциплинами**:
- Создание предметов и привязка их к преподавателям (какие дисциплины имеет право вести конкретный преподаватель).
### 3. Логика расписания
- **Сетка**: 7 фиксированных слотов по 1.5 часа (08:00 - 09:30, и т.д.) + поддержка кастомного времени.
- **Проверка конфликтов**:
- *Критический конфликт*: Преподаватель не может находиться в двух разных аудиториях одновременно.
- *Уточнение по преподавателям*: Преподаватель может иметь несколько пар одновременно (для разных групп), только если они проходят в одной и той же аудитории (потоковая лекция).
- **Потоковые занятия**:
- Возможность назначить одну лекцию сразу нескольким группам (технически — несколько записей в БД или одна запись со списком групп).
- Проверка вместимости: вместимость аудитории должна покрывать суммарную численность всех групп, находящихся в этой аудитории в данный слот.
### 4. Управление инцидентами (Инклюзия отсутствия)
- **Отсутствие (Sickness/Business Trip)**: Регистрация отсутствия преподавателя (с указанием причины и периода дат).
- **Обнаружение коллизий**: Автоматическая подсветка конфликтующих пар в расписании (Red Zone).
- **Система разрешения конфликтов (Resolution Wizard)**:
- Предложение подходящей замены преподавателя на этот слот.
- Предложение переноса занятия на другое время или в другую аудиторию.
---
## Языковые требования
### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway.
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования
- **Все ответы и комментарии на русском языке**
- Сообщения об ошибках и логи на русском
- Пользовательский интерфейс на русском
---
## Существующие правила проекта
## Подробная документация
См. `.agent/rules/main.md` и `.agent/rules/database_schema.md` для полного контекста о функциональных требованиях и схеме БД.
Полная документация проекта находится в папке `docs/`:
| Документ | Содержание |
|----------|-----------|
| [`docs/README.md`](docs/README.md) | Обзор проекта, стек технологий, быстрый старт |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Архитектура системы, мультитенантность, аутентификация |
| [`docs/BUSINESS_LOGIC.md`](docs/BUSINESS_LOGIC.md) | Бизнес-логика, ролевая модель, правила расписания |
| [`docs/DATABASE.md`](docs/DATABASE.md) | Схема БД (ER-диаграмма), описание всех таблиц, Flyway |
| [`docs/API.md`](docs/API.md) | REST API эндпоинты с примерами запросов и ответов |
| [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг (SigNoz) |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта |
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность |
| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz |
| [`docs/UI_COMPONENTS.md`](docs/UI_COMPONENTS.md) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |

178
SCHEDULE_PROPOSAL.md Normal file
View File

@@ -0,0 +1,178 @@
# Концепция динамической генерации расписания
Данный документ представляет собой подробное архитектурное описание новой системы управления расписанием. Система переходит от статического хранения каждой отдельной пары к параметрическому: мы сохраняем **правила проведения** дисциплины и **календарную сетку**, а фактическое расписание на любую дату вычисляется «на лету» (генерируется).
> **Контекст миграции:** Новая система полностью заменяет существующие таблицы `lessons` (статическое расписание) и `schedule_data` (плановая нагрузка). Обе таблицы будут мигрированы в единую модель `schedule_rules` + `schedule_rule_slots`, которая совмещает хранение нагрузки (часы) и расписания (слоты) в одной структуре.
---
## 1. Подробное описание компонентов системы
Новая архитектура строится на строгом разделении данных на три логических слоя: Календарь (основа отсчета времени), Правила (шаблоны занятий) и Генератор (движок рендеринга фактического расписания).
### 1.1 Справочная база времени (Календарный учебный график)
Чтобы система понимала, *когда* можно ставить пары, а когда нет, вводится понятие календарного графика. Он состоит из трёх взаимосвязанных сущностей:
* **Академические периоды (Учебные года и Семестры).** Иерархия из двух уровней:
* **Учебный год** — контейнер с названием и датами (напр. «2024/2025», `01.09.2024``30.06.2025`).
* **Семестр** — дочерняя сущность учебного года. Содержит дату начала, от которой отсчитывается «Неделя 1» данного семестра. Нумерация недель начинается заново для каждого семестра. Тип семестра (`autumn` / `spring`) определяет, какой набор правил активен.
Именно от даты начала семестра отсчитывается «Неделя 1». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели.
* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок.
* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность``Номер недели``Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия».
### 1.2 Справочник временных слотов (Time Slots)
Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки.
Слот содержит:
* `order_number` — порядковый номер пары в дне (1, 2, 3...).
* `start_time` — время начала (напр. `08:00`).
* `end_time` — время окончания (напр. `09:30`).
* `duration_minutes` — длительность пары в минутах.
Это позволяет каждому университету настраивать количество и продолжительность пар без модификации кода.
### 1.3 Движок правил (Schedule Rules)
Старый подход подразумевал, что каждая пара в базе (каждая клеточка) — это изолированная запись `lessons` («понедельник, 1-я пара, математика»). Новая система вводит сущность сводного **Правила Дисциплины**. Одно правило описывает расписание целого курса по конкретному предмету для одной или нескольких студенческих групп (включая потоковые лекции).
**Базовые параметры (Лимиты Правила):**
* `subject_id` — ID преподаваемой дисциплины.
* `semester_id` — ID семестра, к которому привязано правило. Одна и та же дисциплина может читаться в разных семестрах с разными параметрами.
* `startDate` — Дата или номер недели семестра, с которой предмет начинает читаться (поскольку не все предметы идут строго с 1-й недели семестра).
* `totalHours` — Полный объём выделенных **академических часов** (1 ак. час = 45 минут; одна пара = 2 ак. часа). Это важнейший **лимитатор**, который обеспечивает автоматическую остановку генерации: как только заявленные часы будут вычитаны, предмет перестает отображаться в расписании студентов на последующих неделях.
**Связь с группами (Many-to-Many):**
Одно правило может быть связано с несколькими группами через промежуточную таблицу `schedule_rule_groups`. Это обеспечивает поддержку **потоковых лекций** — когда один преподаватель читает лекцию нескольким группам одновременно в одной аудитории. При этом правило создаётся один раз, а группы к нему привязываются списком.
**Массив паттернов (Слоты правила):**
Само «тело» правила разбивается на подчинённые слоты. Если предмет идёт в Пн и Ср, это будет 2 слота внутри одного Правила. Слот содержит:
* `dayOfWeek`: день недели (17, Пн–Вс).
* `parity`: тип четности — `ENUM('BOTH', 'EVEN', 'ODD')`. `BOTH` — каждую неделю, `EVEN` — по чётным (нижним) неделям, `ODD` — по нечётным (верхним). Конкретное соответствие «чётная = верхняя или нижняя» определяется настройкой тенанта.
* `time_slot_id`: FK на таблицу `time_slots` — порядковый номер и время пары.
* `subgroup_id`: FK на подгруппу (NULL = вся группа). *Это гарантирует, что мы сможем ставить разным подгруппам пересекающиеся занятия в разных аудиториях без алгоритмических конфликтов.*
* `teacher_id`: FK на преподавателя слота.
* `classroom_id`: FK на аудиторию слота.
* `lesson_type_id`: FK на тип занятия (`Лекция`, `Практическая работа`, `Лабораторная работа`).
* `lesson_format`: формат проведения (`Очно` / `Онлайн`).
> **Обоснование:** Хранение `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` в **слотах**, а не в главном правиле, позволяет гибко описывать ситуации вроде: лекции в понедельник читает лектор Иванов (Аудитория 100), а лабораторные в среду ведёт практик Петров (Аудитория 102В) — в рамках одного правила по предмету «Программирование», расходуя общий `totalHours`.
### 1.4 Генератор (Рендерер) расписания
Это слой бизнес-логики (служба `ScheduleGeneratorService` в Java), который работает исключительно в оперативной памяти бэкенда и производит расчёт расписания «on-demand» (по требованию) при запросе от клиента фронтенда.
**Пошаговый алгоритм работы генератора:**
1. Фронтенд (Интерфейс пользователя) запрашивает: *«Дай мне расписание группы ИТ-21 на конкретный период, например, с 14 октября по 20 октября»*.
2. Генератор определяет семестр по запрошенным датам и вычисляет, что 14 октября соответствует, к примеру, 7-й неделе семестра (вычисление от `startDate` семестра).
3. Он сверяется с *Матрицей учебного графика*. Для этого генератор определяет текущий курс группы по формуле екущий_учебный_год - year_start_study + 1` и находит `specialty_id` группы. Если у данного курса/специальности сейчас стоит `VACATION` (Каникулы) или `PRACTICE` (Практика), генератор сразу возвращает пустой ответ или ответ со статусом периода.
4. Если статус недели позволяет проводить занятия (`THEORY`), генератор поднимает из Базы Данных все активные **Правила** для запрошенной группы (через таблицу `schedule_rule_groups`), привязанные к текущему семестру.
5. **Механика Лимитатора часов:** Для каждого правила алгоритм «симулирует» прогон времени с даты старта правила до текущей запрошенной недели. Он подсчитывает количество успешно проведённых ак. часов (по 2 ак. часа за каждый отработанный слот), пропуская даты, попавшие в справочник праздников, и недели с типом деятельности отличным от `THEORY`.
6. Если у правила лимит `totalHours` достиг значения `0`, программа понимает, что курс вычитан, и предмет не отображается. Если часы ещё остались, алгоритм проецирует шаблоны (слоты правила) на запрошенную текущую неделю с учётом чётности, аудиторий и подгрупп, отдавая готовый JSON-массив в браузер пользователя.
**Генерация расписания для преподавателя:**
Аналогричный алгоритм, но поиск правил идёт не по привязке к группе, а по `teacher_id` в слотах. Генератор собирает все `schedule_rule_slots`, где `teacher_id` = ID текущего преподавателя, получает родительские правила и рендерит расписание, обогащая каждую запись списком групп из `schedule_rule_groups`.
**Кеширование:**
Для оптимизации производительности (т.к. симуляция прогона за весь семестр для каждого запроса ресурсоёмка) предусмотрен кеш:
* Список праздников текущего учебного года кешируется при первом обращении и инвалидируется при изменении таблицы `holidays`.
* Матрица учебного графика кешируется по ключу `(course, specialty_id, semester_id)`.
* Результаты подсчёта `consumed_hours` для каждого правила могут кешироваться с инвалидацией при изменении праздников или правил.
---
## 2. Архитектурные Решения
На основе обсуждений были задокументированы следующие концептуальные решения по архитектуре:
1. **Реакция на праздники (Продление курса):**
Алгоритм воспринимает праздник как «пропуск хода», не отнимая проведённые часы от `totalHours`. Это означает, что пара **не переносится** на другой день или время — она просто пропускается без вычета часов. Фактически предмет будет отображаться в расписании дольше (больше недель), пока `totalHours` не будет полностью исчерпан. Преподаватель честно выработает положенный объём часов за счёт увеличения количества недель преподавания.
2. **Нормализация через связанные таблицы:**
Мы не используем сырые массивы (`INTEGER[]`) или JSONB-колонки. Реализована структура со строгой нормализацией:
* Главная таблица: `schedule_rules` (хранит лимиты и дату старта).
* Подчинённая таблица: `schedule_rule_slots` (хранит конкретный день, чётность, номер пары, преподавателя, аудиторию, тип и формат — прикреплённые к ID главного правила через Foreign Key).
* Связующая таблица: `schedule_rule_groups` (Many-to-Many между правилом и группами).
Это позволяет базе данных строить сложные выборки в стиле «Покажи загруженность кабинета №21 во вторник на второй паре по чётным неделям», исключая тяжёлый парсинг JSON.
3. **Поддержка подгрупп внутри слотов:**
В таблицу `schedule_rule_slots` введено поле `subgroup_id` (Id подгруппы, nullable). Алгоритм генератора сможет рендерить два предмета для одной группы одновременно и без конфликтов, если они ассоциированы с разными подгруппами одной материнской группы.
4. **Обогащённые слоты (Вариант Б):**
`teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` хранятся в каждой строке `schedule_rule_slots`, а не в главном правиле. Это позволяет описывать лекции и практики одного предмета в рамках одного правила, расходуя общий `totalHours`.
5. **Потоковые лекции через Many-to-Many:**
Одно правило связывается с несколькими группами через `schedule_rule_groups`. Для потоковой лекции создаётся одно правило, к которому привязываются все участвующие группы.
6. **Настраиваемость по тенантам:**
Архитектурно все тенанты одинаковы — каждый университет получает идентичную пустую базу данных. Временные слоты (количество, длительность, время начала/окончания пар), конвенция чётности и прочие параметры не требуют специального механизма: каждый университет просто заполняет свою БД самостоятельно через панель администратора.
---
## 3. Подробный План Действий по Реализации
Интеграция новой архитектуры затронет весь стек приложения (DB → Backend → API → Frontend). Работу предлагается вести строго поэтапно:
### Этап 1. База Данных (Flyway Миграции)
**Схема Временных слотов:**
* `time_slots` (id, order_number, start_time TIME, end_time TIME, duration_minutes INT).
* Заполняется администратором. Нет фиксированных значений — каждый тенант настраивает свою сетку пар.
**Схема Календарного графика:**
* `academic_years` (id, title VARCHAR, start_date DATE, end_date DATE).
* `semesters` (id, academic_year_id FK, semester_type ENUM('autumn','spring'), start_date DATE, end_date DATE).
* Именно от `semesters.start_date` отсчитывается «Неделя 1».
* `holidays` (id, date DATE, academic_year_id FK, description VARCHAR).
* `academic_calendar_matrix` (id, semester_id FK, course_number INT, specialty_id FK, week_number INT, activity_type ENUM('THEORY','EXAM','VACATION','PRACTICE')).
* Привязка к `course_number` + `specialty_id`, а НЕ к конкретной группе.
**Схема Движка Правил:**
* `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date DATE, total_academic_hours INT).
* `total_academic_hours` — в академических часах (1 ак. час = 45 мин, одна пара = 2 ак. часа).
* `schedule_rule_groups` (schedule_rule_id FK, group_id FK) — PK составной.
* Связующая таблица для потоковых лекций.
* `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week INT CHECK(17), parity ENUM('BOTH','EVEN','ODD'), time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format VARCHAR).
**Скрипт Миграции (Data ETL):** Написание SQL/Java скрипта для миграции данных из двух источников:
1. **Из `schedule_data`**`schedule_rules` + `schedule_rule_groups`: перенос плановой нагрузки (`number_of_hours``total_academic_hours`, `group_id`, `subjects_id`, `teacher_id`, `lesson_type_id`, `is_division`, `semester_type`, `period`).
2. **Из `lessons`**`schedule_rule_slots`: перенос расписания с трансформацией данных:
* `day` (строка «Понедельник»...«Суббота») → `day_of_week` (INT 16).
* `time` (строка «8:00 - 9:30») → `time_slot_id` (FK на `time_slots`).
* `week` (строка «Верхняя»/«Нижняя»/«Обе») → `parity` (ENUM `ODD`/`EVEN`/`BOTH`).
* Группировка записей с одинаковым `(subject_id, group_id)` в одно правило.
После успешной миграции и верификации данных — удаление таблиц `lessons` и `schedule_data`.
### Этап 2. Бэкенд и Вычислительное Ядро (Java + Spring Boot)
* `AcademicDateService.java` — сервис утилит для календарной математики:
* Перевод дат в номер недели семестра.
* Определение чётности недели с учётом настройки тенанта.
* Проверка попадания дня в справочник `holidays`.
* Вычисление текущего курса группы: екущий_учебный_год - year_start_study + 1`.
* `ScheduleRuleRepository.java` — JPA репозитории для извлечения графа правил из базы данных, с оптимизацией N+1 проблемы через `JOIN FETCH` со слотами и группами.
* `ScheduleGeneratorService.java` — Сердце системы. Основные методы:
* `List<RenderedLesson> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы.
* `List<RenderedLesson> buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate)` — расписание преподавателя (поиск по `teacher_id` в слотах, обогащение информацией о группах).
* Реализует всю бизнес-логику из пункта 1.4 (подсчёт вычитанных часов, пропуск праздников, кеширование).
* Адаптация валидаторов пересечения аудиторий: теперь валидатор должен работать не на уровне «каждой пары», а симулировать весь семестр на этапе сохранения нового Правила в панели администратора.
### Этап 3. Обновление REST API (Контроллеры)
* **Новый эндпоинт расписания:** `GET /api/schedule` переходит на диапазонную модель. Параметры: `?groupId=123&startDate=2024-10-14&endDate=2024-10-20` или `?teacherId=456&startDate=...&endDate=...`. Ответ — массив объектов с полными датами `YYYY-MM-DD`.
* **Обратная совместимость:** Старый эндпоинт `GET /api/users/lessons` будет помечен как `@Deprecated` и продолжит работать до полной миграции фронтенда. После завершения миграции фронтенда — удаление.
* **CRUD-контроллеры для админки:**
* `/api/admin/time-slots` (настройка сетки временных слотов).
* `/api/admin/calendar/years` (учебные годы и семестры).
* `/api/admin/calendar/matrix` (настройка каникул и сессий по курсам/специальностям/неделям).
* `/api/admin/calendar/holidays` (добавление исключений).
* `/api/admin/schedule-rules` (управление жизненным циклом Правил, их слотами и привязкой к группам).
### Этап 4. Интерфейсы Frontend (Vanilla JS + HTML)
* **Страницы просмотра (Студенты и Преподаватели):**
* Реализация переключателя календарных дат (Date Picker или кнопки-перелистывания недель).
* Логика, которая при свайпе или клике запрашивает у API конкретный диапазон дат и перерисовывает DOM-дерево.
* Для преподавателей — отображение всех групп, привязанных к каждому занятию.
* **Панель Администратора (SPA-интерфейсы):**
* **Вкладка «Временные слоты»:** Настройка сетки пар — количество, время начала/окончания, длительность.
* **Вкладка «Учебный график»:** Визуальная сетка-матрица (недели по горизонтали, Курсы/Специальности по вертикали), где админ может закрашивать пересечения разными цветами, назначая статусы (Практика, Каникулы, Теория, Экзамены).
* **Вкладка «Конструктор Правил»:** Глобально новый визуальный инструмент расписания. Админ выбирает Группы (одну или несколько для потока) и Дисциплину, задаёт `totalHours` в академических часах, а затем динамически добавляет строчки массива слотов через кнопку «Добавить занятие» со списками (Selects) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия.

218
SCHEDULE_TASKS.md Normal file
View File

@@ -0,0 +1,218 @@
# 📋 Задачи: Динамическая генерация расписания
> Декомпозиция [`SCHEDULE_PROPOSAL.md`](SCHEDULE_PROPOSAL.md) на подзадачи для доски планирования.
> Категории: **Backend**, **Frontend**, **DevOps/DB**
---
## DevOps / Database
### Flyway-миграция: Временные слоты
- [ ] Создать миграцию: таблица `time_slots` (id, order_number, start_time, end_time, duration_minutes)
- [ ] Добавить CHECK-ограничения (start_time < end_time, duration_minutes > 0, order_number > 0)
---
### Flyway-миграция: Учебные годы и семестры
- [ ] Создать миграцию: таблица `academic_years` (id, title, start_date, end_date)
- [ ] Создать миграцию: таблица `semesters` (id, academic_year_id FK, semester_type ENUM, start_date, end_date)
- [ ] Добавить CHECK-ограничения и индексы
---
### Flyway-миграция: Праздники
- [ ] Создать миграцию: таблица `holidays` (id, date, academic_year_id FK, description)
- [ ] Добавить уникальный индекс на (date, academic_year_id)
---
### Flyway-миграция: Матрица учебного графика
- [ ] Создать миграцию: таблица `academic_calendar_matrix` (id, semester_id FK, course_number, specialty_id FK, week_number, activity_type ENUM)
- [ ] Добавить ENUM: `THEORY`, `EXAM`, `VACATION`, `PRACTICE`
- [ ] Добавить уникальный индекс на (semester_id, course_number, specialty_id, week_number)
---
### Flyway-миграция: Правила расписания
- [ ] Создать миграцию: таблица `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date, total_academic_hours)
- [ ] Создать миграцию: связующая таблица `schedule_rule_groups` (schedule_rule_id FK, group_id FK, PK составной)
- [ ] Создать миграцию: таблица `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week, parity ENUM, time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format)
- [ ] Добавить CHECK на day_of_week (17)
- [ ] Добавить ENUM: `BOTH`, `EVEN`, `ODD`
---
### ETL-миграция данных
- [ ] Написать SQL/Java скрипт миграции `schedule_data``schedule_rules` + `schedule_rule_groups`
- [ ] Маппинг `number_of_hours``total_academic_hours`
- [ ] Маппинг привязок групп
- [ ] Написать SQL/Java скрипт миграции `lessons``schedule_rule_slots`
- [ ] Трансформация `day` (строка) → `day_of_week` (INT 16)
- [ ] Трансформация `time` (строка) → `time_slot_id` (FK)
- [ ] Трансформация `week` (строка) → `parity` (ENUM)
- [ ] Группировка записей с одинаковым (subject_id, group_id) в одно правило
- [ ] Верификация мигрированных данных (количество записей, целостность FK)
- [ ] Создать миграцию на удаление устаревших таблиц `lessons` и `schedule_data` (после верификации)
---
## Backend (Java + Spring Boot)
### JPA-сущности (Model)
- [ ] Создать Entity: `TimeSlot`
- [ ] Создать Entity: `AcademicYear`
- [ ] Создать Entity: `Semester` (связь ManyToOne → AcademicYear)
- [ ] Создать Entity: `Holiday` (связь ManyToOne → AcademicYear)
- [ ] Создать Entity: `AcademicCalendarMatrix` (связи на Semester, Specialty)
- [ ] Создать Entity: `ScheduleRule` (связи на Subject, Semester)
- [ ] Создать Entity: `ScheduleRuleSlot` (связи на ScheduleRule, TimeSlot, Teacher, Classroom, LessonType)
- [ ] Настроить ManyToMany-связь ScheduleRule ↔ StudentGroup через `schedule_rule_groups`
---
### DTO
- [ ] Создать DTO: `TimeSlotDto`
- [ ] Создать DTO: `AcademicYearDto`, `SemesterDto`
- [ ] Создать DTO: `HolidayDto`
- [ ] Создать DTO: `AcademicCalendarMatrixDto`
- [ ] Создать DTO: `ScheduleRuleDto`, `ScheduleRuleSlotDto`
- [ ] Создать DTO: `RenderedLessonDto` (ответ генератора расписания)
---
### Repository
- [ ] Создать `TimeSlotRepository`
- [ ] Создать `AcademicYearRepository`
- [ ] Создать `SemesterRepository` (метод findByDateRange)
- [ ] Создать `HolidayRepository` (метод findByAcademicYearId)
- [ ] Создать `AcademicCalendarMatrixRepository` (метод findBySemesterAndCourseAndSpecialty)
- [ ] Создать `ScheduleRuleRepository` с JOIN FETCH (решение N+1 проблемы)
- [ ] Метод: findByGroupIdAndSemesterId (через schedule_rule_groups)
- [ ] Метод: findByTeacherIdAndSemesterId (через schedule_rule_slots.teacher_id)
---
### Сервис: AcademicDateService
- [ ] Метод: перевод произвольной даты → номер недели семестра
- [ ] Метод: определение чётности недели с учётом настройки тенанта
- [ ] Метод: проверка попадания даты в справочник `holidays`
- [ ] Метод: вычисление текущего курса группы (екущий_учебный_год - year_start_study + 1`)
- [ ] Метод: определение семестра по дате
- [ ] Написать юнит-тесты для AcademicDateService
---
### Сервис: ScheduleGeneratorService
- [ ] Метод: `buildScheduleForGroup(groupId, startDate, endDate)` — расписание группы
- [ ] Определение семестра по диапазону дат
- [ ] Вычисление номера недели и курса группы
- [ ] Проверка типа деятельности через матрицу графика
- [ ] Загрузка активных правил для группы
- [ ] Симуляция прогона часов (подсчёт consumed_hours)
- [ ] Пропуск праздников при подсчёте часов
- [ ] Проекция слотов на запрошенную неделю с учётом чётности и подгрупп
- [ ] Метод: `buildScheduleForTeacher(teacherId, startDate, endDate)` — расписание преподавателя
- [ ] Поиск правил по teacher_id в слотах
- [ ] Обогащение ответа списком групп из schedule_rule_groups
- [ ] Написать юнит-тесты для ScheduleGeneratorService
- [ ] Написать интеграционные тесты (полный цикл с тестовой БД)
---
### Кеширование
- [ ] Реализовать кеш списка праздников по учебному году
- [ ] Реализовать кеш матрицы учебного графика по ключу (course, specialty_id, semester_id)
- [ ] Реализовать кеш consumed_hours для каждого правила
- [ ] Реализовать инвалидацию кеша праздников при CRUD-операциях с holidays
- [ ] Реализовать инвалидацию кеша consumed_hours при изменении правил или праздников
---
### Валидация
- [ ] Адаптировать валидатор пересечения аудиторий (симуляция всего семестра при сохранении правила)
- [ ] Валидация пересечения преподавателей (один преподаватель не может вести две пары одновременно)
- [ ] Валидация пересечения групп (одна группа не может быть на двух занятиях одновременно, кроме подгрупп)
---
### REST API: Контроллеры
- [ ] `GET /api/schedule` — Новый эндпоинт расписания (параметры: groupId/teacherId + startDate + endDate)
- [ ] Пометить `GET /api/users/lessons` как `@Deprecated` (обратная совместимость)
- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/time-slots`
- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/years`
- [ ] CRUD: `GET/PUT /api/admin/calendar/semesters` (вложены в years)
- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/holidays`
- [ ] CRUD: `GET/PUT /api/admin/calendar/matrix` (массовое сохранение матрицы)
- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/schedule-rules`
- [ ] Включая вложенные слоты и привязку групп
- [ ] Написать интеграционные тесты для API
---
### Удаление устаревшего кода
- [ ] Удалить/рефакторить старый `LessonsController` (после миграции фронтенда)
- [ ] Удалить/рефакторить старый `ScheduleDataController`
- [ ] Удалить старые Entity: `Lesson`, `ScheduleData`
- [ ] Удалить старые Repository и Service для lessons/schedule_data
---
## Frontend (Vanilla JS + HTML/CSS)
### Просмотр расписания: Студенты
- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям)
- [ ] Переключить API-запросы на новый `GET /api/schedule?groupId=...&startDate=...&endDate=...`
- [ ] Рендеринг расписания по дням и временным слотам
- [ ] Отображение статуса периода (Каникулы / Практика / Экзамены), если неделя не учебная
- [ ] Отображение информации о подгруппах (два занятия рядом для разных подгрупп)
---
### Просмотр расписания: Преподаватели
- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям)
- [ ] Переключить API-запросы на новый `GET /api/schedule?teacherId=...&startDate=...&endDate=...`
- [ ] Отображение всех групп, привязанных к каждому занятию
- [ ] Отображение подгрупп, если преподаватель ведёт у подгруппы
---
### Панель администратора: Вкладка «Временные слоты»
- [ ] Создать UI-страницу настройки временных слотов
- [ ] CRUD-интерфейс: добавление/редактирование/удаление пар
- [ ] Отображение таблицы: номер пары → время начала → время окончания → длительность
- [ ] Валидация на фронтенде (пересечение времён, корректность данных)
---
### Панель администратора: Вкладка «Учебный график»
- [ ] Создать UI: выбор учебного года и семестра
- [ ] Создать UI: CRUD учебных годов и семестров
- [ ] Создать UI: CRUD праздников (список дат с описанием)
- [ ] Создать визуальную сетку-матрицу:
- [ ] Горизонтальная ось — номера недель
- [ ] Вертикальная ось — Курс + Специальность
- [ ] Цветовая кодировка ячеек: Теория/Экзамены/Каникулы/Практика
- [ ] Клик/драг для массового назначения статуса
- [ ] Сохранение матрицы через API `PUT /api/admin/calendar/matrix`
---
### Панель администратора: Вкладка «Конструктор Правил»
- [ ] Создать UI: список существующих правил с фильтрацией (по группе, предмету, семестру)
- [ ] Форма создания/редактирования правила:
- [ ] Мультиселект групп (для потоковых лекций)
- [ ] Выбор дисциплины (subject)
- [ ] Выбор семестра
- [ ] Ввод totalHours (академические часы)
- [ ] Ввод даты начала (active_from_date)
- [ ] Динамический массив слотов (кнопка «Добавить занятие»):
- [ ] Select: День недели
- [ ] Select: Временной слот (из таблицы time_slots)
- [ ] Select: Чётность (Обе/Чётная/Нечётная)
- [ ] Select: Подгруппа (опционально)
- [ ] Select: Преподаватель
- [ ] Select: Аудитория
- [ ] Select: Тип занятия (Лекция/Практика/Лаба)
- [ ] Select: Формат (Очно/Онлайн)
- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты)
- [ ] Удаление правила с подтверждением

View File

@@ -4,6 +4,7 @@ COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
RUN curl -L -o opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
FROM eclipse-temurin:17-jre-alpine
@@ -13,5 +14,6 @@ USER spring:spring
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]

View File

@@ -1,77 +0,0 @@
# Руководство Backend-разработчика Magistr
Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами.
Здесь описано, как тут всё устроено, чтобы вы ничего не сломали.
---
## 1. Архитектура мультитенантности
Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента).
- **Как приложение понимает, к какой базе обращаться?**
Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`).
В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`.
- **Как переключаются базы данных?**
Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету.
> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе.
---
## 2. Динамическое управление тенантами (Kubernetes / ConfigMap)
Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**.
Список тенантов не зашит в код:
- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`.
- В классе `DatabaseController` находится API для добавления нового тенанта из админки.
- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`.
- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных.
---
## 3. Базы данных и Миграции (Flyway)
Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`).
Структурой баз данных правит **Flyway**.
Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта.
### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД:
Если вам нужно добавить новую таблицу, колонку или изменить тип поля:
1. **Запрещено трогать старые файлы миграций!**
Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`.
2. **Как правильно добавить таблицу?**
- Зайдите в папку `src/main/resources/db/migration/`.
- Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`.
- Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`).
- Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт.
3. **Что если локально я накосячил в V2?**
Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**:
`DELETE FROM flyway_schema_history WHERE version = '2';`
Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую.
---
## 4. Как запускать проект локально
В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру:
```bash
docker compose up -d
```
Соберется и запустится:
- Фронтенд
- Бэкенд
- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле).
Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети).
Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway.
Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!

View File

@@ -56,6 +56,13 @@
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- OpenTelemetry API for custom span attributes -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>1.49.0</version>
</dependency>
</dependencies>
<build>

View File

@@ -9,6 +9,8 @@ import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.Map;
import org.slf4j.MDC;
import io.opentelemetry.api.trace.Span;
/**
* Interceptor: извлекает поддомен из Host header и кладёт в TenantContext.
@@ -48,6 +50,8 @@ public class TenantInterceptor implements HandlerInterceptor {
// (нужно чтобы админ мог добавить тенант даже если его домен не настроен)
if (path.startsWith("/api/database")) {
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
return true;
}
@@ -66,6 +70,8 @@ public class TenantInterceptor implements HandlerInterceptor {
}
TenantContext.setCurrentTenant(tenant);
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
return true;
}
@@ -73,6 +79,7 @@ public class TenantInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
MDC.remove("tenant.id");
}
private String resolveTenant(String host) {

View File

@@ -38,14 +38,15 @@ public class AuthController {
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
}
User user = userOpt.get();
String token = UUID.randomUUID().toString();
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
}
}

View File

@@ -0,0 +1,106 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateDepartmentRequest;
import com.magistr.app.dto.DepartmentResponse;
import com.magistr.app.model.Department;
import com.magistr.app.repository.DepartmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/departments")
public class DepartmentController {
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
private final DepartmentRepository departmentRepository;
public DepartmentController(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
@GetMapping
public List<Department> getAllDepartments() {
logger.info("Получен запрос на получение списка кафедр");
try {
List<Department> departments = departmentRepository.findAll();
List<Department> response = departments.stream()
.map( d -> new Department(
d.getId(),
d.getDepartmentName(),
d.getDepartmentCode()
))
.toList();
logger.info("Получено {} кафедр", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createDepartment(@RequestBody CreateDepartmentRequest request) {
logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode());
try {
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){
String errorMessage = "Название кафедры обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) {
String errorMessage = "Кафедра с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
String errorMessage = "Код кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) {
String errorMessage = "Кафедра с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Department department = new Department();
department.setDepartmentName(request.getDepartmentName());
department.setDepartmentCode(request.getDepartmentCode());
departmentRepository.save(department);
logger.info("Кафедра успешно создана с ID: {}", department.getId());
return ResponseEntity.ok(
new DepartmentResponse(
department.getId(),
department.getDepartmentName(),
department.getDepartmentCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteDepartment(@PathVariable Long id) {
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
if (!departmentRepository.existsById(id)) {
logger.info("Кафедра с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
departmentRepository.deleteById(id);
logger.info("Кафедра с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
}
}

View File

@@ -6,9 +6,14 @@ import com.magistr.app.model.EducationForm;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Year;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -17,6 +22,8 @@ import java.util.Optional;
@RequestMapping("/api/groups")
public class GroupController {
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
private final GroupRepository groupRepository;
private final EducationFormRepository educationFormRepository;
@@ -28,29 +35,120 @@ public class GroupController {
@GetMapping
public List<GroupResponse> getAllGroups() {
return groupRepository.findAll().stream()
.map(g -> new GroupResponse(
logger.info("Получен запрос на получение всех групп");
try {
List<StudentGroup> groups = groupRepository.findAll();
List<GroupResponse> response = groups.stream()
.map(g -> {
int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy());
int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy());
return new GroupResponse(
g.getId(),
g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(),
g.getEducationForm().getName()))
g.getEducationForm().getName(),
g.getDepartmentId(),
course,
semester,
g.getSpecialityCode()
);
})
.toList();
logger.info("Получено {} групп", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Группы для указанной кафедры не найдены");
}
List<GroupResponse> response = groups.stream()
.map(g -> {
int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy());
int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy());
return new GroupResponse(
g.getId(),
g.getName(),
g.getGroupSize(),
g.getEducationForm().getId(),
g.getEducationForm().getName(),
g.getDepartmentId(),
course,
semester,
g.getSpecialityCode()
);
})
.toList();
logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка групп");
}
}
@PostMapping
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy());
try {
if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
String errorMessage = "Название группы обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
String errorMessage = "Группа с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupSize() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
String errorMessage = "Численность группы обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getEducationFormId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
String errorMessage = "Форма обучения обязательна";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
// if (request.getCourse() == null || request.getCourse() == 0) {
// String errorMessage = "Курс обязателен";
// logger.error("Ошибка валидации: {}", errorMessage);
// return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
// }
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
String errorMessage = "Год начала обучения обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
@@ -62,22 +160,38 @@ public class GroupController {
group.setName(request.getName().trim());
group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId());
group.setYearStartStudy(request.getYearStartStudy());
group.setSpecialityCode(request.getSpecialityCode());
groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId());
return ResponseEntity.ok(new GroupResponse(
group.getId(),
group.getName(),
group.getGroupSize(),
group.getEducationForm().getId(),
group.getEducationForm().getName()));
group.getEducationForm().getName(),
group.getDepartmentId(),
group.getYearStartStudy(),
group.getSpecialityCode()));
} catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
logger.info("Получен запрос на удаление группы с ID - {}", id);
if (!groupRepository.existsById(id)) {
logger.info("Группа с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
logger.info("Группа с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
}
}

View File

@@ -0,0 +1,283 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateScheduleDataRequest;
import com.magistr.app.dto.ScheduleResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import com.magistr.app.utils.SemesterTypeValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/department/schedule")
public class ScheduleDataController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class);
private final ScheduleDataRepository scheduleDataRepository;
private final GroupRepository groupRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SubjectRepository subjectRepository;
private final LessonTypesRepository lessonTypesRepository;
private final UserRepository userRepository;
public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) {
this.scheduleDataRepository = scheduleDataRepository;
this.groupRepository = groupRepository;
this.specialtiesRepository = specialtiesRepository;
this.subjectRepository = subjectRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.userRepository = userRepository;
}
@GetMapping("/allList")
public List<ScheduleData> getAllScheduleDataList() {
logger.info("Получен запрос на получение списка данных расписаний");
try {
List<ScheduleData> scheduleData = scheduleDataRepository.findAll();
List<ScheduleData> response = scheduleData.stream()
.map(s -> new ScheduleData(
s.getId(),
s.getDepartmentId(),
s.getGroupId(),
s.getSubjectsId(),
s.getLessonTypeId(),
s.getNumberOfHours(),
s.getDivision(),
s.getTeacherId(),
s.getSemesterType(),
s.getPeriod()
))
.toList();
logger.info("Получено {} записей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping
public ResponseEntity<?> getSingleScheduleData(
@RequestParam Long departmentId,
@RequestParam SemesterType semesterType,
@RequestParam String period
) {
logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}",
departmentId, semesterType, period);
try {
List<ScheduleData> scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period );
if(scheduleData.isEmpty()){
logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period);
return ResponseEntity.ok(Map.of(
"message", "Записей не найдено"
));
}
List<ScheduleResponse> response = scheduleData.stream()
.map( s -> {
String groupName = groupRepository.findById(s.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
int groupSemester = 0;
int groupCourse = 0;
String specialityCode = "Неизвестно";
StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null);
if (group != null) {
groupCourse = CourseAndSemesterCalculator.getFutureCourse(group.getYearStartStudy(), period);
}
if (group != null) {
groupSemester = CourseAndSemesterCalculator.getFutureSemester(group.getYearStartStudy(), period, semesterType);
}
if (group != null) {
Long specialityId = group.getSpecialityCode();
specialityCode = specialtiesRepository.findById(specialityId).
map(Speciality::getSpecialityCode)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(s.getSubjectsId())
.map(Subject::getName)
.orElse("Неизвестно");
String lessonType = lessonTypesRepository.findById(s.getLessonTypeId())
.map(LessonType::getLessonType)
.orElse("Неизвестно");
String teacherName = userRepository.findById(s.getTeacherId())
.map(User::getFullName)
.orElse("Неизвестно");
String teacherjobTitle = userRepository.findById(s.getTeacherId())
.map(User::getJobTitle)
.orElse("Неизвестно");
return new ScheduleResponse(
s.getId(),
s.getDepartmentId(),
specialityCode,
groupName,
groupCourse,
groupSemester,
subjectName,
lessonType,
s.getNumberOfHours(),
s.getDivision(),
teacherName,
teacherjobTitle,
s.getSemesterType(),
s.getPeriod());
}
)
.toList();
logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e);
throw e;
}
}
// Доделать проверки получаемых полей!!!
@PostMapping("/create")
public ResponseEntity<?> createScheduleData(@RequestBody CreateScheduleDataRequest request) {
logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}",
request.getDepartmentId(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod());
try {
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!scheduleDataRepository.existsById(request.getDepartmentId())) {
String errorMessage = "Кафедра не найдена";
logger.info("Кафедра не найдена");
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupId() == null || request.getGroupId() == 0) {
String errorMessage = "ID группы обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSubjectsId() == null || request.getSubjectsId() == 0) {
String errorMessage = "ID дисциплины обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) {
String errorMessage = "ID типа занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getNumberOfHours() == null) {
request.setNumberOfHours(0L);
}
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
String errorMessage = "ID преподавателя обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSemesterType() == null) {
String errorMessage = "Семестр обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) {
String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes();
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPeriod() == null || request.getPeriod().isBlank()) {
String errorMessage = "Период обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
request.getDepartmentId(),
request.getGroupId(),
request.getSubjectsId(),
request.getLessonTypeId(),
request.getNumberOfHours(),
request.getDivision(),
request.getTeacherId(),
request.getSemesterType(),
request.getPeriod()
);
if(existsRecord) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
}
ScheduleData scheduleData = new ScheduleData();
scheduleData.setDepartmentId(request.getDepartmentId());
scheduleData.setGroupId(request.getGroupId());
scheduleData.setSubjectsId(request.getSubjectsId());
scheduleData.setLessonTypeId(request.getLessonTypeId());
scheduleData.setNumberOfHours(request.getNumberOfHours());
scheduleData.setDivision(request.getDivision());
scheduleData.setTeacherId(request.getTeacherId());
scheduleData.setSemesterType(request.getSemesterType());
scheduleData.setPeriod(request.getPeriod());
ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData);
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", savedSchedule.getId());
response.put("departmentId", savedSchedule.getDepartmentId());
response.put("groupId", savedSchedule.getGroupId());
response.put("subjectId", savedSchedule.getSubjectsId());
response.put("lessonTypeId", savedSchedule.getLessonTypeId());
response.put("numberOfHours", savedSchedule.getNumberOfHours());
response.put("isDivision", savedSchedule.getDivision());
response.put("teacherId", savedSchedule.getTeacherId());
response.put("semesterType", savedSchedule.getSemesterType());
response.put("period", savedSchedule.getPeriod());
logger.info("Запись успешно создана с ID: {}", savedSchedule.getId());
return ResponseEntity.ok(response);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
} catch (Exception e) {
logger.error("Ошибка при создании записи: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteById(@PathVariable Long id) {
logger.info("Получен запрос на удаление записи с ID: {}", id);
if(!scheduleDataRepository.existsById(id)) {
logger.info("Запись с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
scheduleDataRepository.deleteById(id);
logger.info("Запись с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Запись удалена"));
}
}

View File

@@ -0,0 +1,106 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateSpecialityRequest;
import com.magistr.app.dto.SpecialityResponse;
import com.magistr.app.model.Speciality;
import com.magistr.app.repository.SpecialtiesRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/specialties")
public class SpecialityController {
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
private final SpecialtiesRepository specialtiesRepository;
public SpecialityController(SpecialtiesRepository specialtiesRepository) {
this.specialtiesRepository = specialtiesRepository;
}
@GetMapping
public List<Speciality> getAllSpecialties() {
logger.info("Получен запрос на получение списка специальностей");
try {
List<Speciality> specialities = specialtiesRepository.findAll();
List<Speciality> response = specialities.stream()
.map( s -> new Speciality(
s.getId(),
s.getSpecialityName(),
s.getSpecialityCode()
))
.toList();
logger.info("Получено {} специальностей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e);
throw e;
}
}
@PostMapping
public ResponseEntity<?> createSpeciality(@RequestBody CreateSpecialityRequest request) {
logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode());
try {
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
String errorMessage = "Название специальности обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) {
String errorMessage = "Специальность с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) {
String errorMessage = "Специальность с таким кодом уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Speciality speciality = new Speciality();
speciality.setSpecialityName(request.getSpecialityName());
speciality.setSpecialityCode(request.getSpecialityCode());
specialtiesRepository.save(speciality);
logger.info("Специальность успешно создана с ID: {}", speciality.getId());
return ResponseEntity.ok(
new SpecialityResponse(
speciality.getId(),
speciality.getSpecialityName(),
speciality.getSpecialityCode()
)
);
} catch (Exception e) {
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
}
}
@DeleteMapping("/id")
public ResponseEntity<?> deleteSpeciality(@PathVariable Long id) {
logger.info("Получен запрос на удаление специальности с ID: {}", id);
if (!specialtiesRepository.existsById(id)) {
logger.info("Специальность с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
specialtiesRepository.deleteById(id);
logger.info("Специальность с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Специальнсть удалена"));
}
}

View File

@@ -1,7 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -12,6 +18,8 @@ import java.util.Map;
@RequestMapping("/api/subjects")
public class SubjectController {
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) {
@@ -20,32 +28,105 @@ public class SubjectController {
@GetMapping
public List<Subject> getAllSubjects() {
return subjectRepository.findAll();
logger.info("Получен запрос на получение всех дисциплин");
try {
List<Subject> subjects = subjectRepository.findAll();
List<Subject> response = subjects.stream()
.map(s -> new Subject(
s.getId(),
s.getName(),
s.getCode(),
s.getDepartmentId()
))
.toList();
logger.info("Получено {} дисциплин", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e);
throw e;
}
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
try{
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
if(subjects.isEmpty()){
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Дисциплины для указанной кафедры не найдены");
}
logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId);
return ResponseEntity.ok(subjects);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка дисциплин");
}
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
request.getName(), request.getCode(), request.getDepartmentId());
try {
if (request.getName() == null || request.getName().isBlank()) {
String errorMessage = "Название обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
String errorMessage = "Дисциплина с таким названием уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getCode() == null || request.getCode().isBlank()) {
String errorMessage = "Код дисциплины обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Subject subject = new Subject();
subject.setName(name.trim());
subject.setName(request.getName());
subject.setCode(request.getCode());
subject.setDepartmentId(request.getDepartmentId());
subjectRepository.save(subject);
return ResponseEntity.ok(subject);
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
return ResponseEntity.ok(
new SubjectResponse(
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
)
);
} catch (Exception e){
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
if (!subjectRepository.existsById(id)) {
logger.info("Дисциплина с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
logger.info("Дисциплина с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
}
}

View File

@@ -2,9 +2,15 @@ package com.magistr.app.controller;
import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
@@ -16,44 +22,151 @@ import java.util.Map;
@RequestMapping("/api/users")
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
}
@GetMapping
public List<UserResponse> getAllUsers() {
return userRepository.findAll().stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
logger.info("Получен запрос на получение всех пользователей");
try {
List<User> users = userRepository.findAll();
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} пользователей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
throw e;
}
}
@GetMapping("/teachers")
public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
try {
List<User> users = userRepository.findByRole(Role.TEACHER);
List<UserResponse> response = users.stream()
.map(u -> {
String departmentName = departmentRepository.findById(u.getDepartmentId())
.map(Department::getDepartmentName)
.orElse("Неизвестно");
return new UserResponse(
u.getId(),
u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList();
logger.info("Получено {} преподавателей", response.size());
return response;
} catch (Exception e) {
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
throw e;
}
}
@GetMapping("/teachers/{departmentId}")
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
try {
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
if (users.isEmpty()) {
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Преподаватели для указанной кафедры не найдены");
}
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
List<UserResponse> userResponses = users.stream()
.map( user -> {
return new UserResponse(
user.getId(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
}).toList();
return ResponseEntity.ok(userResponses);
} catch (Exception e) {
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Произошла ошибка при получении списка преподавателей");
}
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
try {
if (request.getUsername() == null || request.getUsername().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPassword() == null || request.getPassword().length() < 4) {
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
String errorMessage = "Пароль минимум 4 символа";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
String errorMessage = "Пользователь уже существует";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getFullName() == null || request.getFullName().isBlank()) {
String errorMessage = "Имя пользователя обязательно";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
request.setJobTitle("Не указано");
}
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
Role role;
try {
role = Role.valueOf(request.getRole());
} catch (Exception e) {
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
}
@@ -61,17 +174,30 @@ public class UserController {
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(role);
user.setFullName(request.getFullName());
user.setJobTitle(request.getJobTitle());
user.setDepartmentId(request.getDepartmentId());
userRepository.save(user);
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
logger.info("Пользователь успешно создан с ID: {}", user.getId());
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
} catch (Exception e ) {
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
if (!userRepository.existsById(id)) {
logger.info("Пользователь с ID - {} не найден", id);
return ResponseEntity.notFound().build();
}
userRepository.deleteById(id);
logger.info("Пользователь с ID - {} успешно удалён", id);
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
}
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.dto;
public class CreateDepartmentRequest {
private String departmentName;
private Long departmentCode;
public CreateDepartmentRequest() {}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

View File

@@ -5,6 +5,9 @@ public class CreateGroupRequest {
private String name;
private Long groupSize;
private Long educationFormId;
private Long departmentId;
private Integer yearStartStudy;
private Long specialityCode;
public String getName() {
return name;
@@ -29,4 +32,28 @@ public class CreateGroupRequest {
public void setEducationFormId(Long educationFormId) {
this.educationFormId = educationFormId;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public void setYearStartStudy(Integer yearStartStudy) {
this.yearStartStudy = yearStartStudy;
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
}
}

View File

@@ -0,0 +1,96 @@
package com.magistr.app.dto;
import com.magistr.app.model.SemesterType;
public class CreateScheduleDataRequest {
private Long id;
private Long departmentId;
private Long groupId;
private Long subjectsId;
private Long lessonTypeId;
private Long numberOfHours;
private Boolean division;
private Long teacherId;
private SemesterType semesterType;
private String period;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectsId() {
return subjectsId;
}
public void setSubjectsId(Long subjectsId) {
this.subjectsId = subjectsId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public void setNumberOfHours(Long numberOfHours) {
this.numberOfHours = numberOfHours;
}
public Boolean getDivision() {
return division;
}
public void setDivision(Boolean division) {
this.division = division;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public SemesterType getSemesterType() {
return semesterType;
}
public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType;
}
public String getPeriod() {
return period;
}
public void setPeriod(String period) {
this.period = period;
}
}

View File

@@ -0,0 +1,25 @@
package com.magistr.app.dto;
public class CreateSpecialityRequest {
private String specialityName;
private String specialityCode;
public CreateSpecialityRequest() {}
public String getSpecialityName() {
return specialityName;
}
public void setSpecialityName(String specialityName) {
this.specialityName = specialityName;
}
public String getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(String specialityCode) {
this.specialityCode = specialityCode;
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
public class CreateSubjectRequest {
private Long id;
private String name;
private String code;
private Long departmentId;
public CreateSubjectRequest() {};
public CreateSubjectRequest(Long id, String name, String code, Long departmentId) {
this.id = id;
this.name = name;
this.code = code;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -5,6 +5,9 @@ public class CreateUserRequest {
private String username;
private String password;
private String role;
private String fullName;
private String jobTitle;
private Long departmentId;
public CreateUserRequest() {
}
@@ -32,4 +35,28 @@ public class CreateUserRequest {
public void setRole(String role) {
this.role = role;
}
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;
}
}

View File

@@ -0,0 +1,38 @@
package com.magistr.app.dto;
public class DepartmentResponse {
private Long id;
private String departmentName;
private Long departmentCode;
public DepartmentResponse(Long id, String departmentName, Long departmentCode) {
this.id = id;
this.departmentName = departmentName;
this.departmentCode = departmentCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

View File

@@ -1,5 +1,8 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupResponse {
private Long id;
@@ -7,13 +10,33 @@ public class GroupResponse {
private Long groupSize;
private Long educationFormId;
private String educationFormName;
private Long departmentId;
private Integer yearStartStudy;
private Integer course;
private Integer semester;
private Long specialityCode;
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName) {
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Integer semester, Long specialityCode) {
this.id = id;
this.name = name;
this.groupSize = groupSize;
this.educationFormId = educationFormId;
this.educationFormName = educationFormName;
this.departmentId = departmentId;
this.course = course;
this.semester = semester;
this.specialityCode = specialityCode;
}
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer yearStartStudy, Long specialityCode) {
this.id = id;
this.name = name;
this.groupSize = groupSize;
this.educationFormId = educationFormId;
this.educationFormName = educationFormName;
this.departmentId = departmentId;
this.yearStartStudy = yearStartStudy;
this.specialityCode = specialityCode;
}
public Long getId() {
@@ -35,4 +58,24 @@ public class GroupResponse {
public String getEducationFormName() {
return educationFormName;
}
public Long getDepartmentId() {
return departmentId;
}
public Integer getCourse() {
return course;
}
public Integer getSemester() {
return semester;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public Long getSpecialityCode() {
return specialityCode;
}
}

View File

@@ -7,16 +7,18 @@ public class LoginResponse {
private String token;
private String role;
private String redirect;
private Long departmentId;
public LoginResponse() {
}
public LoginResponse(boolean success, String message, String token, String role, String redirect) {
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
this.success = success;
this.message = message;
this.token = token;
this.role = role;
this.redirect = redirect;
this.departmentId = departmentId;
}
public boolean isSuccess() {
@@ -58,4 +60,12 @@ public class LoginResponse {
public void setRedirect(String redirect) {
this.redirect = redirect;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -0,0 +1,128 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.magistr.app.model.SemesterType;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ScheduleResponse {
private Long id;
private String specialityCode;
private Long departmentId;
private Long groupId;
private String groupName;
private Integer groupCourse;
private Integer groupSemester;
private Long subjectsId;
private String subjectName;
private Long lessonTypeId;
private String lessonType;
private Long numberOfHours;
private Boolean division;
private Long teacherId;
private String teacherName;
private String teacherJobTitle;
private SemesterType semesterType;
private String period;
public ScheduleResponse(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.groupId = groupId;
this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherId = teacherId;
this.semesterType = semesterType;
this.period = period;
}
public ScheduleResponse(Long id, Long departmentId, String specialityCode, String groupName, Integer groupCourse, Integer groupSemester, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.specialityCode = specialityCode;
this.groupName = groupName;
this.groupCourse = groupCourse;
this.groupSemester = groupSemester;
this.subjectName = subjectName;
this.lessonType = lessonType;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherName = teacherName;
this.teacherJobTitle = teacherJobTitle;
this.semesterType = semesterType;
this.period = period;
}
public Long getId() {
return id;
}
public String getSpecialityCode() {
return specialityCode;
}
public Long getDepartmentId() {
return departmentId;
}
public Long getGroupId() {
return groupId;
}
public String getGroupName() {
return groupName;
}
public Integer getGroupCourse() {
return groupCourse;
}
public Integer getGroupSemester() {
return groupSemester;
}
public Long getSubjectsId() {
return subjectsId;
}
public String getSubjectName() {
return subjectName;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public String getLessonType() {
return lessonType;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public Boolean getDivision() {
return division;
}
public Long getTeacherId() {
return teacherId;
}
public String getTeacherName() {
return teacherName;
}
public String getTeacherJobTitle() {
return teacherJobTitle;
}
public SemesterType getSemesterType() {
return semesterType;
}
public String getPeriod() {
return period;
}
}

View File

@@ -0,0 +1,38 @@
package com.magistr.app.dto;
public class SpecialityResponse {
private Long id;
private String specialityName;
private String specialityCode;
public SpecialityResponse(Long id, String specialityName, String specialityCode) {
this.id = id;
this.specialityName = specialityName;
this.specialityCode = specialityCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSpecialityName() {
return specialityName;
}
public void setSpecialityName(String specialityName) {
this.specialityName = specialityName;
}
public String getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(String specialityCode) {
this.specialityCode = specialityCode;
}
}

View File

@@ -0,0 +1,35 @@
package com.magistr.app.dto;
public class SubjectResponse {
private Long id;
private String name;
private String code;
private Long departmentId;
public SubjectResponse() {};
public SubjectResponse(Long id, String name, String code, Long departmentId) {
this.id = id;
this.name = name;
this.code = code;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getCode() {
return code;
}
public Long getDepartmentId() {
return departmentId;
}
}

View File

@@ -1,41 +1,72 @@
package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserResponse {
private Long id;
private String username;
private String role;
private String fullName;
private String jobTitle;
private String departmentName;
private Long departmentId;
public UserResponse() {
}
public UserResponse(Long id, String username, String role) {
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
this.id = id;
this.username = username;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentName = departmentName;
}
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
this.id = id;
this.username = username;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentId = departmentId;
}
public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) {
this.id = id;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentId = departmentId;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
public String getFullName() {
return fullName;
}
public String getJobTitle() {
return jobTitle;
}
public String getDepartmentName() {
return departmentName;
}
public Long getDepartmentId() {
return departmentId;
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false)
private String departmentName;
@Column(name = "code", nullable = false)
private Long departmentCode;
public Department() {}
public Department(Long id, String departmentName, Long departmentCode) {
this.id = id;
this.departmentName = departmentName;
this.departmentCode = departmentCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDepartmentName() {
return departmentName;
}
public void setDepartmentName(String departmentName) {
this.departmentName = departmentName;
}
public Long getDepartmentCode() {
return departmentCode;
}
public void setDepartmentCode(Long departmentCode) {
this.departmentCode = departmentCode;
}
}

View File

@@ -0,0 +1,31 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="lesson_types")
public class LessonType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="name", nullable = false)
private String lessonType;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLessonType() {
return lessonType;
}
public void setLessonType(String lessonType) {
this.lessonType = lessonType;
}
}

View File

@@ -0,0 +1,135 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="schedule_data")
public class ScheduleData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="department_id", nullable = false)
private Long departmentId;
@Column(name="group_id", nullable = false)
private Long groupId;
@Column(name="subjects_id", nullable = false)
private Long subjectsId;
@Column(name="lesson_type_id", nullable = false)
private Long lessonTypeId;
@Column(name="number_of_hours", nullable = false)
private Long numberOfHours;
@Column(name="is_division", nullable = false)
private Boolean division;
@Column(name="teacher_id", nullable = false)
private Long teacherId;
@Enumerated(EnumType.STRING)
@Column(name="semester_type", nullable = false)
private SemesterType semesterType;
@Column(name="period", nullable = false)
private String period;
public ScheduleData() {}
public ScheduleData(Long id, Long departmentId, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.groupId = groupId;
this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherId = teacherId;
this.semesterType = semesterType;
this.period = period;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getSubjectsId() {
return subjectsId;
}
public void setSubjectsId(Long subjectsId) {
this.subjectsId = subjectsId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public Long getNumberOfHours() {
return numberOfHours;
}
public void setNumberOfHours(Long numberOfHours) {
this.numberOfHours = numberOfHours;
}
public Boolean getDivision() {
return division;
}
public void setDivision(Boolean division) {
this.division = division;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public SemesterType getSemesterType() {
return semesterType;
}
public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType;
}
public String getPeriod() {
return period;
}
public void setPeriod(String period) {
this.period = period;
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.model;
public enum SemesterType {
spring,
autumn
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="specialties")
public class Speciality {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="name", nullable = false)
private String specialityName;
@Column(name="specialty_code",nullable = false)
private String specialityCode;
public Speciality() {}
public Speciality(Long id, String specialityName, String specialityCode) {
this.id = id;
this.specialityName = specialityName;
this.specialityCode = specialityCode;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSpecialityName() {
return specialityName;
}
public void setSpecialityName(String specialityName) {
this.specialityName = specialityName;
}
public String getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(String specialityCode) {
this.specialityCode = specialityCode;
}
}

View File

@@ -20,6 +20,15 @@ public class StudentGroup {
@JoinColumn(name = "education_form_id", nullable = false)
private EducationForm educationForm;
@Column(name = "department_id", nullable = false)
private Long departmentId;
@Column(name="specialty_code", nullable = false)
private Long specialityCode;
@Column(name="year_start_study", nullable = false)
private Integer yearStartStudy;
public StudentGroup() {
}
@@ -54,4 +63,28 @@ public class StudentGroup {
public void setEducationForm(EducationForm educationForm) {
this.educationForm = educationForm;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
}
public Integer getYearStartStudy() {
return yearStartStudy;
}
public void setYearStartStudy(Integer yearStartStudy) {
this.yearStartStudy = yearStartStudy;
}
}

View File

@@ -13,12 +13,20 @@ public class Subject {
@Column(unique = true, nullable = false, length = 200)
private String name;
@Column(name = "code")
private String code;
@Column(name = "department_id", nullable = false)
private Long departmentId;
public Subject() {
}
public Subject(Long id, String name) {
public Subject(Long id, String name, String code, Long departmentId) {
this.id = id;
this.name = name;
this.code = code;
this.departmentId = departmentId;
}
public Long getId() {
@@ -36,4 +44,20 @@ public class Subject {
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
}

View File

@@ -20,6 +20,15 @@ public class User {
@Column(nullable = false, length = 20)
private Role role = Role.STUDENT;
@Column(name = "full_name", nullable = false)
private String fullName;
@Column(name="job_title", nullable = false)
private String jobTitle;
@Column(name="department_id", nullable = false)
private Long departmentId;
public User() {
}
@@ -54,4 +63,28 @@ public class User {
public void setRole(Role role) {
this.role = role;
}
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;
}
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.Department;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface DepartmentRepository extends JpaRepository<Department, Long> {
Optional<Department> findByDepartmentName(String departmentName);
Optional<Department> findByDepartmentCode(Long departmentCode);
}

View File

@@ -11,4 +11,6 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
Optional<StudentGroup> findByName(String name);
List<StudentGroup> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId);
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.repository;
import com.magistr.app.model.LessonType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LessonTypesRepository extends JpaRepository<LessonType, Long> {
}

View File

@@ -0,0 +1,24 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleData;
import com.magistr.app.model.SemesterType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ScheduleDataRepository extends JpaRepository<ScheduleData, Long> {
List<ScheduleData> findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period);
boolean existsByDepartmentIdAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
Long departmentId,
Long groupId,
Long subjectsId,
Long lessonTypeId,
Long numberOfHours,
Boolean division,
Long teacherId,
SemesterType semesterType,
String period
);
}

View File

@@ -0,0 +1,13 @@
package com.magistr.app.repository;
import com.magistr.app.model.Speciality;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
Optional<Speciality> findBySpecialityName(String specialityName);
Optional<Speciality> findBySpecialityCode(String specialityCode);
}

View File

@@ -3,8 +3,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
List<Subject> findByDepartmentId(Long departmentId);
}

View File

@@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
List<User> findByRole(Role role);
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
}

View File

@@ -0,0 +1,49 @@
package com.magistr.app.utils;
import com.magistr.app.model.SemesterType;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@Service
public class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) {
LocalDate now = LocalDate.now();
int currentYear = now.getYear();
int currentMonth = now.getMonthValue();
if (currentMonth >= 9) {
return currentYear - yearStartStudy + 1;
} else {
return currentYear - yearStartStudy;
}
}
public static int getActualSemester(Integer yearStartStudy) {
int course = getActualCourse(yearStartStudy);
int currentMonth = LocalDate.now().getMonthValue();
if ( currentMonth <= 1 || currentMonth >= 9) {
return course * 2 - 1;
} else {
return course * 2;
}
}
public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
int recordYear = Integer.parseInt(periodYears.substring(0, 4));
return recordYear - yearStartStudy + 1;
}
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
int course = getFutureCourse(yearStartStudy, periodYears);
if (semesterType == SemesterType.autumn) {
return course * 2 - 1;
} else if (semesterType == SemesterType.spring) {
return course * 2;
}
throw new IllegalArgumentException("Неизвестный semesterType: " + semesterType);
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.utils;
import com.magistr.app.model.SemesterType;
import java.util.Arrays;
public class SemesterTypeValidator {
public static boolean isValidTypeSemester(String semesterType) {
if (semesterType == null) {
return false;
}
try {
SemesterType.valueOf(semesterType);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
public static String getValidTypes() {
return String.join(", ", Arrays.stream(SemesterType.values())
.map(Enum::name)
.toArray(String[]::new));
}
}

View File

@@ -3,6 +3,31 @@
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ===============================
-- Справочники высшего уровня
-- ===============================
CREATE TABLE IF NOT EXISTS departments (
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
code BIGINT UNIQUE NOT NULL
);
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
specialty_code VARCHAR(255) NOT NULL
);
INSERT INTO specialties (name, specialty_code) VALUES
('Информационная безопасность', '10.03.01'),
('Информатика и вычислительная техника', '09.03.01'),
('Программная инженерия', '09.03.04');
-- ==========================================
-- Пользователи и роли
-- ==========================================
@@ -11,14 +36,17 @@ CREATE TABLE IF NOT EXISTS users (
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
full_name VARCHAR(255) NOT NULL,
job_title VARCHAR(255) NOT NULL,
department_id BIGINT NOT NULL REFERENCES departments(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN'),
('Тестовый преподаватель', '1234567890', 'TEACHER')
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2)
ON CONFLICT (username) DO NOTHING;
-- ==========================================
@@ -45,14 +73,16 @@ CREATE TABLE IF NOT EXISTS student_groups (
name VARCHAR(100) UNIQUE NOT NULL,
group_size BIGINT NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
course INT CHECK (course BETWEEN 1 AND 6),
department_id BIGINT NOT NULL REFERENCES departments(id),
specialty_code INT NOT NULL REFERENCES specialties(id),
year_start_study BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, course)
VALUES ('ИВТ-21-1', 25, 1, 3),
('ИБ-41м', 15, 2, 2)
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study)
VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025),
('ИБ-41м', 15, 2, 1, 1, 2024)
ON CONFLICT (name) DO NOTHING;
-- ==========================================
@@ -75,23 +105,24 @@ CREATE TABLE IF NOT EXISTS subjects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) UNIQUE NOT NULL,
code VARCHAR(20),
department_id BIGINT NOT NULL REFERENCES departments(id),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO subjects (name) VALUES
('Высшая математика'),
('Философия'),
('Информатика'),
('Базы данных'),
('Английский язык')
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT (name) DO NOTHING;
-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
color_code VARCHAR(7) DEFAULT '#3788d8', -- для цветовой индикации в календаре
color_code VARCHAR(7) DEFAULT '#3788d8',
duration_minutes INT DEFAULT 90
);
@@ -145,7 +176,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
PRIMARY KEY (classroom_id, equipment_id)
);
-- Заполнение привязок оборудования с использованием подзапросов
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
@@ -164,7 +194,6 @@ ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
-- Связи для преподавателей
-- ==========================================
-- Привязка преподавателей к дисциплинам
CREATE TABLE IF NOT EXISTS teacher_subjects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
@@ -173,7 +202,6 @@ CREATE TABLE IF NOT EXISTS teacher_subjects (
PRIMARY KEY(user_id, subject_id)
);
-- Какие типы занятий может вести преподаватель по дисциплине
CREATE TABLE IF NOT EXISTS teacher_lesson_types (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
@@ -205,6 +233,34 @@ INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesso
(2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'),
(1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30');
-- ===============================
-- Создание таблицы данных расписания (schedule_data)
-- ===============================
CREATE TABLE IF NOT EXISTS schedule_data (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
group_id BIGINT NOT NULL REFERENCES student_groups(id),
subjects_id BIGINT NOT NULL REFERENCES subjects(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
number_of_hours INT NOT NULL,
is_division BOOLEAN NOT NULL DEFAULT FALSE,
teacher_id BIGINT NOT NULL REFERENCES users(id),
semester_type VARCHAR(255) NOT NULL,
period VARCHAR(255) NOT NULL
);
INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'),
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'),
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'),
(1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'),
(1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'),
(1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025');
-- ==========================================
-- Функция обновления timestamp
-- ==========================================
@@ -216,7 +272,6 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-- Триггеры для обновления updated_at
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
@@ -227,3 +282,110 @@ CREATE TRIGGER update_users_updated_at
-- ==========================================
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
COMMENT ON TABLE lessons IS 'Основное расписание занятий';
COMMENT ON TABLE departments IS 'Кафедры';
COMMENT ON TABLE specialties IS 'Специальности';
COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания';
COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры';
COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы';
COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета';
COMMENT ON COLUMN schedule_data.lesson_type_id IS 'Идентификатор типа занятия';
COMMENT ON COLUMN schedule_data.number_of_hours IS 'Количество часов';
COMMENT ON COLUMN schedule_data.is_division IS 'Является ли занятие разделенным';
COMMENT ON COLUMN schedule_data.teacher_id IS 'Идентификатор преподавателя';
COMMENT ON COLUMN schedule_data.semester_type IS 'Тип семестра (Весенний, Осенний)';
COMMENT ON COLUMN schedule_data.period IS 'Период занятий (год/год)';
COMMENT ON TABLE education_forms IS 'Формы обучения';
COMMENT ON TABLE subgroups IS 'Подгруппы';
COMMENT ON TABLE lesson_types IS 'Типы занятий';
COMMENT ON TABLE equipments IS 'Оборудование';
COMMENT ON TABLE classrooms IS 'Аудитории';
COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям';
COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам';
COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя';
COMMENT ON COLUMN users.id IS 'ID пользователя';
COMMENT ON COLUMN users.username IS 'Логин пользователя';
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
COMMENT ON COLUMN users.role IS 'Роль пользователя';
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
COMMENT ON COLUMN users.department_id IS 'ID кафедры';
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
COMMENT ON COLUMN education_forms.description IS 'Описание';
COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN student_groups.id IS 'ID учебной группы';
COMMENT ON COLUMN student_groups.name IS 'Название группы';
COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов';
COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа';
COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры';
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';
COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа';
COMMENT ON COLUMN subgroups.name IS 'Название подгруппы';
COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе';
COMMENT ON COLUMN subjects.id IS 'ID предмета';
COMMENT ON COLUMN subjects.name IS 'Название предмета';
COMMENT ON COLUMN subjects.code IS 'Код предмета';
COMMENT ON COLUMN subjects.department_id IS 'ID кафедры';
COMMENT ON COLUMN subjects.description IS 'Описание предмета';
COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания';
COMMENT ON COLUMN lesson_types.id IS 'ID урока';
COMMENT ON COLUMN lesson_types.name IS 'Название типа урока';
COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока';
COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах';
COMMENT ON COLUMN equipments.id IS 'ID оборудования';
COMMENT ON COLUMN equipments.name IS 'Название оборудования';
COMMENT ON COLUMN equipments.description IS 'Описание оборудования';
COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования';
COMMENT ON COLUMN classrooms.id IS 'ID аудитории';
COMMENT ON COLUMN classrooms.name IS 'Название аудитории';
COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории';
COMMENT ON COLUMN classrooms.building IS 'Корпус';
COMMENT ON COLUMN classrooms.floor IS 'Этаж';
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';
COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2
COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи';
COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя';
COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания';
COMMENT ON COLUMN lessons.id IS 'ID урока';
COMMENT ON COLUMN lessons.teacher_id IS 'Идентификатор преподавателя, который проводит урок';
COMMENT ON COLUMN lessons.group_id IS 'ID группы, в которой проходит урок';
COMMENT ON COLUMN lessons.subject_id IS 'ID предмета, который преподается';
COMMENT ON COLUMN lessons.lesson_format IS 'Формат урока';
COMMENT ON COLUMN lessons.type_lesson IS 'Тип урока';
COMMENT ON COLUMN lessons.classroom_id IS 'ID аудитории, в которой проходит урок';
COMMENT ON COLUMN lessons.day IS 'День недели, в который проходит урок';
COMMENT ON COLUMN lessons.week IS 'Номер недели, в которой проходит урок';
COMMENT ON COLUMN lessons.time IS 'Время урока';
COMMENT ON COLUMN departments.id IS 'ID кафедры';
COMMENT ON COLUMN departments.name IS 'Название кафедры';
COMMENT ON COLUMN departments.code IS 'Код кафедры';
COMMENT ON COLUMN specialties.id IS 'ID специальности';
COMMENT ON COLUMN specialties.name IS 'Название специальности';
COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности';
COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя';
COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета';
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';

View File

@@ -8,6 +8,9 @@ services:
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
depends_on:
db:
condition: service_healthy
networks:
- proxy
@@ -32,6 +35,11 @@ services:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: supersecretpassword
POSTGRES_DB: app_db
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
interval: 5s
timeout: 5s
retries: 5
networks:
- proxy

482
docs/API.md Normal file
View File

@@ -0,0 +1,482 @@
# 🔌 REST API
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
---
## Аутентификация
### `POST /api/auth/login`
Вход в систему.
**Тело запроса:**
```json
{
"username": "admin",
"password": "admin"
}
```
**Успешный ответ (200):**
```json
{
"success": true,
"message": "OK",
"token": "550e8400-e29b-41d4-a716-446655440000",
"role": "ADMIN",
"redirect": "/admin/"
}
```
**Ошибка (401):**
```json
{
"success": false,
"message": "Неверное имя пользователя или пароль",
"token": null,
"role": null,
"redirect": null
}
```
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
---
## Пользователи
### `GET /api/users`
Список всех пользователей.
**Ответ:**
```json
[
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
]
```
### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
### `POST /api/users`
Создание пользователя.
**Тело запроса:**
```json
{
"username": "teacher1",
"password": "password",
"role": "TEACHER",
"fullName": "Test Teacher",
"jobTitle": "Proffessor",
"departmentId": 1
}
```
**Валидация:**
- `username` — обязателен и уникален
- `password` — минимум 4 символа
- `role``ADMIN`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
### `DELETE /api/users/{id}`
Удаление пользователя.
---
## Расписание (Lessons)
### `GET /api/users/lessons`
Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория).
**Ответ:**
```json
[
{
"id": 1,
"teacherName": "Тестовый преподаватель",
"groupName": "ИВТ-21-1",
"classroomName": "101 Ленинская",
"educationFormName": "Бакалавриат",
"subjectName": "Высшая математика",
"typeLesson": "Лекция",
"lessonFormat": "Очно",
"day": "Понедельник",
"week": "Верхняя",
"time": "11:40 - 13:10"
}
]
```
### `GET /api/users/lessons/{teacherId}`
Занятия конкретного преподавателя.
### `POST /api/users/lessons/create`
Создание занятия.
**Тело запроса:**
```json
{
"teacherId": 2,
"groupId": 1,
"subjectId": 1,
"lessonFormat": "Очно",
"typeLesson": "Лекция",
"classroomId": 1,
"day": "Понедельник",
"week": "Верхняя",
"time": "11:40 - 13:10"
}
```
**Валидация:**
| Поле | Правило |
|------|---------|
| `teacherId` | Обязателен, ≠ 0 |
| `groupId` | Обязателен, ≠ 0 |
| `subjectId` | Обязателен, ≠ 0 |
| `lessonFormat` | `Очно` или `Онлайн` |
| `typeLesson` | `Лекция`, `Практическая работа`, `Лабораторная работа` |
| `classroomId` | Обязателен, ≠ 0 |
| `day` | Пн–Сб (на русском) |
| `week` | `Верхняя`, `Нижняя`, `Обе` |
| `time` | Обязателен |
### `PUT /api/users/lessons/update/{lessonId}`
Обновление занятия. Поддерживает partial update — передаются только изменённые поля.
**Тело ответа:**
```json
{
"id": 5,
"teacherId": 1,
"groupId": 1,
"subjectId": 2,
"LessonFormat": "Онлайн",
"typeLesson": "Практическая работа",
"classroomId": 3,
"day": "Понедельник",
"week": "Верхняя",
"time": "9:40 - 11:10",
"updatedFields": {
"teacherId": 1,
"subjectId": 2,
"lessonFormat": "Онлайн",
"classroomId": 3,
"day": "Понедельник",
"time": "9:40 - 11:10"
},
"message": "Занятие успешно обновлено"
}
```
### `DELETE /api/users/lessons/delete/{lessonId}`
Удаление занятия.
### `GET /api/users/lessons/ping`
Проверка доступности контроллера. Возвращает строку `pong`.
---
## Группы
### `GET /api/groups`
Список всех групп.
**Ответ:**
```json
[
{
"id": 1,
"name": "ИВТ-21-1",
"groupSize": 25,
"educationFormId": 1,
"educationFormName": "Бакалавриат",
"departmentId": 1,
"course": 3,
"specialityCode": 1
}
]
```
### `GET /api/groups/{departmentId}`
Список всех групп привязанных к конкретной кафедре.
### `POST /api/groups`
Создание группы.
```json
{
"name": "ИВТ-11",
"groupSize": 12,
"educationFormId": 1,
"departmentId": 1,
"course": 2,
"specialityCode": 1
}
```
### `DELETE /api/groups/{id}`
Удаление группы.
---
## Аудитории
### `GET /api/classrooms`
Список аудиторий с привязанным оборудованием.
**Ответ:**
```json
[
{
"id": 1,
"name": "101 Ленинская",
"capacity": 120,
"isAvailable": true,
"equipments": [
{ "id": 1, "name": "Проектор" },
{ "id": 4, "name": "Интерактивная доска" }
]
}
]
```
### `POST /api/classrooms`
Создание аудитории.
```json
{
"name": "404 Лаборатория",
"capacity": 30,
"isAvailable": true,
"equipmentIds": [1, 2, 3]
}
```
### `PUT /api/classrooms/{id}`
Обновление аудитории (partial update).
### `DELETE /api/classrooms/{id}`
Удаление аудитории.
---
## Дисциплины
### `GET /api/subjects`
Список всех дисциплин.
```json
{
"name": "Физика",
"code": null,
"departmentId": 1
}
```
### `GET /api/subjects/{departmentId}`
Список всех дисциплин привязанных к кафедре.
### `POST /api/subjects`
```json
{
"name": "Физика",
"code": null,
"departmentId": 1
}
```
### `DELETE /api/subjects/{id}`
Удаление дисциплины.
---
## Оборудование
### `GET /api/equipments`
Список всего оборудования.
### `POST /api/equipments`
```json
{ "name": "3D-принтер" }
```
### `DELETE /api/equipments/{id}`
Удаление оборудования.
---
## Формы обучения
### `GET /api/education-forms`
Список форм обучения.
**Ответ:**
```json
[
{ "id": 1, "name": "Бакалавриат" },
{ "id": 2, "name": "Магистратура" }
]
```
### `POST /api/education-forms`
```json
{ "name": "Аспирантура" }
```
### `DELETE /api/education-forms/{id}`
Удаление формы обучения. **Невозможно**, если к ней привязаны группы.
---
## Привязка «Преподаватель ↔ Дисциплина»
### `GET /api/teacher-subjects`
Список всех привязок.
**Ответ:**
```json
[
{
"userId": 2,
"userName": "Тестовый преподаватель",
"subjectId": 1,
"subjectName": "Высшая математика"
}
]
```
### `POST /api/teacher-subjects`
```json
{
"userId": 2,
"subjectId": 3
}
```
### `DELETE /api/teacher-subjects`
```json
{
"userId": 2,
"subjectId": 3
}
```
---
## Управление тенантами (Базы данных)
### `GET /api/database/status`
Статус текущего подключения (определяется по домену запроса).
**Ответ:**
```json
{
"tenant": "default",
"connected": true,
"configured": true,
"name": "Default",
"url": "jdbc:postgresql://db:5432/app_db"
}
```
### `GET /api/database/tenants`
Список всех тенантов.
### `POST /api/database/tenants`
Добавление нового тенанта.
```json
{
"name": "СВФУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
```
**Логика:**
1. Создаёт HikariCP пул для нового тенанта
2. Запускает Flyway миграции на его БД
3. Обновляет Kubernetes ConfigMap
### `DELETE /api/database/tenants/{domain}`
Удаление тенанта.
### `POST /api/database/test`
Тест подключения к произвольной БД (без регистрации тенанта).
```json
{
"url": "jdbc:postgresql://host:5432/testdb",
"username": "user",
"password": "pass"
}
```
**Ответ:**
```json
{
"success": true,
"message": "Подключение успешно!"
}
```
---
## Коды ответов
| Код | Описание |
|-----|----------|
| `200` | Успех |
| `400` | Ошибка валидации (с `message` в теле) |
| `401` | Неверные учётные данные |
| `404` | Ресурс / тенант не найден |
| `500` | Внутренняя ошибка сервера |

142
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,142 @@
# 🏗 Архитектура системы
## Общая схема
```mermaid
graph TD
Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"]
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd:alpine)"]
Caddy -->|/api/*| Backend["Backend<br/>(Spring Boot 3.2.5)"]
Backend --> TenantRouter{"TenantRoutingDataSource"}
TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL<br/>swsu_db"]
TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL<br/>mgu_db"]
TenantRouter -->|...| DBn["PostgreSQL<br/>tenant_n_db"]
Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"]
OTel --> SigNoz["SigNoz"]
```
## Компоненты
### Frontend (Apache httpd:alpine)
- **Тип:** Статические файлы (HTML/CSS/JS)
- **Контейнер:** `httpd:alpine` — лёгкий Apache HTTP Server
- **Порт:** 80
- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/`
- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`)
### Backend (Spring Boot 3.2.5)
- **Тип:** REST API сервер
- **Язык:** Java 17
- **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта)
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), UUID-токены
### PostgreSQL
- **Версия:** `postgres:alpine3.23`
- **Локально:** Одна БД `app_db` (тенант `default`)
- **Продакшн:** Множество БД, по одной на каждый университет (тенант)
### Caddy (реверс-прокси)
- **Расположение:** `../caddy-proxy/`
- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend
- **Домен:** `*.zuev.company`
---
## Мультитенантная архитектура
Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL.
### Принцип работы
```mermaid
sequenceDiagram
participant Browser as Браузер
participant Interceptor as TenantInterceptor
participant Context as TenantContext
participant Router as TenantRoutingDataSource
participant DB as PostgreSQL
Browser->>Interceptor: GET /api/users<br/>Host: swsu.zuev.company
Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu"
Interceptor->>Context: setCurrentTenant("swsu")
Note over Interceptor: Проверка: hasTenant("swsu")?
Interceptor-->>Browser: 404 если тенант не найден
Note over Context,Router: Обработка запроса контроллером
Router->>Router: determineCurrentLookupKey() → "swsu"
Router->>DB: SQL запрос к swsu_db
DB-->>Browser: Ответ с данными
```
### Ключевые классы
| Класс | Назначение |
|-------|-----------|
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
Логика определения тенанта по заголовку `Host`:
| Host | Результат |
|------|----------|
| `swsu.zuev.company` | `swsu` |
| `mgu.zuev.company` | `mgu` |
| `localhost` | `default` |
| `localhost:8080` | `default` |
| `192.168.1.1` | `default` |
### Конфигурация тенантов
Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json`
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
Формат:
```json
[
{
"name": "ЮЗГУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
]
```
### Жизненный цикл тенанта
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
### Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта:
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
---
## Аутентификация
Система использует **простую модель аутентификации** без JWT или Spring Security фильтров:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
2. Backend проверяет пароль через `BCryptPasswordEncoder`
3. При успехе возвращается:
- UUID-токен (для заголовка `Authorization: Bearer`)
- Роль пользователя (`ADMIN`, `TEACHER`, `STUDENT`)
- Redirect URL (`/admin/`, `/teacher/`, `/student/`)
4. Токен хранится в `localStorage` на клиенте

149
docs/BUSINESS_LOGIC.md Normal file
View File

@@ -0,0 +1,149 @@
# 📋 Бизнес-логика
## Ролевая модель
Система поддерживает три роли пользователей:
| Роль | Enum | Возможности |
|------|------|------------|
| **Администратор** (Деканат) | `ADMIN` | Полный доступ: CRUD пользователей, групп, аудиторий, дисциплин, расписания. Управление тенантами (БД). |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
После авторизации пользователь перенаправляется на свой интерфейс:
- `ADMIN``/admin/`
- `TEACHER``/teacher/`
- `STUDENT``/student/`
---
## Управление ресурсами
### Кафедры (Departments)
Организационные единицы университета. К кафедре привязываются пользователи, группы и дисциплины.
- Имеют уникальный числовой `code`
- Предзаполнены: «Кафедра ИБ», «Кафедра ВТ», «Кафедра КТ»
### Специальности (Specialties)
Учебные направления с кодом по ФГОС.
- Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04)
### Формы обучения (Education Forms)
Уровни/формы обучения для привязки к группам.
- Предзаполнены: Бакалавриат, Магистратура, Специалитет
- Нельзя удалить форму обучения, если к ней привязаны группы
### Учебные группы (Student Groups)
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (16)
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
### Аудитории (Classrooms)
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
- **Статус:** Флаг `is_available` для блокирования назначения пар
### Оборудование (Equipments)
Каталог оборудования для привязки к аудиториям.
- Предзаполнены: Проектор, ПК, Лаборатория, Интерактивная доска, Документ-камера, Аудиосистема
- Уникальность по названию
### Дисциплины (Subjects)
- **Поля:** Название (уникальное), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
---
## Логика расписания
### Сущность «Занятие» (Lesson)
Каждая запись в расписании содержит:
| Поле | Описание | Пример |
|------|----------|--------|
| `teacher_id` | Преподаватель | 2 |
| `group_id` | Учебная группа | 1 |
| `subject_id` | Дисциплина | 3 |
| `lesson_format` | Формат проведения | `Очно`, `Онлайн` |
| `type_lesson` | Тип занятия | `Лекция`, `Практическая работа`, `Лабораторная работа` |
| `classroom_id` | Аудитория | 1 |
| `day` | День недели | `Понедельник` ... `Суббота` |
| `week` | Чётность недели | `Верхняя`, `Нижняя`, `Обе` |
| `time` | Временной слот | `8:00 - 9:30` |
### Временны́е слоты
Система использует 7 фиксированных слотов по 90 минут:
| № | Время |
|---|-------|
| 1 | 08:00 09:30 |
| 2 | 09:40 11:10 |
| 3 | 11:40 13:10 |
| 4 | 13:30 15:00 |
| 5 | 15:00 16:30 |
| 6 | 16:40 18:10 |
| 7 | 18:30 20:00 |
### Валидация при создании/обновлении
- **Дни:** только `Понедельник` `Суббота` (`DayAndWeekValidator`)
- **Недели:** только `Верхняя`, `Нижняя`, `Обе`
- **Формат:** только `Очно`, `Онлайн` (`TypeAndFormatLessonValidator`)
- **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа`
- Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0
### Данные к составлению расписания (Schedule Data)
Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания:
| Поле | Описание |
|------|----------|
| `department_id` | Кафедра |
| `semester` | Номер семестра |
| `group_id` | Учебная группа |
| `subjects_id` | Дисциплина |
| `lesson_type_id` | Тип занятия |
| `number_of_hours` | Количество часов |
| `is_division` | Деление на подгруппы |
| `teacher_id` | Преподаватель |
| `semester_type` | Тип семестра (Весенний / Осенний) |
| `period` | Учебный год (напр. `2024/2025`) |
---
## Привязка преподаватель ↔ дисциплина
Связь Many-to-Many через таблицу `teacher_subjects`:
- Указывается, какие дисциплины может вести конкретный преподаватель
- Дополнительные поля: `qualification_level`, `experience_years`
Дополнительная связь через `teacher_lesson_types`:
- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине
---
## Бизнес-правила (планируемые)
> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.
### Проверка конфликтов
- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях
- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
### Управление инцидентами
- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
- Автоматическая подсветка конфликтующих пар (Red Zone)
- Resolution Wizard: предложение замены преподавателя или переноса занятия

362
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,362 @@
# 🗄 База данных
## Общая информация
- **СУБД:** PostgreSQL (локально `postgres:alpine3.23`, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей)
- **Мультитенантность:** Каждый тенант = отдельная БД
---
## ER-диаграмма
```mermaid
erDiagram
departments {
BIGSERIAL id PK
VARCHAR name
BIGINT code UK
}
specialties {
BIGSERIAL id PK
VARCHAR name
VARCHAR specialty_code
}
users {
BIGSERIAL id PK
VARCHAR username UK
VARCHAR password
VARCHAR role
VARCHAR full_name
VARCHAR job_title
BIGINT department_id FK
TIMESTAMP created_at
TIMESTAMP updated_at
}
education_forms {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
TIMESTAMP created_at
}
student_groups {
BIGSERIAL id PK
VARCHAR name UK
BIGINT group_size
BIGINT education_form_id FK
BIGINT department_id FK
INT course
TIMESTAMP created_at
}
subgroups {
BIGSERIAL id PK
BIGINT group_id FK
VARCHAR name
INT student_capacity
}
subjects {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR code
BIGINT department_id FK
TEXT description
TIMESTAMP created_at
}
lesson_types {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR color_code
INT duration_minutes
}
equipments {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
VARCHAR inventory_number
}
classrooms {
BIGSERIAL id PK
VARCHAR name UK
INT capacity
VARCHAR building
INT floor
BOOLEAN is_available
TEXT description
TIMESTAMP created_at
}
classroom_equipments {
BIGINT classroom_id FK,PK
BIGINT equipment_id FK,PK
INT quantity
TEXT notes
}
teacher_subjects {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
VARCHAR qualification_level
INT experience_years
}
teacher_lesson_types {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
BIGINT lesson_type_id FK,PK
}
lessons {
BIGSERIAL id PK
BIGINT teacher_id FK
BIGINT group_id FK
BIGINT subject_id FK
VARCHAR lesson_format
VARCHAR type_lesson
BIGINT classroom_id FK
VARCHAR day
VARCHAR week
VARCHAR time
}
schedule_data {
BIGSERIAL id PK
BIGINT department_id FK
INT semester
BIGINT group_id FK
BIGINT subjects_id FK
BIGINT lesson_type_id FK
INT number_of_hours
BOOLEAN is_division
BIGINT teacher_id FK
VARCHAR semester_type
VARCHAR period
}
departments ||--o{ users : "department_id"
departments ||--o{ student_groups : "department_id"
departments ||--o{ subjects : "department_id"
departments ||--o{ schedule_data : "department_id"
education_forms ||--o{ student_groups : "education_form_id"
student_groups ||--o{ subgroups : "group_id"
student_groups ||--o{ lessons : "group_id"
student_groups ||--o{ schedule_data : "group_id"
users ||--o{ lessons : "teacher_id"
users ||--o{ teacher_subjects : "user_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ schedule_data : "teacher_id"
subjects ||--o{ lessons : "subject_id"
subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ schedule_data : "subjects_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_data : "lesson_type_id"
classrooms ||--o{ lessons : "classroom_id"
classrooms ||--o{ classroom_equipments : "classroom_id"
equipments ||--o{ classroom_equipments : "equipment_id"
```
---
## Описание таблиц
### Справочники высшего уровня
#### `departments` — Кафедры
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID кафедры |
| `name` | VARCHAR(255) | Название кафедры |
| `code` | BIGINT UNIQUE | Код кафедры |
#### `specialties` — Специальности
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID специальности |
| `name` | VARCHAR(255) | Название специальности |
| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) |
### Пользователи
#### `users` — Пользователи системы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID пользователя |
| `username` | VARCHAR(50) UNIQUE | Логин |
| `password` | VARCHAR(255) | bcrypt-хеш пароля |
| `role` | VARCHAR(20) | `ADMIN`, `TEACHER`, `STUDENT` |
| `full_name` | VARCHAR(255) | ФИО |
| `job_title` | VARCHAR(255) | Должность |
| `department_id` | BIGINT FK → departments | Кафедра |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления (авто-триггер) |
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
### Учебный процесс
#### `education_forms` — Формы обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) |
| `description` | TEXT | Описание |
#### `student_groups` — Учебные группы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) |
| `group_size` | BIGINT | Количество студентов |
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
| `department_id` | BIGINT FK → departments | Кафедра |
| `course` | INT CHECK(16) | Курс |
#### `subgroups` — Подгруппы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов |
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(200) UNIQUE | Название |
| `code` | VARCHAR(20) | Код предмета |
| `department_id` | BIGINT FK → departments | Кафедра |
| `description` | TEXT | Описание |
### Аудиторный фонд
#### `classrooms` — Аудитории
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название (напр. `101 Ленинская`) |
| `capacity` | INT CHECK(> 0) | Вместимость |
| `building` | VARCHAR(50) | Корпус |
| `floor` | INT | Этаж |
| `is_available` | BOOLEAN | Доступна для назначения пар |
| `description` | TEXT | Описание |
#### `equipments` — Оборудование
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название |
| `description` | TEXT | Описание |
| `inventory_number` | VARCHAR(50) | Инвентарный номер |
#### `classroom_equipments` — Привязка оборудования к аудиториям
| Колонка | Тип | Описание |
|---------|-----|----------|
| `classroom_id` | BIGINT PK, FK → classrooms (CASCADE) | Аудитория |
| `equipment_id` | BIGINT PK, FK → equipments (CASCADE) | Оборудование |
| `quantity` | INT CHECK(> 0) | Количество единиц |
| `notes` | TEXT | Примечания |
### Расписание
#### `lessons` — Основное расписание занятий
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `group_id` | BIGINT FK → student_groups | Группа |
| `subject_id` | BIGINT FK → subjects | Дисциплина |
| `lesson_format` | VARCHAR(255) | `Очно` / `Онлайн` |
| `type_lesson` | VARCHAR(255) | `Лекция` / `Практическая работа` / `Лабораторная работа` |
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `day` | VARCHAR(255) | День недели |
| `week` | VARCHAR(255) | `Верхняя` / `Нижняя` / `Обе` |
| `time` | VARCHAR(255) | Временной слот |
#### `lesson_types` — Типы занятий (справочник)
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название типа |
| `color_code` | VARCHAR(7) | HEX-цвет для UI (напр. `#FF6B6B`) |
| `duration_minutes` | INT | Длительность (по умолчанию 90) |
### Связи «Преподаватель ↔ Дисциплина»
#### `teacher_subjects` — Квалификация преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `qualification_level` | VARCHAR(50) | Уровень квалификации |
| `experience_years` | INT | Стаж |
#### `teacher_lesson_types` — Типы занятий преподавателя
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
#### `schedule_data` — Данные к составлению расписания
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `department_id` | BIGINT FK → departments | Кафедра |
| `semester` | INT | Номер семестра |
| `group_id` | BIGINT FK → student_groups | Группа |
| `subjects_id` | BIGINT FK → subjects | Дисциплина |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `number_of_hours` | INT | Количество часов |
| `is_division` | BOOLEAN | Деление на подгруппы |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `semester_type` | VARCHAR(255) | Весенний / Осенний |
| `period` | VARCHAR(255) | Учебный год |
---
## Flyway миграции
### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии |
### Накатывание на существующих тенантов
Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend:
```bash
# Kubernetes
kubectl rollout restart deployment backend -n magistr
# Docker Compose (локально)
docker compose restart backend
```
### Полный сброс БД (локально)
```bash
docker compose down -v # Удаляет volumes (данные)
docker compose up -d # Пересоздаёт БД с нуля
```

275
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,275 @@
# 🛠 Руководство для разработчиков
## Локальный запуск
### Предварительные требования
- Docker и Docker Compose
- Git
- (Опционально) Java 17 + Maven 3.9+ для запуска backend вне Docker
### Первый запуск
```bash
# Создать Docker-сеть
docker network create proxy
# Собрать и запустить
docker compose up -d --build
# Убедиться, что всё работает
docker compose logs -f
```
Приложение доступно: **http://localhost:80**
### Пересборка после изменений
```bash
# Пересобрать только backend
docker compose up -d --build backend
# Пересобрать только frontend
docker compose up -d --build frontend
```
### Полный сброс данных
```bash
docker compose down -v # Удаляет БД
docker compose up -d # Пересоздаёт с нуля
```
---
## Соглашения о коде
### Java (Backend)
#### Именование
| Категория | Стиль | Пример |
|-----------|-------|--------|
| Классы | PascalCase | `LessonsController`, `LessonResponse` |
| Методы и переменные | camelCase | `getAllLessons()`, `teacherId` |
| Константы | UPPER_SNAKE_CASE | `ROLE_REDIRECTS` |
| Пакеты | lowercase | `com.magistr.app.controller` |
#### Архитектурные правила
- **Constructor Injection** — все зависимости через конструктор (не `@Autowired` на поля)
- **Controller → Repository** — контроллеры работают напрямую с репозиториями (без слоя service)
- **Префикс `/api/`** — все REST-эндпоинты
- **`ResponseEntity<?>`** — все мутирующие методы возвращают `ResponseEntity` с HTTP-статусом
- **Сообщения на русском** — все ошибки и уведомления на русском языке
#### Логирование
Используйте SLF4J:
```java
private static final Logger logger = LoggerFactory.getLogger(MyController.class);
// Информационные сообщения
logger.info("Запрос на получение всех занятий");
// Ошибки с полным стектрейсом
logger.error("Ошибка при сохранении: {}", e.getMessage(), e);
```
#### Валидация
- Для сложных правил — отдельные классы-валидаторы (`DayAndWeekValidator`, `TypeAndFormatLessonValidator`)
- Для простых — inline-проверки в контроллере с `ResponseEntity.badRequest()`
#### Импорты
```java
// 1. Static imports
import static org.junit.Assert.*;
// 2. Java/Jakarta
import java.util.*;
import jakarta.persistence.*;
// 3. External libraries
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
// 4. Internal packages (wildcard для того же модуля)
import com.magistr.app.model.*;
import com.magistr.app.repository.*;
```
#### Форматирование
- **Отступы:** 4 пробела
- **Скобки:** K&R style (открывающая на той же строке)
- **Длина строки:** до 120 символов
- **Фигурные скобки** обязательны для `if`/`for`/`while`
### JavaScript (Frontend)
#### Именование
| Категория | Стиль | Пример |
|-----------|-------|--------|
| Файлы | kebab-case | `main.js`, `schedule-view.js` |
| Функции и переменные | camelCase | `loadUsers()`, `pageTitle` |
| Константы | UPPER_SNAKE_CASE | `API_BASE_URL` |
#### Модули
- ES6 Modules с `import`/`export`
- **Всегда указывать расширение:** `import { api } from './api.js';`
#### Лучшие практики
```javascript
// ✅ Предпочитайте const
const token = localStorage.getItem('token');
// ✅ Async/await вместо .then()
async function loadData() {
try {
const data = await api.get('/api/users');
} catch (e) {
console.error('Ошибка:', e.message);
}
}
// ✅ Template literals
const msg = `Найдено ${items.length} записей`;
// ✅ Деструктуризация
const { id, name, role } = user;
```
#### Форматирование
- **Отступы:** 4 пробела
- **Кавычки:** одинарные `'`
- **Точки с запятой:** обязательны
---
## Создание нового эндпоинта (пошагово)
### 1. Модель (если нужна новая таблица)
Создайте Flyway миграцию `V{N}__{description}.sql`:
```sql
-- backend/src/main/resources/db/migration/V3__add_absences.sql
CREATE TABLE IF NOT EXISTS absences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
reason VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL
);
```
Создайте JPA-сущность:
```java
@Entity
@Table(name = "absences")
public class Absence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
```
### 2. Репозиторий
```java
public interface AbsenceRepository extends JpaRepository<Absence, Long> {
List<Absence> findByTeacherId(Long teacherId);
}
```
### 3. DTO (опционально)
```java
public record AbsenceResponse(Long id, String teacherName, String reason) {}
```
### 4. Контроллер
```java
@RestController
@RequestMapping("/api/absences")
public class AbsenceController {
private final AbsenceRepository absenceRepository;
public AbsenceController(AbsenceRepository absenceRepository) {
this.absenceRepository = absenceRepository;
}
@GetMapping
public List<Absence> getAll() {
return absenceRepository.findAll();
}
}
```
---
## Работа с миграциями Flyway
### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения
### Применение
```bash
# Локально — сброс и повтор всех миграций
docker compose down -v && docker compose up -d
# Продакшн — применить к существующим тенантам
kubectl rollout restart deployment backend -n magistr
```
---
## Структура пакетов (Backend)
```
com.magistr.app/
├── Application.java # Точка входа
├── config/
│ ├── AppConfig.java # Бины (BCryptPasswordEncoder)
│ ├── DataInitializer.java # Инициализация данных
│ └── tenant/ # Мультитенантность
│ ├── TenantConfig.java # POJO конфигурации тенанта
│ ├── TenantContext.java # ThreadLocal текущего тенанта
│ ├── TenantInterceptor.java # Определение тенанта из Host
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
│ ├── TenantDataSourceConfig.java # Spring-конфигурация
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
├── controller/ # REST-контроллеры
│ ├── AuthController.java
│ ├── LessonsController.java
│ ├── ClassroomController.java
│ ├── DatabaseController.java
│ ├── UserController.java
│ ├── GroupController.java
│ ├── SubjectController.java
│ ├── EquipmentController.java
│ ├── EducationFormController.java
│ └── TeacherSubjectController.java
├── dto/ # Data Transfer Objects
├── model/ # JPA-сущности
├── repository/ # Spring Data JPA
└── utils/ # Валидаторы
├── DayAndWeekValidator.java
└── TypeAndFormatLessonValidator.java
```

256
docs/FRONTEND.md Normal file
View File

@@ -0,0 +1,256 @@
# 🎨 Frontend
## Общая информация
| Параметр | Значение |
|----------|----------|
| **Фреймворк** | Нет (Vanilla JavaScript) |
| **Модульная система** | ES6 Modules (`import`/`export`) |
| **Стили** | CSS (модульный подход) |
| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) |
| **Веб-сервер** | Apache httpd:alpine |
---
## Структура файлов
```
frontend/
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
├── theme-toggle.js # Переключение светлой/тёмной темы
├── Dockerfile # httpd:alpine
├── admin/ # 👨‍💼 Интерфейс администратора
│ ├── index.html # SPA-оболочка с sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, цвета, типографика
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна
│ │ ├── department.css # Стили кафедры
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
│ │ ├── users.js # Управление пользователями
│ │ ├── groups.js # Управление группами
│ │ ├── classrooms.js # Управление аудиториями
│ │ ├── subjects.js # Управление дисциплинами
│ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения
│ │ ├── schedule.js # Расписание занятий
│ │ ├── database.js # Управление тенантами
│ │ ├── department.js # Кафедры
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений
│ │ ├── users.html
│ │ ├── groups.html
│ │ ├── classrooms.html
│ │ ├── subjects.html
│ │ ├── equipments.html
│ │ ├── edu-forms.html
│ │ ├── schedule.html
│ │ ├── database.html
│ │ ├── department.html
│ │ └── departments-data.html
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, базовые стили
│ │ └── layout.css # Sidebar, topbar, content
│ ├── js/
│ │ └── main.js # Навигация по вкладкам настроек
│ └── views/
│ └── general.html # Общие настройки (заглушка)
├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ └── index.html # Просмотр расписания
└── student/ # 🎓 Интерфейс студента
└── index.html # Просмотр расписания (read-only)
```
---
## Система маршрутизации (Admin SPA)
Админ-панель работает как **Single Page Application** без фреймворка.
Навигация реализована через `data-tab` атрибуты на элементах sidebar:
```html
<a href="#" class="nav-item" data-tab="users">Пользователи</a>
<a href="#" class="nav-item" data-tab="groups">Группы</a>
<a href="#" class="nav-item" data-tab="schedule">Расписание занятий</a>
```
При клике на пункт меню `main.js`:
1. Загружает HTML-шаблон из `views/{tab}.html` через `fetch()`
2. Вставляет его в `#app-content`
3. Подключает соответствующий JS-модуль из `js/views/{tab}.js`
4. Обновляет заголовок страницы (`#page-title`)
### Разделы админ-панели
| Tab | Описание | API |
|-----|----------|-----|
| `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп | `/api/groups` |
| `edu-forms` | Формы обучения | `/api/education-forms` |
| `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
| `schedule` | Расписание | `/api/users/lessons` |
| `database` | Тенанты | `/api/database` |
| `department` | Кафедры | `/api/departments` |
| `departments-data` | Создание кафедры/специальности | `/api/departments` |
### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
- Доступ: через dropdown «Настройки» в footer боковой панели админки
- Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки:
- **Общие настройки** — заглушка (в разработке)
---
## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`:
```javascript
export async function apiFetch(endpoint, method = 'GET', body = null) {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
});
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return await response.json();
}
// Shortcut-методы
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
put: (url, body) => apiFetch(url, 'PUT', body),
delete: (url, body) => apiFetch(url, 'DELETE', body)
};
```
Токен берётся из `localStorage.getItem('token')`.
---
## Аутентификация (Frontend)
### Страница входа (`/index.html`)
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`:
- `token` — UUID-токен
- `role` — роль пользователя
4. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/`
- `TEACHER``/teacher/`
- `STUDENT``/student/`
### Проверка авторизации
На каждой странице проверяется наличие токена и роли:
```javascript
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return token && role === 'ADMIN';
}
```
### Выход
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
---
## CSS-архитектура
### Модульный подход
Стили разделены на модульные файлы (порядок подключения важен):
1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема
2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive
3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle
4. **`modals.css`** — Модальные окна
5. **`department.css`** — Стили страницы кафедр
6. **`departments-data.css`** — Стили создания кафедры/специальности
### Темизация
CSS-переменные позволяют поддерживать светлую/тёмную тему:
```css
:root {
--bg-primary: #ffffff;
--text-primary: #1a1a2e;
--accent: #6366f1;
}
[data-theme="dark"] {
--bg-primary: #0f0f23;
--text-primary: #e2e8f0;
--accent: #818cf8;
}
```
Переключение — через `theme-toggle.js`.
---
## Боковая панель (Sidebar)
- **Скрытие/раскрытие** — кнопка-крестик в правом верхнем углу sidebar
- **Десктоп** (`>768px`): sidebar складывается влево, контент расширяется; состояние сохраняется в `localStorage` (`sidebar-collapsed`)
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
---
## OpenTelemetry (`otel.js`)
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`.
- **На production** — загружается автоматически через динамический `import()`
- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh`
```javascript
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
```
---
## Адаптивность
Интерфейс адаптирован под мобильные устройства:
- Sidebar скрывается на экранах < 768px, выезжает как overlay
- Появляется кнопка-гамбургер (`#menu-toggle`)
- Кнопка-крестик закрывает sidebar на всех устройствах
- Таблицы получают горизонтальный скролл

137
docs/INFRASTRUCTURE.md Normal file
View File

@@ -0,0 +1,137 @@
# 🏭 Инфраструктура
## Docker Compose (локальная разработка)
### Сервисы
```yaml
services:
backend: # Spring Boot (Java 17), порт 8080
frontend: # Apache httpd:alpine, порт 80
db: # PostgreSQL alpine3.23, порт 5432
```
### Сеть
Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском:
```bash
docker network create proxy
```
### Переменные окружения
Файл `.env` в корне проекта:
```env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword
```
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3-eclipse-temurin-17-alpine``mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine``java -jar app.jar`
### Dockerfile (Frontend)
```dockerfile
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
```
---
## Kubernetes (продакшн)
### Расположение конфигурации
Файлы Kubernetes манифестов: `../k8s/`
### Ключевые ресурсы
| Ресурс | Тип | Описание |
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов |
### ConfigMap для тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
При добавлении тенанта через API:
1. `DatabaseController` обновляет in-memory DataSource
2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API
3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек)
### Обновление backend
```bash
kubectl rollout restart deployment backend -n magistr
```
---
## Caddy (реверс-прокси)
**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис
В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`:
- Автоматическое получение TLS-сертификатов (Let's Encrypt)
- Маршрутизация `/api/*` → backend:8080
- Маршрутизация статики → frontend:80
---
## CI/CD (Gitea Actions)
### Пайплайн сборки Docker-образов
Расположение: `.gitea/workflows/docker-build.yaml`
Основные шаги:
1. Checkout кода
2. Login в Docker Registry
3. Build + Push образов (`backend`, `frontend`)
4. Генерация меток через `docker/metadata-action`
---
## Мониторинг (SigNoz + OpenTelemetry)
### Архитектура мониторинга
```mermaid
graph LR
Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector
Collector --> SigNoz["SigNoz"]
Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"]
```
### Интеграция Backend
Backend отправляет через OpenTelemetry:
- **Логи** — через Logback + OTLP exporter
- **Трейсы** — автоинструментация Spring Boot
- **Метрики** — JVM метрики, HTTP метрики
Tenant ID добавляется в:
- MDC (логи): `MDC.put("tenant.id", tenant)`
- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)`
### Интеграция Frontend
Файл `admin/js/otel.js` — клиентская телеметрия:
- Метрики производительности страниц
- Трейсы пользовательских действий
### Дашборды SigNoz
- JVM Dashboard (Heap, GC, Threads)
- PostgreSQL Dashboard (Connections, Queries)
- HTTP Dashboard (Requests, Latency, Errors)

167
docs/LOGGING.md Normal file
View File

@@ -0,0 +1,167 @@
# 📋 Логирование
## Стек технологий
| Компонент | Технология |
|-----------|------------|
| Фасад | SLF4J (`org.slf4j.Logger`) |
| Реализация | Logback (поставляется с `spring-boot-starter-web`) |
| Конфигурация | Стандартная Spring Boot (без кастомного `logback.xml`) |
| Экспорт (прод) | OpenTelemetry Java Agent → OTLP → SigNoz |
| Контекст тенанта | SLF4J MDC (`tenant.id`) |
---
## Архитектура
```mermaid
graph LR
Code["Java-код<br/>log.info(...)"] --> SLF4J["SLF4J API"]
SLF4J --> Logback["Logback"]
Logback -->|"Локальная разработка"| Console["stdout / stderr"]
Logback -->|"Продакшн"| OTelAgent["OTel Java Agent<br/>(Logback Appender)"]
OTelAgent -->|"OTLP HTTP"| SigNoz["SigNoz"]
```
### Локальная разработка
Логи выводятся в `stdout` контейнера в стандартном формате Spring Boot:
```
2026-03-22 12:00:00.123 INFO 1 --- [main] c.m.app.config.DataInitializer : Initializing databases for 1 tenant(s)...
```
Просмотр логов:
```bash
docker compose logs -f backend
```
### Продакшн (Kubernetes)
OpenTelemetry Java Agent подключается как `-javaagent` в [Dockerfile](file:///mnt/HDD/magistr/magistr/backend/Dockerfile) и автоматически перехватывает логи Logback, экспортируя их в SigNoz по OTLP.
```dockerfile
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]
```
Конфигурация агента задаётся через переменные окружения в [backend.yaml](file:///mnt/HDD/magistr/k8s/backend.yaml):
| Переменная | Значение | Назначение |
|------------|----------|------------|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://192.168.1.100:4318` | Адрес SigNoz Collector |
| `OTEL_SERVICE_NAME` | `magistr-backend` | Имя сервиса в SigNoz |
| `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=default` | Окружение |
| `OTEL_LOGS_EXPORTER` | `otlp` | Экспорт логов через OTLP |
| `OTEL_METRICS_EXPORTER` | `otlp` | Экспорт метрик через OTLP |
| `OTEL_TRACES_EXPORTER` | `otlp` | Экспорт трейсов через OTLP |
| `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES` | `tenant.id` | Захват MDC-атрибута в логи |
> [!NOTE]
> В локальной разработке OpenTelemetry Agent также встроен в Docker-образ, но без переменных `OTEL_*` он работает в режиме noop — логи идут только в stdout.
---
## Мультитенантный контекст (MDC)
Каждый HTTP-запрос обогащается tenant ID через [TenantInterceptor](file:///mnt/HDD/magistr/magistr/backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java):
```java
// preHandle — при входе запроса
MDC.put("tenant.id", tenant);
Span.current().setAttribute("tenant.id", tenant);
// afterCompletion — после завершения
MDC.remove("tenant.id");
```
Это позволяет:
- Фильтровать логи по тенанту в SigNoz
- Коррелировать логи с трейсами через Span-атрибуты
- Идентифицировать, к какому университету относится каждая запись
---
## Использование в коде
### Классы с логированием
| Класс | Уровни | Что логируется |
|-------|--------|----------------|
| `TenantInterceptor` | DEBUG, WARN | Резолвинг тенанта, неизвестный тенант (404) |
| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 |
| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения |
| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции |
| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s |
| `DataInitializer` | INFO | Инициализация БД при старте |
| `LessonsController` | INFO, DEBUG, ERROR | CRUD-операции с занятиями, валидация |
### Паттерн использования
```java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyClass {
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
public void doSomething() {
log.info("Операция выполнена: param={}", value);
log.error("Ошибка: {}", e.getMessage(), e); // со стектрейсом
}
}
```
### Рекомендации по уровням
| Уровень | Когда использовать |
|---------|-------------------|
| `ERROR` | Необработанные ошибки, сбои подключения к БД, провалы миграций |
| `WARN` | Неизвестный тенант, нет конфигурации, fallback-сценарии |
| `INFO` | Успешные операции, CRUD-действия, старт/стоп компонентов |
| `DEBUG` | Детали резолвинга тенанта, ping-запросы |
---
## Настройка уровня логирования
В [application.properties](file:///mnt/HDD/magistr/magistr/backend/src/main/resources/application.properties) (по умолчанию закомментировано):
```properties
# Включить DEBUG для всего приложения
#logging.level.root=DEBUG
# Только для пакета приложения
logging.level.com.magistr.app=DEBUG
# Только для конкретного класса
logging.level.com.magistr.app.config.tenant.TenantInterceptor=DEBUG
```
Также можно задавать через переменные окружения:
```bash
LOGGING_LEVEL_ROOT=DEBUG
LOGGING_LEVEL_COM_MAGISTR_APP=DEBUG
```
---
## Просмотр логов
### Локально (Docker Compose)
```bash
# Все логи backend
docker compose logs -f backend
# Фильтрация по ключевому слову
docker compose logs -f backend | grep "tenant"
```
### Продакшн (SigNoz)
Логи доступны в веб-интерфейсе SigNoz → раздел **Logs**:
- Фильтрация по `service.name = magistr-backend`
- Фильтрация по `tenant.id` (из MDC)
- Корреляция с трейсами через общий `trace_id`

113
docs/README.md Normal file
View File

@@ -0,0 +1,113 @@
# 📚 Magistr — Система управления университетским расписанием
## Обзор
**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом.
- **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company)
- **Локальная разработка:** [http://localhost:80](http://localhost:80)
---
## Стек технологий
| Компонент | Технология |
|-----------|-----------|
| **Backend** | Java 17, Spring Boot 3.2.5 |
| **Frontend** | Vanilla JavaScript (ES6 Modules) + HTML/CSS |
| **База данных** | PostgreSQL (через Flyway миграции) |
| **Контейнеризация** | Docker, Docker Compose |
| **Продакшн** | Kubernetes, Caddy (реверс-прокси) |
| **Мониторинг** | SigNoz, OpenTelemetry |
| **CI/CD** | Gitea Actions |
---
## Быстрый старт
### Предварительные требования
- Docker и Docker Compose
- Git
### Локальный запуск
```bash
# 1. Клонировать репозиторий
git clone <repo-url> magistr && cd magistr
# 2. Создать Docker-сеть (если ещё не создана)
docker network create proxy
# 3. Запустить все сервисы
docker compose up -d --build
```
После запуска приложение доступно по адресу: **http://localhost:80**
**Учётные данные по умолчанию:**
| Логин | Пароль | Роль |
|-------|--------|------|
| `admin` | `admin` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
### Полезные команды
```bash
# Просмотр логов
docker compose logs -f backend
# Полный сброс базы данных (удаление данных + повтор миграций)
docker compose down -v
docker compose up -d
# Остановка всех сервисов
docker compose down
```
---
## Структура проекта
```
magistr/
├── backend/ # Java Spring Boot backend
│ └── src/main/
│ ├── java/com/magistr/app/
│ │ ├── controller/ # REST-контроллеры (10 шт.)
│ │ ├── model/ # JPA-сущности
│ │ ├── dto/ # Data Transfer Objects
│ │ ├── repository/ # Spring Data JPA репозитории
│ │ ├── config/ # Конфигурация приложения
│ │ │ └── tenant/ # Мультитенантность
│ │ └── utils/ # Валидаторы
│ └── resources/
│ ├── application.properties
│ └── db/migration/ # Flyway SQL миграции
├── frontend/ # Статический фронтенд
│ ├── index.html # Страница авторизации
│ ├── admin/ # Админ-панель (деканат)
│ │ ├── js/views/ # Модули представлений
│ │ └── css/ # Стили
│ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация (вы здесь)
├── compose.yaml # Docker Compose конфигурация
├── .env # Переменные окружения
└── AGENTS.md # Руководство для AI-агентов
```
---
## 📖 Навигация по документации
| Документ | Содержание |
|----------|-----------|
| [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов |
| [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами |
| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции |
| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов |
| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг |
| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков |
| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили |

115
docs/UI_COMPONENTS.md Normal file
View File

@@ -0,0 +1,115 @@
# 🎨 Использование UI компонентов: Выпадающие списки (Dropdowns)
В проекте Magistr используется **премиальная кастомная дизайн-система** выпадающих списков. В связи с ограничениями браузеров на стилизацию стандартных элементов `<select>`, мы реализовали два типа компонентов, которые выглядят потрясающе (с эффектом glassmorphism, встроенными микро-анимациями и свечением), но интегрируются максимально просто.
---
## 1. Стандартные одинарные списки (Custom Select Wrapper)
Этот компонент автоматически "оборачивает" любые стандартные теги `<select>` на всём сайте, превращая их в красивые выпадающие меню. Вам **не нужно** писать сложный HTML, всё работает автоматически!
### Как добавить новый одинарный список:
Просто добавьте обычный тег `<select>` в HTML:
```html
<div class="form-group">
<label for="my-new-select">Выберите опцию</label>
<select id="my-new-select">
<option value="">Выберите...</option>
<option value="1">Опция 1</option>
<option value="2">Опция 2</option>
</select>
</div>
```
### Как это работает:
1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**.
2. Как только любой скрипт или загрузка страницы добавляет `<select>` в DOM, скрипт автоматически:
- Скрывает оригинальный `<select>` (но оставляет его доступным из JS!).
- Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона.
- Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`).
### Динамическое обновление списка (через JS):
Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно:
```javascript
const select = document.getElementById('my-new-select');
select.innerHTML = '<option value="99">Новое значение с API</option>';
```
**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `<option>`, поэтому он **автоматически перестроит красивый кастомный выпадающий список**. Никаких дополнительных вызовов для перерисовки не требуется.
---
## 2. Множественный выбор (Multi-Select с чекбоксами)
Этот UI-компонент позволяет выбирать сразу несколько элементов из выпадающего списка. Он включает в себя кастомные красивые галочки (checkmarks) с неоновой подсветкой и кастомный скроллбар.
Этот компонент требует написания определённой HTML-структуры, так как нативного тега `select multiple` с похожей функциональностью не существует.
### Как добавить мульти-селект:
**1. HTML Структура:**
```html
<div class="form-group">
<label>Выберите оборудование</label>
<div class="custom-multi-select">
<!-- Кнопка-триггер (то, на что нажимаем) -->
<div class="select-box" id="my-multi-box">
<span class="select-text" id="my-multi-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="my-multi-menu">
<div id="my-multi-checkboxes" class="checkbox-group-vertical">
<!-- Сюда JS добавит чекбоксы -->
</div>
</div>
</div>
</div>
```
**2. Инициализация (в вашем JS-файле):**
Используйте готовую утилиту `initMultiSelect` из `utils.js` (она обрабатывает клики и открытие/закрытие):
```javascript
import { initMultiSelect } from '../utils.js';
// Передаем ID: box, menu, text, container
initMultiSelect('my-multi-box', 'my-multi-menu', 'my-multi-text', 'my-multi-checkboxes');
```
**3. Рендеринг элементов с кастомными галочками:**
Чтобы нарисовать сами чекбоксы, нужно использовать класс `.checkbox-item` и обязательный пустой `span.checkmark`. Пример генерации HTML:
```javascript
const container = document.getElementById('my-multi-checkboxes');
const items = [{id: 1, name: "Проектор"}, {id: 2, name: "Компьютер"}];
container.innerHTML = items.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<!-- Обязательный элемент для красивой галочки: -->
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
```
### Как прочитать выбранные значения:
Просто соберите массив value у выбранных чекбоксов внутри контейнера:
```javascript
const checkedBoxes = Array.from(document.querySelectorAll('#my-multi-checkboxes input:checked'));
const selectedIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
console.log(selectedIds); // [1, 2]
```
---
## Итог и правила
1. **Никогда не пытайтесь "красить" нативные теги `<option>`.** Браузеры (особенно Safari и Chrome) не позволяют этого сделать.
2. Для **отдельного выбора (1 из N)** всегда используйте стандартный `select`. Наша обёртка сделает всю магию сама.
3. Для **множественного выбора (N из M)** используйте HTML-шаблон `.custom-multi-select` (с `span.checkmark`).

View File

@@ -0,0 +1,154 @@
/* ===== Auditorium Workload Specific Styles ===== */
.workload-grid-container {
width: 100%;
max-height: 600px;
overflow: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
position: relative;
}
.workload-table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
table-layout: fixed;
}
.workload-table th, .workload-table td {
border: 1px solid var(--bg-card-border);
padding: 0.5rem;
vertical-align: top;
position: relative;
min-width: 150px;
height: 80px;
}
.workload-table th {
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 500;
text-align: center;
position: sticky;
top: 0;
z-index: 10;
padding: 1rem 0.5rem;
box-shadow: 0 1px 0 var(--bg-card-border);
}
.workload-table .time-cell {
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 500;
text-align: center;
vertical-align: middle;
width: 120px;
min-width: 120px;
position: sticky;
left: 0;
z-index: 5;
box-shadow: 1px 0 0 var(--bg-card-border);
}
.workload-table .top-left-cell {
position: sticky;
top: 0;
left: 0;
z-index: 20;
background: var(--bg-input);
min-width: 120px;
width: 120px;
box-shadow: 1px 1px 0 var(--bg-card-border);
}
/* Diagonal line using SVG or linear-gradient */
.workload-table .top-left-cell::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
to bottom right,
transparent calc(50% - 1px),
var(--bg-card-border) 50%,
transparent calc(50% + 1px)
);
pointer-events: none;
}
.top-left-cell span.top-label {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.top-left-cell span.bottom-label {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Lesson Cards inside grid cells */
.lesson-card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
padding: 0.5rem;
font-size: 0.8rem;
margin-bottom: 0.25rem;
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
[data-theme="light"] .lesson-card {
background: rgba(255, 255, 255, 0.9);
}
.lesson-card:hover {
background: var(--bg-hover);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
border-color: var(--accent);
}
.lesson-subject {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.2rem;
line-height: 1.2;
}
.lesson-group {
font-weight: 700; /* Bolder specific for groups request mockup */
color: var(--text-primary);
margin-bottom: 0.1rem;
font-size: 0.75rem;
}
.lesson-teacher {
color: var(--text-secondary);
font-size: 0.75rem;
}
/* Custom scrollbar adjustments for grid container */
.workload-grid-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.workload-grid-container::-webkit-scrollbar-track {
background: transparent;
}
.workload-grid-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
[data-theme="light"] .workload-grid-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}

View File

@@ -72,7 +72,7 @@
}
.form-group input,
.form-group select {
.filter-row input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
@@ -85,20 +85,22 @@
transition: all var(--transition);
}
.form-group input::placeholder {
.form-group input::placeholder,
.filter-row input::placeholder {
color: var(--text-placeholder);
transition: opacity var(--transition);
}
.form-group input:focus,
.form-group select:focus {
.filter-row input:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.form-group input:focus::placeholder {
.form-group input:focus::placeholder,
.filter-row input:focus::placeholder {
opacity: 0.5;
}
@@ -114,34 +116,187 @@ input[type="number"] {
appearance: textfield;
}
/* Select Base Style */
.form-group select,
.filter-row select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
/* ===== Premium Custom Dropdown Styles ===== */
.custom-select-wrapper {
position: relative;
width: 100%;
user-select: none;
font-family: inherit;
}
.form-group select option,
.filter-row select option {
background: #1a1a2e;
.custom-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
outline: none;
transition: all var(--transition);
}
.filter-row .custom-select-trigger {
padding: 0.45rem 1rem;
font-size: 0.85rem;
border-color: transparent;
}
.custom-select-trigger:hover {
background: var(--bg-hover);
}
.custom-select-trigger:focus,
.custom-select-wrapper.open .custom-select-trigger {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.filter-row .custom-select-wrapper.open .custom-select-trigger,
.filter-row .custom-select-trigger:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.custom-select-trigger.placeholder-active .custom-select-text {
color: var(--text-placeholder);
}
.custom-select-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-select-icon {
margin-left: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.custom-select-wrapper.open .custom-select-icon {
transform: rotate(180deg);
color: var(--accent);
}
.custom-select-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
max-height: 280px;
overflow-y: auto;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 0.5rem;
z-index: 9999;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.98);
transform-origin: top center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
list-style: none;
margin: 0;
}
.custom-select-wrapper.open .custom-select-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
/* Custom Scrollbar for Dropdown */
.custom-select-menu::-webkit-scrollbar {
width: 6px;
}
.custom-select-menu::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
.custom-select-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.custom-select-menu::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.custom-select-item {
padding: 0.6rem 0.8rem;
margin-bottom: 0.15rem;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
}
.custom-select-item:last-child {
margin-bottom: 0;
}
.custom-select-item:hover:not(.disabled) {
background: var(--bg-hover);
padding-left: 1.1rem;
}
.custom-select-item.selected {
background: var(--accent-glow);
color: #fff;
font-weight: 500;
}
.custom-select-item.selected:hover {
background: var(--accent-glow);
padding-left: 0.8rem;
}
.custom-select-item.disabled {
color: var(--text-secondary);
opacity: 0.6;
cursor: not-allowed;
}
.custom-select-item.placeholder-item {
display: none; /* Hide placeholder options in the actual dropdown list naturally */
}
/* Light theme selects */
[data-theme="light"] .form-group input,
[data-theme="light"] .form-group select,
[data-theme="light"] .filter-row select {
[data-theme="light"] .filter-row input,
[data-theme="light"] .custom-select-trigger {
border-color: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .form-group select option,
[data-theme="light"] .filter-row select option {
background: #fff;
color: #1a1a2e;
[data-theme="light"] .custom-select-menu {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
}
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
[data-theme="light"] .custom-select-item.selected {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
}
/* Filter Row */
@@ -172,7 +327,7 @@ input[type="number"] {
white-space: nowrap;
}
.filter-row select {
.filter-row input {
padding: 0.45rem 2rem 0.45rem 0.7rem;
background: var(--bg-input);
border: 1px solid transparent;
@@ -182,7 +337,7 @@ input[type="number"] {
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.filter-row select:focus {
.filter-row input:focus {
background-color: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
@@ -230,26 +385,33 @@ input[type="number"] {
.dropdown-menu {
position: absolute;
top: 100%;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
margin-top: 0.5rem;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: var(--radius-md);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 0.5rem;
z-index: 100;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--transition);
transform: translateY(-8px) scale(0.98);
transform-origin: top center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
}
[data-theme="light"] .custom-multi-select .dropdown-menu {
background: rgba(255, 255, 255, 0.98);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
}
.dropdown-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.dropdown-menu.open {
@@ -261,26 +423,102 @@ input[type="number"] {
.checkbox-group-vertical {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 200px;
gap: 0.25rem;
max-height: 250px;
overflow-y: auto;
padding-right: 0.25rem;
}
.checkbox-group-vertical::-webkit-scrollbar {
width: 6px;
}
.checkbox-group-vertical::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
.checkbox-group-vertical::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
padding: 0.5rem 0.5rem 0.5rem 2.25rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-primary);
padding: 0.25rem 0;
border-radius: var(--radius-sm);
user-select: none;
transition: background 0.2s ease;
}
.checkbox-item:hover {
background: var(--bg-hover);
}
.checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
width: 1.1rem;
height: 1.1rem;
accent-color: var(--accent);
height: 0;
width: 0;
}
.checkbox-item .checkmark {
position: absolute;
top: 50%;
left: 0.6rem;
transform: translateY(-50%);
height: 1.15rem;
width: 1.15rem;
background-color: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-item:hover input ~ .checkmark {
border-color: var(--accent);
}
.checkbox-item input:focus ~ .checkmark {
box-shadow: 0 0 0 3px var(--accent-glow);
}
.checkbox-item input:checked ~ .checkmark {
background-color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
}
.checkmark::after {
content: "";
display: none;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
margin-bottom: 2px;
}
.checkbox-item input:checked ~ .checkmark::after {
display: block;
}
/* ===== Buttons ===== */
@@ -754,3 +992,44 @@ tbody tr:hover {
align-items: center;
gap: 8px;
}
/* ===== Theme Toggle Button ===== */
.theme-toggle {
width: 42px;
height: 42px;
border: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
z-index: 100;
flex-shrink: 0;
}
.theme-toggle svg {
width: 20px;
height: 20px;
transition: transform 0.4s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px var(--accent-glow);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle--fixed {
position: fixed;
top: 1.25rem;
right: 1.25rem;
}

View File

@@ -0,0 +1,344 @@
/* ===== Оверлей для модалок создания записей (к/ф) ===== */
.cs-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.cs-overlay.open {
display: block;
}
.cs-overlay-scroll {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
/* Общие стили для обеих модалок */
.cs-modal {
width: 100%;
max-width: 1100px;
position: relative;
animation: csModalAppear 0.25s ease-out;
}
/* Модалка 1 (форма) всегда поверх модалки 2 (таблицы),
чтобы выпадающие списки не уходили под таблицу */
.cs-modal-form {
z-index: 2;
}
.cs-modal-table {
z-index: 1;
}
@keyframes csModalAppear {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
.cs-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.cs-modal-header h2 {
margin: 0;
}
/* Кнопка закрытия */
.btn-close-panel {
background: none;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
font-size: 1.3rem;
line-height: 1;
padding: 0.25rem 0.6rem;
color: var(--text-secondary);
cursor: pointer;
transition: color var(--transition), background var(--transition), border-color var(--transition);
}
.btn-close-panel:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
border-color: var(--error);
}
.wrap{
max-width: 900px;
margin: 0 auto;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0,0,0,.06);
}
.header{
padding: 14px 16px;
border-bottom: 1px solid var(--bg-card-border);
font-weight: 700;
color: var(--text-primary);
}
details.table-item{
border-top: 1px solid var(--bg-card-border);
}
details.table-item:first-of-type{ border-top:none; }
summary{
list-style: none;
cursor: pointer;
user-select: none;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
}
summary::-webkit-details-marker{ display:none; }
.chev{
width: 28px;
height: 28px;
border: 1px solid var(--bg-card-border);
border-radius: 10px;
display: grid;
place-items: center;
flex: 0 0 auto;
color: var(--text-secondary);
background: var(--bg-input);
transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease;
}
.chev-icon{
width: 16px;
height: 16px;
display: block;
}
summary:hover .chev{
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border));
color: var(--text-primary);
}
details[open] .chev{
transform: rotate(180deg);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border));
background: color-mix(in srgb, var(--accent) 10%, var(--bg-input));
}
.meta{ color: var(--text-secondary); font-size: 12px; }
.content{ padding: 0 16px 16px 16px; }
.wrap table{
width: 100%;
border-collapse: collapse;
border: 1px solid var(--bg-card-border);
border-radius: 10px;
overflow: hidden;
background: var(--bg-card);
}
.wrap thead th{
text-align: left;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-input);
border-bottom: 1px solid var(--bg-card-border);
padding: 10px 12px;
}
.wrap tbody td{
padding: 10px 12px;
border-bottom: 1px solid var(--bg-card-border);
font-size: 14px;
color: var(--text-primary);
}
.wrap tbody tr:hover{ background: var(--bg-hover); }
.title-multiline{
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.2;
}
.title-multiline .title-main{
font-weight: 700;
color: var(--text-primary);
}
.title-multiline .title-sub{
font-weight: 500;
font-size: 12px;
color: var(--text-secondary);
}
.title-multiline b{
font-weight: 700;
color: var(--text-primary);
}
/* summary = 3 колонки: [chev] [title] [meta] */
details.table-item > summary{
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 12px;
align-items: start; /* важно: всё прижимаем к верху */
padding: 12px 16px;
}
/* чтобы текст нормально переносился и не растягивал мету */
details.table-item > summary .title{
min-width: 0; /* важно для grid, иначе может распирать */
}
/* "2 записи" всегда справа и сверху, аккуратно */
details.table-item > summary .meta{
justify-self: end;
align-self: start;
white-space: nowrap;
padding-top: 4px; /* чуть опустить относительно первой строки */
font-size: 12px;
color: var(--text-secondary);
}
/* стрелка тоже сверху */
details.table-item > summary .chev{
align-self: start;
margin-top: 2px;
}
.records-search{
width: min(360px, 60vw);
padding: 0.45rem 0.7rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
}
.records-search::placeholder{ color: var(--text-placeholder); }
.records-search:focus{
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* Таблица внутри раскрывающегося блока */
details.table-item .content table{
width: 100%;
border-collapse: separate; /* нужно для красивых линий */
border-spacing: 0;
border: 1px solid var(--bg-card-border);
border-radius: 12px;
overflow: hidden;
background: var(--bg-card);
}
/* Шапка */
details.table-item .content thead th{
position: sticky; /* опционально: шапка прилипает при скролле */
top: 0;
z-index: 1;
background: var(--bg-input);
color: var(--text-secondary);
border-bottom: 1px solid var(--bg-card-border);
}
/* Ячейки: одинаковые отступы */
details.table-item .content th,
details.table-item .content td{
padding: 0.75rem 0.85rem;
vertical-align: top;
}
/* Вертикальные разделители между колонками */
details.table-item .content th:not(:last-child),
details.table-item .content td:not(:last-child){
border-right: 1px solid var(--bg-card-border);
}
/* Горизонтальные разделители между строками */
details.table-item .content tbody td{
border-bottom: 1px solid var(--bg-card-border);
color: var(--text-primary);
}
/* У последней строки нет нижней линии */
details.table-item .content tbody tr:last-child td{
border-bottom: none;
}
/* "Зебра" для читабельности */
details.table-item .content tbody tr:nth-child(even){
background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover));
}
/* Ховер по строке */
details.table-item .content tbody tr:hover{
background: var(--bg-hover);
}
/* (Опционально) Чтобы длинный текст не ломал ширину */
details.table-item .content td{
word-break: break-word;
}
/* (Опционально) если таблица широкая — пусть скроллится горизонтально */
details.table-item .content{
overflow-x: auto;
}
/* ===== Контейнер занятий преподавателя в модалках ===== */
.cs-modal-table .lessons-container {
max-height: 50vh;
overflow-y: auto;
padding-right: 0.5rem;
scrollbar-width: thin;
scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06);
}
.cs-modal-table .lessons-container::-webkit-scrollbar {
width: 10px;
}
.cs-modal-table .lessons-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
.cs-modal-table .lessons-container::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.55);
border-radius: 10px;
border: 2px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
}
.cs-modal-table .lessons-container::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.75);
}

View File

@@ -0,0 +1,11 @@
/* Стили для формы создания кафедр и специальностей */
.departments-data-icon {
margin-right: 0.5rem;
}
#departments-tbody .loading-row,
#specialties-tbody .loading-row {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}

View File

@@ -12,13 +12,34 @@
left: 0;
top: 0;
bottom: 0;
z-index: 10;
transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 1000;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.sidebar-close-btn:hover {
background: var(--bg-card-border);
color: var(--text-primary);
}
.logo {
@@ -99,7 +120,7 @@
border-top: 1px solid var(--bg-card-border);
}
.btn-logout {
.btn-settings {
width: 100%;
display: flex;
align-items: center;
@@ -116,16 +137,189 @@
position: relative;
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
.btn-settings:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.settings-chevron {
margin-left: auto;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.settings-dropdown.open .settings-chevron {
transform: rotate(180deg);
}
/* Settings Dropdown Menu */
.settings-dropdown {
position: relative;
}
.settings-menu {
position: absolute;
bottom: calc(100% + 0.5rem);
left: 0;
min-width: 100%;
width: max-content;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 0.5rem;
z-index: 200;
opacity: 0;
visibility: hidden;
transform: translateY(8px) scale(0.98);
transform-origin: bottom center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
}
[data-theme="light"] .settings-menu {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
}
.settings-dropdown.open .settings-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.settings-menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
width: 100%;
margin-bottom: 0.15rem;
}
.settings-menu-item:last-child {
margin-bottom: 0;
}
.settings-menu-item:hover {
background: var(--bg-hover);
padding-left: 1.1rem;
}
.settings-menu-item--danger {
color: var(--error);
}
.settings-menu-item--danger:hover {
background: rgba(248, 113, 113, 0.1);
padding-left: 1.1rem;
}
.settings-menu-divider {
height: 1px;
background: var(--bg-card-border);
margin: 0.25rem 0.5rem;
}
/* ===== Main ===== */
.main {
flex: 1;
margin-left: 260px;
min-height: 100vh;
transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Desktop Collapse State */
@media (min-width: 769px) {
.sidebar.collapsed {
width: 74px;
}
.sidebar.collapsed .logo span,
.sidebar.collapsed .settings-chevron {
display: none;
}
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .btn-settings span {
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%) translateX(-10px);
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: var(--text-primary);
padding: 0.5rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .sidebar.collapsed .nav-item span,
[data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sidebar.collapsed .nav-item:hover span,
.sidebar.collapsed .btn-settings:hover span {
opacity: 1;
visibility: visible;
transform: translateY(-50%) translateX(0);
}
.sidebar.collapsed .sidebar-close-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .logo {
justify-content: center;
padding: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.75rem 0;
overflow: visible;
}
.sidebar.collapsed .btn-settings {
justify-content: center;
padding: 0.65rem 0;
}
.sidebar.collapsed .sidebar-header {
flex-direction: column;
gap: 1.5rem;
padding: 1.25rem 0;
}
.main.sidebar-collapsed {
margin-left: 74px;
}
.main.sidebar-collapsed .menu-toggle {
display: none;
}
}
.topbar {
@@ -180,7 +374,9 @@
backdrop-filter: blur(2px);
z-index: 9;
opacity: 0;
transition: opacity var(--transition);
visibility: hidden;
pointer-events: none;
transition: opacity var(--transition), visibility var(--transition);
}
/* ===== Responsive Mobile ===== */
@@ -212,5 +408,7 @@
.sidebar-overlay.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}

View File

@@ -14,6 +14,9 @@
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/department.css">
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
</head>
<body>
@@ -34,6 +37,11 @@
</svg>
<span>Magistr</span>
</div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="users">
@@ -44,7 +52,25 @@
<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="department">
<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 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16" />
<path d="M2 21h20" />
<path d="M8 7h0M12 7h0M16 7h0" />
<path d="M8 11h0M12 11h0M16 11h0" />
<path d="M10 21v-4h4v4" />
</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">
<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="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -52,7 +78,7 @@
<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"
@@ -62,7 +88,7 @@
<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"
@@ -70,14 +96,14 @@
<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"
@@ -85,7 +111,7 @@
<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 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">
@@ -94,7 +120,15 @@
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Расписание занятий
<span>Расписание занятий</span>
</a>
<a href="#" class="nav-item" data-tab="auditorium-workload">
<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="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</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">
@@ -102,12 +136,35 @@
<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>
<div class="sidebar-footer">
<button class="btn-logout" id="btn-logout">
<div class="settings-dropdown" id="settings-dropdown">
<button class="btn-settings" id="btn-settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Настройки</span>
<svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div class="settings-menu" id="settings-menu">
<a href="/admin/settings/" class="settings-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Общие настройки
</a>
<div class="settings-menu-divider"></div>
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
@@ -116,6 +173,8 @@
Выйти
</button>
</div>
</div>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->

View File

@@ -0,0 +1,222 @@
// dropdown.js - Premium Custom Dropdowns
export class CustomSelect {
constructor(originalSelect) {
if (originalSelect.classList.contains('custom-select-initialized')) return;
this.originalSelect = originalSelect;
this.originalSelect.classList.add('custom-select-initialized');
// Hide original but keep it accessible for form submissions and JS
this.originalSelect.style.display = 'none';
// Bind methods
this.handleTriggerClick = this.handleTriggerClick.bind(this);
this.closeAll = this.closeAll.bind(this);
this.handleItemClick = this.handleItemClick.bind(this);
this.rebuildMenu = this.rebuildMenu.bind(this);
this.init();
// Watch for dynamic changes (like when api fetching populates <option> tags)
this.observer = new MutationObserver((mutations) => {
let shouldRebuild = false;
mutations.forEach(mut => {
if (mut.type === 'childList') shouldRebuild = true;
});
if (shouldRebuild) {
this.rebuildMenu();
}
});
this.observer.observe(this.originalSelect, { childList: true });
// Listen for external value changes (e.g. form.reset())
this.originalSelect.addEventListener('change', () => {
this.syncTriggerText();
});
}
init() {
// Create wrapper
this.wrapper = document.createElement('div');
this.wrapper.className = 'custom-select-wrapper';
// Insert wrapper right after the original select
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect.nextSibling);
// Create trigger button
this.trigger = document.createElement('div');
this.trigger.className = 'custom-select-trigger';
this.trigger.tabIndex = 0; // Make focusable
this.triggerText = document.createElement('span');
this.triggerText.className = 'custom-select-text';
this.triggerIcon = document.createElement('div');
this.triggerIcon.className = 'custom-select-icon';
this.triggerIcon.innerHTML = `<svg 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>`;
this.trigger.appendChild(this.triggerText);
this.trigger.appendChild(this.triggerIcon);
// Create menu
this.menu = document.createElement('ul');
this.menu.className = 'custom-select-menu';
this.wrapper.appendChild(this.trigger);
this.wrapper.appendChild(this.menu);
this.rebuildMenu();
// Events
this.trigger.addEventListener('click', this.handleTriggerClick);
this.trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleTriggerClick(e);
} else if (e.key === 'Escape') {
this.close();
}
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!this.wrapper.contains(e.target)) {
this.close();
}
});
}
rebuildMenu() {
this.menu.innerHTML = '';
const options = Array.from(this.originalSelect.options);
if (options.length === 0) {
const li = document.createElement('li');
li.className = 'custom-select-item disabled';
li.textContent = 'Нет опций';
this.menu.appendChild(li);
} else {
options.forEach((option, index) => {
const li = document.createElement('li');
li.className = 'custom-select-item';
li.textContent = option.text;
li.dataset.value = option.value;
li.dataset.index = index;
if (option.disabled || option.value === '') {
li.classList.add('disabled');
if (option.value === '') li.classList.add('placeholder-item');
} else {
li.addEventListener('click', (e) => this.handleItemClick(e, index));
}
if (option.selected) {
li.classList.add('selected');
}
this.menu.appendChild(li);
});
}
this.syncTriggerText();
}
syncTriggerText() {
const selectedOption = this.originalSelect.options[this.originalSelect.selectedIndex];
if (selectedOption) {
this.triggerText.textContent = selectedOption.text;
if (selectedOption.value === '') {
this.trigger.classList.add('placeholder-active');
} else {
this.trigger.classList.remove('placeholder-active');
}
} else {
this.triggerText.textContent = '—';
this.trigger.classList.add('placeholder-active');
}
// Disable state sync
if (this.originalSelect.disabled) {
this.wrapper.classList.add('disabled');
this.trigger.tabIndex = -1;
} else {
this.wrapper.classList.remove('disabled');
this.trigger.tabIndex = 0;
}
// Highlight selected in menu
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => item.classList.remove('selected'));
if (selectedOption && this.originalSelect.selectedIndex >= 0) {
const activeItem = this.menu.querySelector(`[data-index="${this.originalSelect.selectedIndex}"]`);
if(activeItem) activeItem.classList.add('selected');
}
}
handleTriggerClick(e) {
if (this.originalSelect.disabled) return;
const isOpen = this.wrapper.classList.contains('open');
this.closeAll(); // Close other open dropdowns
if (!isOpen) {
this.wrapper.classList.add('open');
// Scroll selected item into view
const selectedItem = this.menu.querySelector('.selected');
if (selectedItem) {
setTimeout(() => {
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 50);
}
}
}
closeAll() {
document.querySelectorAll('.custom-select-wrapper.open').forEach(wrapper => {
wrapper.classList.remove('open');
});
}
close() {
this.wrapper.classList.remove('open');
}
handleItemClick(e, index) {
e.stopPropagation();
this.originalSelect.selectedIndex = index;
// Trigger native change event so other scripts (users.js) pick it up
this.originalSelect.dispatchEvent(new Event('change', { bubbles: true }));
this.syncTriggerText();
this.close();
}
}
// Global initializer
export function initAllCustomDropdowns(root = document) {
const selects = root.querySelectorAll('select:not(.custom-select-initialized)');
selects.forEach(select => {
new CustomSelect(select);
});
}
// Observe DOM for automatically picking up new select elements
export function startDropdownAutoObserver() {
const observer = new MutationObserver((mutations) => {
let shouldInit = false;
mutations.forEach(mut => {
if (mut.addedNodes.length > 0) {
shouldInit = true;
}
});
if (shouldInit) {
initAllCustomDropdowns(document.body);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}

View File

@@ -1,5 +1,22 @@
// OTel: загружаем только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { isAuthenticatedAsAdmin } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
// Auth check
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
@@ -9,6 +26,9 @@ 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 {initDepartment} from "./views/department.js";
import {initDepartmentsData} from "./views/departments-data.js";
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
// Configuration
const ROUTES = {
@@ -19,7 +39,10 @@ const ROUTES = {
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload },
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment },
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
};
let currentTab = null;
@@ -31,7 +54,9 @@ const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
// Initial auth check
if (!isAuthenticatedAsAdmin()) {
@@ -42,16 +67,56 @@ if (!isAuthenticatedAsAdmin()) {
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
// Menu Toggle
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Menu Toggle (Hamburger)
menuToggle.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
});
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Settings Dropdown
const settingsDropdown = document.getElementById('settings-dropdown');
const btnSettings = document.getElementById('btn-settings');
btnSettings.addEventListener('click', (e) => {
e.stopPropagation();
settingsDropdown.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target)) {
settingsDropdown.classList.remove('open');
}
});
// Logout
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');

47
frontend/admin/js/otel.js Normal file
View File

@@ -0,0 +1,47 @@
import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0';
import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0';
import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1';
import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0';
import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1';
import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0';
import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0';
import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0';
// Инициализация провайдера метрик и трейсов с именем сервиса
const provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin',
}),
});
// Экспортер отправляет данные на относительный путь /otel/v1/traces.
// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318).
const traceExporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces',
});
// Использование BatchSpanProcessor для буферизации трейсов перед отправкой
provider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc)
provider.register({
contextManager: new ZoneContextManager(),
});
// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc)
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-fetch': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
'@opentelemetry/instrumentation-xml-http-request': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
}),
],
});
console.log('OpenTelemetry Web SDK initialized successfully.');

View File

@@ -0,0 +1,153 @@
import { initMultiSelect } from '../utils.js';
export function initAuditoriumWorkload() {
// Initialize date input with current date
const dateInput = document.getElementById('workload-date');
if (dateInput) {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
dateInput.value = `${yyyy}-${mm}-${dd}`;
}
// Initialize Multi-Selects
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');
// Populate Filters with Mock/Initial Data
populateFilters();
// Render Mock Data for the Grid based on the UI requested layout
renderMockGrid();
}
function populateFilters() {
// Buildings
const buildingsContainer = document.getElementById('building-checkboxes');
const buildings = [
{ id: 1, name: "Корпус 1 (Главный)" },
{ id: 2, name: "Корпус 2 (Физ-мат)" },
{ id: 3, name: "Корпус 3 (Гуманитарный)" }
];
buildingsContainer.innerHTML = buildings.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
// Capacities
const capacityContainer = document.getElementById('capacity-checkboxes');
const capacities = [
{ id: 'small', name: "До 30 мест" },
{ id: 'medium', name: "30 - 60 мест" },
{ id: 'large', name: "60 - 100 мест" },
{ id: 'xlarge', name: "Более 100 мест" }
];
capacityContainer.innerHTML = capacities.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
// Equipment
const equipmentContainer = document.getElementById('equipment-checkboxes');
const equipmentList = [
{ id: 1, name: "Проектор" },
{ id: 2, name: "Компьютерные места" },
{ id: 3, name: "Интерактивная доска" },
{ id: 4, name: "Микрофон" }
];
equipmentContainer.innerHTML = equipmentList.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
}
function renderMockGrid() {
// In future this will be loaded from API
const timeslots = [
"8:00-9:30",
"9:40-11:10",
"11:40-13:10",
"13:20-14:50",
"15:00-16:30",
"16:50-18:20",
"18:30-19:50",
"20:00-21:20"
];
const auditoriums = [
"201", "202", "204", "205", "206", "207", "208"
];
// Mock schedule data mapped by room and time
// Key: "roomId_timeSlotId", Value: Lesson object
const mockSchedule = {
"201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." },
"201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." },
"201_11:40-13:10": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
"201_13:20-14:50": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
"202_9:40-11:10": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." },
"202_13:20-14:50": { subject: "Математика", group: "ИВТ-21-1", teacher: "Рутина Л.П." },
"202_15:00-16:30": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." },
"202_16:50-18:20": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." },
"205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." },
};
// Render Headers
const headerRow = document.getElementById('workload-header-row');
// Start after the first fixed cell (which is already in HTML)
auditoriums.forEach(room => {
const th = document.createElement('th');
th.textContent = room;
headerRow.appendChild(th);
});
// Render Body Rows
const tbody = document.getElementById('workload-tbody');
timeslots.forEach((time) => {
const tr = document.createElement('tr');
// Add Time Cell
const tdTime = document.createElement('td');
tdTime.className = 'time-cell';
tdTime.textContent = time;
tr.appendChild(tdTime);
// Add Room Cells for this Time
auditoriums.forEach(room => {
const td = document.createElement('td');
const scheduleKey = `${room}_${time}`;
const lesson = mockSchedule[scheduleKey];
if (lesson) {
// Render lesson card
td.innerHTML = `
<div class="lesson-card">
<div class="lesson-subject">${lesson.subject}</div>
<div class="lesson-group">${lesson.group}</div>
<div class="lesson-teacher">${lesson.teacher}</div>
</div>
`;
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}

View File

@@ -0,0 +1,456 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
// Ключ для хранения данных в sessionStorage
const STORAGE_KEY = 'department_schedule_blocks';
export async function initDepartment() {
const form = document.getElementById('department-schedule-form');
const departmentSelect = document.getElementById('filter-department');
const container = document.getElementById('schedule-blocks-container');
let departments = [];
// Загрузка кафедр
try {
departments = await api.get('/api/departments');
departmentSelect.innerHTML = '<option value="">Выберите кафедру...</option>' +
departments.map(d => `<option value="${d.id}">${escapeHtml(d.departmentName || d.name)}</option>`).join('');
} catch (e) {
departmentSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
// ===== Восстанавливаем ранее загруженные таблицы из sessionStorage =====
restoreScheduleBlocks();
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('schedule-form-alert');
const departmentId = departmentSelect.value;
const period = document.getElementById('filter-period').value;
const semesterType = document.querySelector('input[name="semesterType"]:checked')?.value;
if (!departmentId || !period || !semesterType) {
showAlert('schedule-form-alert', 'Заполните все поля', 'error');
return;
}
const deptName = departmentSelect.options[departmentSelect.selectedIndex].text;
try {
const params = new URLSearchParams({ departmentId, semesterType, period });
const data = await api.get(`/api/department/schedule?${params.toString()}`);
const semesterName = semesterType === 'spring' ? 'весенний' : (semesterType === 'autumn' ? 'осенний' : semesterType);
const periodName = period.replace('-', '/');
renderScheduleBlock(deptName, semesterName, periodName, data, departmentId, semesterType, period);
// НЕ сбрасываем форму — фильтры остаются заполненными (fix #3)
} catch (err) {
showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error');
}
});
// ===== Уникальный ключ для таблицы по параметрам =====
function blockKey(departmentId, semesterType, period) {
return `${departmentId}_${semesterType}_${period}`;
}
// ===== Рендер блока таблицы (с дедупликацией — fix #6) =====
function renderScheduleBlock(deptName, semester, period, schedule, departmentId, semesterType, rawPeriod) {
const key = blockKey(departmentId, semesterType, rawPeriod);
// Удаляем ранее загруженный блок с тем же ключом
const existing = container.querySelector(`[data-block-key="${key}"]`);
if (existing) existing.remove();
const details = document.createElement('details');
details.className = 'table-item';
details.open = true;
details.setAttribute('data-block-key', key);
details.innerHTML = `
<summary>
<div class="chev" aria-hidden="true">
<svg viewBox="0 0 20 20" class="chev-icon" focusable="false" aria-hidden="true">
<path d="M5.5 7.5L10 12l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="title title-multiline">
<span class="title-main">Данные к составлению расписания</span>
<span class="title-sub">Кафедра: <b>${escapeHtml(deptName)}</b></span>
<span class="title-sub">Семестр: <b>${escapeHtml(semester)}</b></span>
<span class="title-sub">Уч. год: <b>${escapeHtml(period)}</b></span>
</div>
<div class="meta">${Array.isArray(schedule) ? schedule.length : 0} записей</div>
</summary>
<div class="content">
<table>
<thead>
<tr>
<th>Специальность</th>
<th>Курс/семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид занятий</th>
<th>Часов в неделю</th>
<th>Деление на подгруппы</th>
<th>Преподаватель</th>
</tr>
</thead>
<tbody>
${renderRows(schedule)}
</tbody>
</table>
</div>
`;
container.prepend(details);
// Сохраняем в sessionStorage
saveScheduleBlock(key, { deptName, semester, period, schedule, departmentId, semesterType, rawPeriod });
}
function renderRows(schedule) {
if (!Array.isArray(schedule) || schedule.length === 0) {
return '<tr><td colspan="8" class="loading-row">Нет данных</td></tr>';
}
return schedule.map(r => `
<tr>
<td>${escapeHtml(r.specialityCode || '-')}</td>
<td>${(() => {
const course = r.groupCourse || '-';
const semester = r.semester || '-';
if (course === '-' && semester === '-') return '-';
return `${course} | ${semester}`;
})()}</td>
<td>${escapeHtml(r.groupName || '-')}</td>
<td>${escapeHtml(r.subjectName || '-')}</td>
<td>${escapeHtml(r.lessonType || '-')}</td>
<td>${escapeHtml(r.numberOfHours || '-')}</td>
<td>${r.division === true ? '✓' : ''}</td>
<td>${(() => {
const jobTitle = r.teacherJobTitle || '-';
const teacherName = r.teacherName || '-';
if (jobTitle === '-' && teacherName === '-') return '-';
return `${jobTitle}, ${teacherName}`;
})()}</td>
</tr>
`).join('');
}
// ===== Persistence: sessionStorage (fix #4) =====
function saveScheduleBlock(key, blockData) {
try {
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
stored[key] = blockData;
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
} catch (e) {
console.warn('Ошибка сохранения в sessionStorage:', e);
}
}
function restoreScheduleBlocks() {
try {
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
const keys = Object.keys(stored);
if (keys.length === 0) return;
keys.forEach(key => {
const b = stored[key];
renderScheduleBlock(b.deptName, b.semester, b.period, b.schedule, b.departmentId, b.semesterType, b.rawPeriod);
});
} catch (e) {
console.warn('Ошибка восстановления из sessionStorage:', e);
}
}
// =========================================================
// ЛОГИКА ДЛЯ ФУНКЦИОНАЛА "СОЗДАТЬ ЗАПИСЬ (К/Ф)"
// Два модальных окна поверх всего контента в одном оверлее
// =========================================================
const btnCreateSchedule = document.getElementById('btn-create-schedule');
const csOverlay = document.getElementById('cs-overlay');
const modalCreateSchedule = document.getElementById('modal-create-schedule');
const modalCreateScheduleClose = document.getElementById('modal-create-schedule-close');
const formCreateSchedule = document.getElementById('create-schedule-form');
const modalViewSchedules = document.getElementById('modal-view-schedules');
const btnSaveSchedules = document.getElementById('btn-save-schedules');
const preparedSchedulesTbody = document.getElementById('prepared-schedules-tbody');
const csGroupSelect = document.getElementById('cs-group');
const csSubjectSelect = document.getElementById('cs-subject');
const csTeacherSelect = document.getElementById('cs-teacher');
const csDepartmentIdInput = document.getElementById('cs-department-id');
let preparedSchedules = [];
let csGroups = [];
let csSubjects = [];
let csTeachers = [];
const SEMESTER_LABELS = { autumn: 'Осенний', spring: 'Весенний' };
const LESSON_TYPE_LABELS = { 1: 'Лекция', 2: 'Практическая работа', 3: 'Лабораторная работа' };
const localDepartmentId = localStorage.getItem('departmentId');
// ===== Загрузка справочников =====
async function loadDictionariesForSchedule() {
try {
csGroups = await api.get('/api/groups');
csGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
csGroups.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join('');
csSubjects = await api.get('/api/subjects');
csSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
csSubjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
// Загрузка преподавателей: сначала по кафедре, при ошибке — все преподаватели
csTeachers = [];
if (localDepartmentId) {
try {
csTeachers = await api.get(`/api/users/teachers/${localDepartmentId}`);
} catch (e) {
console.warn('Не удалось загрузить преподавателей для кафедры, загружаем всех:', e);
}
}
// Фолбэк: загружаем всех преподавателей
if (!Array.isArray(csTeachers) || csTeachers.length === 0) {
try {
csTeachers = await api.get('/api/users/teachers');
} catch (e2) {
console.error('Ошибка загрузки всех преподавателей:', e2);
}
}
if (Array.isArray(csTeachers) && csTeachers.length > 0) {
csTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
csTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)}</option>`).join('');
} else {
csTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
}
} catch (e) {
console.error('Ошибка загрузки справочников:', e);
}
}
loadDictionariesForSchedule();
// ===== Открытие / Закрытие оверлея =====
function openOverlay() {
csOverlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeOverlay() {
csOverlay.classList.remove('open');
document.body.style.overflow = '';
hideAlert('create-schedule-alert');
hideAlert('save-schedules-alert');
}
function updateTableVisibility() {
modalViewSchedules.style.display = preparedSchedules.length > 0 ? '' : 'none';
}
// ===== Кнопка «Создать запись» =====
btnCreateSchedule.addEventListener('click', () => {
if (localDepartmentId) {
csDepartmentIdInput.value = localDepartmentId;
} else {
showAlert('schedule-form-alert', 'Требуется перезайти (отсутствует ID кафедры)', 'error');
return;
}
openOverlay();
});
// ===== Закрытие =====
modalCreateScheduleClose.addEventListener('click', closeOverlay);
csOverlay.addEventListener('click', (e) => {
if (e.target === csOverlay || e.target.classList.contains('cs-overlay-scroll')) {
closeOverlay();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && csOverlay.classList.contains('open')) {
closeOverlay();
}
});
// ===== Рендер таблицы подготовленных записей =====
function renderPreparedSchedules() {
if (preparedSchedules.length === 0) {
preparedSchedulesTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет записей</td></tr>';
return;
}
preparedSchedulesTbody.innerHTML = preparedSchedules.map((s, index) => {
const groupName = csGroups.find(g => g.id == s.groupId)?.name || s.groupId;
const subjectName = csSubjects.find(sub => sub.id == s.subjectsId)?.name || s.subjectsId;
const teacherName = csTeachers.find(t => t.id == s.teacherId)?.fullName
|| csTeachers.find(t => t.id == s.teacherId)?.username || s.teacherId;
const lessonTypeName = LESSON_TYPE_LABELS[s.lessonTypeId] || 'Неизвестно';
const semLabel = SEMESTER_LABELS[s.semesterType] || s.semesterType;
const periodDisplay = s.period.replace('-', '/');
const divText = s.isDivision ? '✓' : '';
const hasError = !!s._errorMsg;
const rowStyle = hasError ? ' style="background: rgba(239, 68, 68, 0.08);"' : '';
let row = `
<tr${rowStyle}>
<td>${escapeHtml(periodDisplay)}</td>
<td>${escapeHtml(semLabel)}</td>
<td>${escapeHtml(String(groupName))}</td>
<td>${escapeHtml(String(subjectName))}</td>
<td>${escapeHtml(lessonTypeName)}</td>
<td>${s.numberOfHours}</td>
<td>${divText}</td>
<td>${escapeHtml(String(teacherName))}</td>
<td><button type="button" class="btn-delete" data-index="${index}">Удалить</button></td>
</tr>`;
if (hasError) {
row += `<tr style="background: rgba(239, 68, 68, 0.05);">
<td colspan="9" style="color: var(--error); font-size: 0.85rem; padding: 0.4rem 0.85rem;">
${escapeHtml(s._errorMsg)}
</td>
</tr>`;
}
return row;
}).join('');
}
// ===== Удаление строки из таблицы =====
preparedSchedulesTbody.addEventListener('click', (e) => {
if (e.target.classList.contains('btn-delete')) {
const idx = parseInt(e.target.getAttribute('data-index'), 10);
preparedSchedules.splice(idx, 1);
renderPreparedSchedules();
updateTableVisibility();
}
});
// ===== Очистка полей формы (частичная) =====
function clearFormFields() {
document.getElementById('cs-hours').value = '';
document.getElementById('cs-division').checked = false;
}
// ===== Добавление записи в список =====
formCreateSchedule.addEventListener('submit', (e) => {
e.preventDefault();
hideAlert('create-schedule-alert');
const depId = csDepartmentIdInput.value;
const period = document.getElementById('cs-period').value;
const semesterType = document.querySelector('input[name="csSemesterType"]:checked')?.value;
const groupId = csGroupSelect.value;
const subjectId = csSubjectSelect.value;
const lessonTypeId = document.getElementById('cs-lesson-type').value;
const hours = document.getElementById('cs-hours').value;
const isDivision = document.getElementById('cs-division').checked;
const teacherId = csTeacherSelect.value;
if (!period || !semesterType || !groupId || !subjectId || !lessonTypeId || !hours || !teacherId) {
showAlert('create-schedule-alert', 'Заполните все обязательные поля', 'error');
return;
}
const newRecord = {
departmentId: Number(depId),
groupId: Number(groupId),
subjectsId: Number(subjectId),
lessonTypeId: Number(lessonTypeId),
numberOfHours: Number(hours),
isDivision: isDivision,
teacherId: Number(teacherId),
semesterType: semesterType,
period: period
};
// Проверка на дубликат
const isDuplicate = preparedSchedules.some(s =>
s.period === newRecord.period &&
s.semesterType === newRecord.semesterType &&
s.groupId === newRecord.groupId &&
s.subjectsId === newRecord.subjectsId &&
s.lessonTypeId === newRecord.lessonTypeId &&
s.numberOfHours === newRecord.numberOfHours &&
s.isDivision === newRecord.isDivision &&
s.teacherId === newRecord.teacherId
);
if (isDuplicate) {
showAlert('create-schedule-alert', 'Такая запись уже есть в списке', 'error');
return;
}
preparedSchedules.push(newRecord);
clearFormFields();
showAlert('create-schedule-alert', 'Запись добавлена ✓', 'success');
setTimeout(() => hideAlert('create-schedule-alert'), 4000); // fix #1: 4 секунды
renderPreparedSchedules();
updateTableVisibility();
});
// ===== Сохранение в БД =====
btnSaveSchedules.addEventListener('click', async () => {
if (preparedSchedules.length === 0) {
showAlert('save-schedules-alert', 'Нет записей для сохранения', 'error');
return;
}
btnSaveSchedules.disabled = true;
btnSaveSchedules.textContent = 'Сохранение...';
hideAlert('save-schedules-alert');
let errors = 0;
let saved = 0;
const failedRecords = [];
for (const record of preparedSchedules) {
try {
await api.post('/api/department/schedule/create', record);
saved++;
} catch (err) {
console.error('Ошибка сохранения записи:', err);
errors++;
const isDuplicate = err.status === 409 ||
(err.message && err.message.toLowerCase().includes('уже существует'));
failedRecords.push({
...record,
_errorMsg: isDuplicate
? 'Такая запись уже есть в базе данных'
: (err.message || 'Ошибка сохранения')
});
}
}
btnSaveSchedules.disabled = false;
btnSaveSchedules.textContent = 'Сохранить в БД';
if (errors === 0) {
showAlert('save-schedules-alert', `Все записи (${saved}) успешно сохранены!`, 'success');
preparedSchedules = [];
renderPreparedSchedules();
updateTableVisibility();
setTimeout(closeOverlay, 2000);
} else {
preparedSchedules = failedRecords;
renderPreparedSchedules();
if (saved > 0) {
showAlert('save-schedules-alert',
`Сохранено: ${saved}. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error');
} else {
showAlert('save-schedules-alert',
`Не удалось сохранить. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error');
}
}
});
}

View File

@@ -0,0 +1,103 @@
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');
let departments = [];
let specialties = [];
async function loadData() {
// Load Departments
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
}
// Load Specialties
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="3" 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>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="3" 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>
</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');
}
});
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');
}
});
loadData();
}

View File

@@ -24,7 +24,9 @@ export function renderEquipmentCheckboxes(equipments, containerId, textId, check
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
<input type="checkbox" value="${eq.id}" ${isChecked}>
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label>
`}).join('');
updateSelectText(containerId, textId);

View File

@@ -17,7 +17,7 @@ export async function initGroups() {
populateEfSelects(educationForms);
await loadGroups();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки данных</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
@@ -26,7 +26,7 @@ export async function initGroups() {
allGroups = await api.get('/api/groups');
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Ошибка загрузки</td></tr>';
}
}
@@ -61,7 +61,7 @@ export async function initGroups() {
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
@@ -70,6 +70,9 @@ export async function initGroups() {
<td>${escapeHtml(g.name)}</td>
<td>${escapeHtml(g.groupSize)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td>${g.departmentId || '-'}</td>
<td>${g.course || '-'}</td>
<td>${escapeHtml(g.specialityCode || '-')}</td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join('');
}
@@ -80,14 +83,27 @@ export async function initGroups() {
const name = document.getElementById('new-group-name').value.trim();
const groupSize = document.getElementById('new-group-size').value;
const educationFormId = newGroupEfSelect.value;
const departmentId = document.getElementById('new-group-department').value;
const course = document.getElementById('new-group-course').value;
const specialityCode = document.getElementById('new-group-speciality-code').value.trim();
if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; }
if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; }
if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; }
if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; }
if (!specialityCode) { showAlert('create-group-alert', 'Введите код специальности', 'error'); return; }
try {
const data = await api.post('/api/groups', { name, groupSize, educationFormId: Number(educationFormId) });
showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success');
const data = await api.post('/api/groups', {
name,
groupSize: Number(groupSize),
educationFormId: Number(educationFormId),
departmentId: Number(departmentId),
course: Number(course),
specialityCode: specialityCode
});
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success');
createGroupForm.reset();
loadGroups();
} catch (e) {

View File

@@ -1,5 +1,5 @@
import { api } from '../api.js';
import { escapeHtml } from '../utils.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initSchedule() {
const tbody = document.getElementById('schedule-tbody');
@@ -20,7 +20,6 @@ export async function initSchedule() {
// ===================== Фильтрация =====================
// Извлечение отображаемого значения поля для фильтрации
function getDisplayValue(lesson, key) {
switch (key) {
case 'teacher':
@@ -38,20 +37,17 @@ export async function initSchedule() {
}
}
// Собрать уникальные значения из данных
function getUniqueValues(key) {
const vals = new Set();
lessonsData.forEach(lesson => {
vals.add(getDisplayValue(lesson, key));
});
// Для дней — сортируем по порядку
if (key === 'day') {
return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99));
}
return [...vals].sort((a, b) => a.localeCompare(b, 'ru'));
}
// Применить все фильтры
function applyFilters(lessons) {
return lessons.filter(lesson => {
for (const key of Object.keys(activeFilters)) {
@@ -79,7 +75,6 @@ export async function initSchedule() {
function onDocumentClick(e) {
if (currentPopup && !currentPopup.contains(e.target)) {
// Проверяем, не кликнули ли по иконке фильтра
if (!e.target.closest('.filter-icon')) {
closePopup();
}
@@ -87,7 +82,6 @@ export async function initSchedule() {
}
function openFilterPopup(th, filterKey) {
// Если уже открыт этот же — закрыть
if (currentPopup && currentPopup.dataset.filterKey === filterKey) {
closePopup();
return;
@@ -97,19 +91,16 @@ export async function initSchedule() {
const uniqueValues = getUniqueValues(filterKey);
const currentFilter = activeFilters[filterKey];
// Создаём попап
const popup = document.createElement('div');
popup.className = 'filter-popup';
popup.dataset.filterKey = filterKey;
// Поисковое поле
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'filter-search';
searchInput.placeholder = 'Поиск...';
popup.appendChild(searchInput);
// Кнопки «Выбрать все» / «Сбросить»
const btnRow = document.createElement('div');
btnRow.className = 'filter-btn-row';
@@ -133,7 +124,6 @@ export async function initSchedule() {
btnRow.appendChild(btnNone);
popup.appendChild(btnRow);
// Список чекбоксов
const listWrap = document.createElement('div');
listWrap.className = 'filter-list';
@@ -146,7 +136,6 @@ export async function initSchedule() {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = val;
// Если фильтр активен — отмечаем только выбранные; если нет — все отмечены
cb.checked = currentFilter ? currentFilter.has(val) : true;
const span = document.createElement('span');
@@ -160,7 +149,6 @@ export async function initSchedule() {
popup.appendChild(listWrap);
// Кнопка «Применить»
const btnApply = document.createElement('button');
btnApply.className = 'filter-btn-apply';
btnApply.textContent = 'Применить';
@@ -171,7 +159,6 @@ export async function initSchedule() {
if (cb.checked) selected.add(cb.value);
});
// Если все выбраны — снимаем фильтр
if (selected.size === uniqueValues.length) {
delete activeFilters[filterKey];
th.classList.remove('filter-active');
@@ -185,7 +172,6 @@ export async function initSchedule() {
});
popup.appendChild(btnApply);
// Поиск по чекбоксам
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase();
listWrap.querySelectorAll('.filter-item').forEach(item => {
@@ -194,28 +180,22 @@ export async function initSchedule() {
});
});
// Предотвращаем всплытие кликов внутри попапа (чтобы не срабатывала сортировка th)
popup.addEventListener('click', (e) => e.stopPropagation());
searchInput.addEventListener('click', (e) => e.stopPropagation());
// Позиционируем попап под th
th.style.position = 'relative';
th.appendChild(popup);
currentPopup = popup;
// Фокус на поиск
setTimeout(() => searchInput.focus(), 50);
// Закрытие по клику вне
setTimeout(() => {
document.addEventListener('click', onDocumentClick, true);
}, 10);
}
// Обработчики кликов по заголовкам с фильтрами (клик по всей ячейке)
table.querySelectorAll('thead th.filterable').forEach(th => {
th.addEventListener('click', (e) => {
// Не открываем попап при клике внутри самого попапа
if (e.target.closest('.filter-popup')) return;
const filterKey = th.dataset.filterKey;
openFilterPopup(th, filterKey);
@@ -249,7 +229,6 @@ export async function initSchedule() {
case 'week':
return (lesson.week || '').toLowerCase();
case 'time': {
// Составной ключ: день + время для правильной сортировки
const d = (lesson.day || '').toLowerCase();
const dayNum = dayOrder[d] ?? 99;
const t = lesson.time || '99:99';
@@ -287,10 +266,8 @@ export async function initSchedule() {
});
}
// Навешиваем обработчики клика на заголовки (сортировка)
table.querySelectorAll('thead th.sortable').forEach(th => {
th.addEventListener('click', (e) => {
// Не сортируем, если кликнули по иконке фильтра или внутри попапа
if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return;
const key = th.dataset.sortKey;
@@ -310,7 +287,7 @@ export async function initSchedule() {
});
});
// ===================== Загрузка и рендер =====================
// ===================== Загрузка и рендер таблицы =====================
async function loadSchedule() {
try {
@@ -318,21 +295,20 @@ export async function initSchedule() {
lessonsData = lessons;
renderSchedule(lessons);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="11" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
}
}
function renderSchedule(lessons) {
if (!lessons || !lessons.length) {
tbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет занятий</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="loading-row">Нет занятий</td></tr>';
return;
}
// Сначала фильтруем, потом сортируем
const filtered = applyFilters(lessons);
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет занятий по выбранным фильтрам</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="loading-row">Нет занятий по выбранным фильтрам</td></tr>';
return;
}
@@ -366,5 +342,343 @@ export async function initSchedule() {
}).join('');
}
await loadSchedule();
// ===================== Модалки добавления занятия =====================
const overlay = document.getElementById('sch-overlay');
const modalForm = document.getElementById('sch-modal-form');
const modalLessons = document.getElementById('sch-modal-lessons');
const btnAddLesson = document.getElementById('sch-btn-add-lesson');
const btnClose = document.getElementById('sch-modal-close');
const addForm = document.getElementById('sch-add-lesson-form');
const schTeacherSelect = document.getElementById('sch-teacher');
const schGroupSelect = document.getElementById('sch-group');
const schDisciplineSelect = document.getElementById('sch-discipline');
const schClassroomSelect = document.getElementById('sch-classroom');
const schDaySelect = document.getElementById('sch-day');
const schTimeSelect = document.getElementById('sch-time');
const schTypeSelect = document.getElementById('sch-type');
const schWeekUpper = document.getElementById('sch-week-upper');
const schWeekLower = document.getElementById('sch-week-lower');
const schFormatOffline = document.getElementById('sch-format-offline');
const schTeacherName = document.getElementById('sch-teacher-name');
const schLessonsContainer = document.getElementById('sch-lessons-container');
let groups = [];
let subjects = [];
let classrooms = [];
let teachers = [];
const weekdaysTimes = [
"8:00-9:30", "9:40-11:10", "11:40-13:10",
"13:20-14:50", "15:00-16:30", "16:50-18:20", "18:30-19:00"
];
const saturdayTimes = [
"8:20-9:50", "10:00-11:30", "11:40-13:10", "13:20-14:50"
];
// ===== Загрузка справочников =====
async function loadGroups() {
try {
groups = await api.get('/api/groups');
schGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
groups.map(g => {
let text = escapeHtml(g.name);
if (g.groupSize) text += ` (числ: ${g.groupSize} чел.)`;
return `<option value="${g.id}">${text}</option>`;
}).join('');
} catch (e) { console.error('Ошибка загрузки групп:', e); }
}
async function loadSubjects() {
try {
subjects = await api.get('/api/subjects');
schDisciplineSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
} catch (e) { console.error('Ошибка загрузки дисциплин:', e); }
}
async function loadClassrooms() {
try {
classrooms = await api.get('/api/classrooms');
renderClassroomOptions();
} catch (e) { console.error('Ошибка загрузки аудиторий:', e); }
}
async function loadTeachers() {
try {
teachers = await api.get('/api/users/teachers');
schTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)}</option>`).join('');
} catch (e) { console.error('Ошибка загрузки преподавателей:', e); }
}
function renderClassroomOptions() {
if (!classrooms || classrooms.length === 0) {
schClassroomSelect.innerHTML = '<option value="">Нет доступных аудиторий</option>';
return;
}
const selectedGroupId = schGroupSelect.value;
const selectedGroup = groups?.find(g => g.id == selectedGroupId);
const groupSize = selectedGroup?.groupSize || 0;
schClassroomSelect.innerHTML = '<option value="">Выберите аудиторию</option>' +
classrooms.map(c => {
let text = escapeHtml(c.name);
if (c.capacity) text += ` (вместимость: ${c.capacity} чел.)`;
if (c.isAvailable === false) {
text += ` ❌ Занята`;
} else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) {
text += ` ⚠️ Недостаточно места`;
}
return `<option value="${c.id}">${text}</option>`;
}).join('');
}
schGroupSelect.addEventListener('change', () => renderClassroomOptions());
function updateTimeOptions(dayValue) {
let times = [];
if (dayValue === "Суббота") {
times = saturdayTimes;
} else if (dayValue && dayValue !== '') {
times = weekdaysTimes;
} else {
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
schTimeSelect.disabled = true;
return;
}
schTimeSelect.innerHTML = '<option value="">Выберите время</option>' +
times.map(t => `<option value="${t}">${t}</option>`).join('');
schTimeSelect.disabled = false;
}
schDaySelect.addEventListener('change', function () {
updateTimeOptions(this.value);
});
// ===== Автозаполнение преподавателя из фильтра =====
function getFilteredTeacherId() {
const teacherFilter = activeFilters['teacher'];
if (teacherFilter && teacherFilter.size === 1) {
const teacherName = [...teacherFilter][0];
// Сопоставляем по username, fullName и их комбинациям
const match = teachers.find(t =>
t.username === teacherName ||
t.fullName === teacherName ||
(t.fullName || t.username) === teacherName
);
return match ? String(match.id) : '';
}
return '';
}
// ===== Загрузка занятий преподавателя =====
async function loadTeacherLessons(teacherId) {
const teacher = teachers.find(t => t.id == teacherId);
const name = teacher ? (teacher.fullName || teacher.username) : '';
schTeacherName.textContent = name
? `Занятия преподавателя: ${name}`
: 'Занятия преподавателя';
modalLessons.style.display = '';
schLessonsContainer.innerHTML = '<div class="loading-lessons">Загрузка занятий...</div>';
try {
const lessons = await api.get(`/api/users/lessons/${teacherId}`);
if (!lessons || !Array.isArray(lessons) || lessons.length === 0) {
schLessonsContainer.innerHTML = '<div class="no-lessons">У преподавателя пока нет занятий</div>';
return;
}
const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
const lessonsByDay = {};
lessons.forEach(l => {
if (!lessonsByDay[l.day]) lessonsByDay[l.day] = [];
lessonsByDay[l.day].push(l);
});
Object.keys(lessonsByDay).forEach(day => {
lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time));
});
let html = '';
daysOrder.forEach(day => {
if (!lessonsByDay[day]) return;
html += `<div class="lesson-day-divider">${day}</div>`;
lessonsByDay[day].forEach(lesson => {
html += `
<div class="lesson-card">
<div class="lesson-card-header">
<span class="lesson-group">${escapeHtml(lesson.groupName)}</span>
<span class="lesson-time">${escapeHtml(lesson.time)}</span>
</div>
<div class="lesson-card-body">
<div class="lesson-subject">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-details">
<span class="lesson-detail-item">${escapeHtml(lesson.typeLesson)}</span>
<span class="lesson-detail-item">${escapeHtml(lesson.lessonFormat)}</span>
<span class="lesson-detail-item">${escapeHtml(lesson.week)}</span>
<span class="lesson-detail-item">${escapeHtml(lesson.classroomName)}</span>
</div>
</div>
</div>`;
});
});
schLessonsContainer.innerHTML = html;
} catch (e) {
schLessonsContainer.innerHTML = `<div class="no-lessons">Ошибка загрузки: ${escapeHtml(e.message)}</div>`;
}
}
// ===== При смене преподавателя — подгрузить его занятия =====
schTeacherSelect.addEventListener('change', function () {
const teacherId = this.value;
if (teacherId) {
loadTeacherLessons(teacherId);
} else {
modalLessons.style.display = 'none';
schLessonsContainer.innerHTML = '<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>';
}
});
// ===== Открытие / закрытие оверлея =====
function openOverlay() {
// Автозаполнение преподавателя из фильтра таблицы
const autoTeacherId = getFilteredTeacherId();
if (autoTeacherId) {
schTeacherSelect.value = autoTeacherId;
loadTeacherLessons(autoTeacherId);
}
overlay.classList.add('open');
}
function closeOverlay() {
overlay.classList.remove('open');
resetForm();
}
function resetForm() {
addForm.reset();
schTeacherSelect.value = '';
schGroupSelect.value = '';
schDisciplineSelect.value = '';
schClassroomSelect.value = '';
schDaySelect.value = '';
schTypeSelect.value = '';
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
schTimeSelect.disabled = true;
if (schWeekUpper) schWeekUpper.checked = false;
if (schWeekLower) schWeekLower.checked = false;
if (schFormatOffline) schFormatOffline.checked = true;
modalLessons.style.display = 'none';
schLessonsContainer.innerHTML = '<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>';
hideAlert('sch-add-alert');
}
btnAddLesson.addEventListener('click', openOverlay);
btnClose.addEventListener('click', closeOverlay);
// Закрытие по клику на оверлей (мимо модалок)
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.classList.contains('cs-overlay-scroll')) {
closeOverlay();
}
});
// Закрытие по Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('open')) {
closeOverlay();
}
});
// ===== Отправка формы =====
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('sch-add-alert');
const teacherId = schTeacherSelect.value;
const groupId = schGroupSelect.value;
const subjectId = schDisciplineSelect.value;
const classroomId = schClassroomSelect.value;
const lessonType = schTypeSelect.value;
const dayOfWeek = schDaySelect.value;
const timeSlot = schTimeSelect.value;
const lessonFormat = document.querySelector('input[name="schLessonFormat"]:checked')?.value;
if (!teacherId) { showAlert('sch-add-alert', 'Выберите преподавателя', 'error'); return; }
if (!groupId) { showAlert('sch-add-alert', 'Выберите группу', 'error'); return; }
if (!subjectId) { showAlert('sch-add-alert', 'Выберите дисциплину', 'error'); return; }
if (!classroomId) { showAlert('sch-add-alert', 'Выберите аудиторию', 'error'); return; }
if (!dayOfWeek) { showAlert('sch-add-alert', 'Выберите день недели', 'error'); return; }
if (!timeSlot) { showAlert('sch-add-alert', 'Выберите время', 'error'); return; }
const weekUpperChecked = schWeekUpper?.checked || false;
const weekLowerChecked = schWeekLower?.checked || false;
if (!weekUpperChecked && !weekLowerChecked) {
showAlert('sch-add-alert', 'Не выбран тип недели', 'error');
return;
}
let weekType = null;
if (weekUpperChecked && weekLowerChecked) weekType = 'Обе';
else if (weekUpperChecked) weekType = 'Верхняя';
else if (weekLowerChecked) weekType = 'Нижняя';
try {
await api.post('/api/users/lessons/create', {
teacherId: parseInt(teacherId),
groupId: parseInt(groupId),
subjectId: parseInt(subjectId),
classroomId: parseInt(classroomId),
typeLesson: lessonType,
lessonFormat: lessonFormat,
day: dayOfWeek,
week: weekType,
time: timeSlot
});
showAlert('sch-add-alert', 'Занятие добавлено ✓', 'success');
// Очистить все поля кроме преподавателя (для массового добавления)
schGroupSelect.selectedIndex = 0;
schDisciplineSelect.selectedIndex = 0;
schClassroomSelect.selectedIndex = 0;
schTypeSelect.selectedIndex = 0;
schDaySelect.selectedIndex = 0;
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
schTimeSelect.disabled = true;
schWeekUpper.checked = false;
schWeekLower.checked = false;
document.querySelector('input[name="schLessonFormat"][value="Очно"]').checked = true;
// Обновить занятия преподавателя в модалке 2
if (teacherId) {
await loadTeacherLessons(teacherId);
}
// Обновить основную таблицу
await loadSchedule();
setTimeout(() => {
hideAlert('sch-add-alert');
}, 4000);
} catch (err) {
showAlert('sch-add-alert', err.message || 'Ошибка добавления занятия', 'error');
}
});
// ===================== Инициализация =====================
await Promise.all([
loadSchedule(),
loadGroups(),
loadSubjects(),
loadClassrooms(),
loadTeachers()
]);
}

View File

@@ -24,19 +24,21 @@ export async function initSubjects() {
renderSubjects(allSubjects);
populateSubjectSelect(allSubjects);
} catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects || !subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет дисциплин</td></tr>';
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.name)}</td>
<td>${escapeHtml(s.code || '-')}</td>
<td>${s.departmentId || '-'}</td>
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
</tr>`).join('');
}
@@ -100,11 +102,19 @@ export async function initSubjects() {
e.preventDefault();
hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim();
const code = document.getElementById('new-subject-code').value.trim();
const departmentId = document.getElementById('new-subject-department').value;
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; }
if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; }
try {
const data = await api.post('/api/subjects', { name });
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success');
const data = await api.post('/api/subjects', {
name,
code,
departmentId: Number(departmentId)
});
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success');
createSubjectForm.reset();
loadSubjects();
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }

View File

@@ -7,7 +7,9 @@ const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'b
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const modalBackdrop = document.getElementById('modal-backdrop');
// ===== Оверлей (cs-overlay) =====
const usersOverlay = document.getElementById('users-overlay');
// ===== 1-е модальное окно: Добавить занятие =====
const modalAddLesson = document.getElementById('modal-add-lesson');
@@ -28,7 +30,6 @@ export async function initUsers() {
// ===== 2-е модальное окно: Просмотр занятий =====
const modalViewLessons = document.getElementById('modal-view-lessons');
const modalViewLessonsClose = document.getElementById('modal-view-lessons-close');
const lessonsContainer = document.getElementById('lessons-container');
const modalTeacherName = document.getElementById('modal-teacher-name');
@@ -56,36 +57,6 @@ export async function initUsers() {
"13:20-14:50"
];
// =========================================================
// СИНХРОНИЗАЦИЯ ВЫСОТЫ 1-й МОДАЛКИ -> CSS переменная
// =========================================================
const addLessonContent = document.querySelector('#modal-add-lesson .modal-content');
function setAddLessonHeightVar(px) {
const h = Math.max(0, Math.ceil(px || 0));
document.documentElement.style.setProperty('--add-lesson-height', `${h}px`);
}
function syncAddLessonHeight() {
if (!addLessonContent) return;
if (!modalAddLesson?.classList.contains('open')) {
// если первая модалка закрыта — "шапки" нет
setAddLessonHeightVar(0);
return;
}
setAddLessonHeightVar(addLessonContent.getBoundingClientRect().height);
}
// Авто-обновление при любом изменении размеров первой модалки
if (addLessonContent && 'ResizeObserver' in window) {
const ro = new ResizeObserver(() => syncAddLessonHeight());
ro.observe(addLessonContent);
}
window.addEventListener('resize', () => syncAddLessonHeight());
// =========================================================
// Загрузка справочников
// =========================================================
@@ -196,14 +167,14 @@ export async function initUsers() {
renderUsers(users);
} catch (e) {
usersTbody.innerHTML =
'<tr><td colspan="4" class="loading-row">Ошибка загрузки: ' +
'<tr><td colspan="8" class="loading-row">Ошибка загрузки: ' +
escapeHtml(e.message) + '</td></tr>';
}
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
usersTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет пользователей</td></tr>';
return;
}
@@ -211,6 +182,9 @@ export async function initUsers() {
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td>${escapeHtml(u.fullName || '-')}</td>
<td>${escapeHtml(u.jobTitle || '-')}</td>
<td>${u.departmentName || '-'}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
<button class="btn-delete" data-id="${u.id}">Удалить</button>
@@ -222,25 +196,15 @@ export async function initUsers() {
`).join('');
}
function updateBackdrop() {
if(!modalBackdrop) return;
const anyOpen =
modalAddLesson?.classList.contains('open') ||
modalViewLessons?.classList.contains('open');
modalBackdrop.classList.toggle('open', anyOpen);
// ===== Открытие / закрытие оверлея =====
function openOverlay() {
if (usersOverlay) usersOverlay.classList.add('open');
}
// Клик мимо модалок закроет их, если не надо, то закомментить этот код
modalBackdrop?.addEventListener('click', () => {
if (modalAddLesson?.classList.contains('open')) {
modalAddLesson.classList.remove('open');
function closeOverlay() {
if (usersOverlay) usersOverlay.classList.remove('open');
if (modalViewLessons) modalViewLessons.style.display = 'none';
resetLessonForm();
syncAddLessonHeight();
}
if (modalViewLessons?.classList.contains('open')) {
closeViewLessonsModal();
}
});
// =========================================================
// 1-я модалка: добавление занятия
@@ -267,9 +231,7 @@ export async function initUsers() {
lessonDaySelect.value = '';
updateTimeOptions('');
modalAddLesson.classList.add('open');
updateBackdrop();
requestAnimationFrame(() => syncAddLessonHeight());
openOverlay();
}
addLessonForm.addEventListener('submit', async (e) => {
@@ -286,15 +248,20 @@ export async function initUsers() {
const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value;
if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; }
if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; }
if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; }
if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; }
if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); return; }
const weekUpperChecked = weekUpper?.checked || false;
const weekLowerChecked = weekLower?.checked || false;
if (!weekUpperChecked && !weekLowerChecked) {
showAlert('add-lesson-alert', 'Не выбран тип недели', 'error');
return;
}
let weekType = null;
if (weekUpperChecked && weekLowerChecked) weekType = 'Обе';
else if (weekUpperChecked) weekType = 'Верхняя';
@@ -313,57 +280,45 @@ export async function initUsers() {
time: timeSlot
});
if (modalViewLessons?.classList.contains('open') && currentLessonsTeacherId == userId) {
if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) {
await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName);
}
showAlert('add-lesson-alert', 'Занятие добавлено', 'success');
showAlert('add-lesson-alert', 'Занятие добавлено', 'success');
lessonGroupSelect.value = '';
lessonDisciplineSelect.value = '';
lessonClassroomSelect.value = '';
lessonTypeSelect.value = '';
lessonDaySelect.value = '';
lessonTimeSelect.value = '';
lessonGroupSelect.selectedIndex = 0;
lessonDisciplineSelect.selectedIndex = 0;
lessonClassroomSelect.selectedIndex = 0;
lessonTypeSelect.selectedIndex = 0;
lessonDaySelect.selectedIndex = 0;
lessonTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
lessonTimeSelect.disabled = true;
weekUpper.checked = false;
weekLower.checked = false;
document.querySelector('input[name="lessonFormat"][value="Очно"]').checked = true;
requestAnimationFrame(() => syncAddLessonHeight());
setTimeout(() => {
hideAlert('add-lesson-alert');
syncAddLessonHeight();
}, 3000);
} catch (err) {
showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error');
requestAnimationFrame(() => syncAddLessonHeight());
}
});
lessonDaySelect.addEventListener('change', function () {
updateTimeOptions(this.value);
requestAnimationFrame(() => syncAddLessonHeight());
});
if (modalAddLessonClose) {
modalAddLessonClose.addEventListener('click', () => {
modalAddLesson.classList.remove('open');
resetLessonForm();
syncAddLessonHeight();
updateBackdrop();
});
modalAddLessonClose.addEventListener('click', () => closeOverlay());
}
if (modalAddLesson) {
modalAddLesson.addEventListener('click', (e) => {
if (e.target === modalAddLesson) {
modalAddLesson.classList.remove('open');
resetLessonForm();
syncAddLessonHeight();
updateBackdrop();
// Клик по оверлею (мимо модалок) закрывает всё
if (usersOverlay) {
usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => {
if (e.target.classList.contains('cs-overlay-scroll')) {
closeOverlay();
}
});
}
@@ -378,14 +333,24 @@ export async function initUsers() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
const fullName = document.getElementById('new-fullname').value.trim();
const jobTitle = document.getElementById('new-jobtitle').value.trim();
const departmentId = document.getElementById('new-department').value;
if (!username || !password) {
if (!username || !password || !fullName || !jobTitle || !departmentId) {
showAlert('create-alert', 'Заполните все поля', 'error');
return;
}
try {
const data = await api.post('/api/users', { username, password, role });
const data = await api.post('/api/users', {
username,
password,
role,
fullName,
jobTitle,
departmentId: Number(departmentId)
});
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
createForm.reset();
loadUsers();
@@ -468,48 +433,20 @@ export async function initUsers() {
currentLessonsTeacherId = teacherId;
currentLessonsTeacherName = teacherName || '';
if (modalViewLessons) modalViewLessons.style.display = '';
loadTeacherLessons(teacherId, teacherName);
requestAnimationFrame(() => syncAddLessonHeight());
modalViewLessons.classList.add('open');
updateBackdrop();
// document.body.style.overflow = 'hidden';
}
function closeViewLessonsModal() {
modalViewLessons.classList.remove('open');
updateBackdrop();
// document.body.style.overflow = '';
if (modalViewLessons) modalViewLessons.style.display = 'none';
currentLessonsTeacherId = null;
currentLessonsTeacherName = '';
}
if (modalViewLessonsClose) {
modalViewLessonsClose.addEventListener('click', closeViewLessonsModal);
}
if (modalViewLessons) {
modalViewLessons.addEventListener('click', (e) => {
if (e.target === modalViewLessons) closeViewLessonsModal();
});
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (modalAddLesson?.classList.contains('open')) {
modalAddLesson.classList.remove('open');
resetLessonForm();
syncAddLessonHeight();
updateBackdrop();
return;
}
if (modalViewLessons?.classList.contains('open')) {
closeViewLessonsModal();
return;
if (usersOverlay?.classList.contains('open')) {
closeOverlay();
}
});

View File

@@ -0,0 +1,325 @@
/* ===== Sidebar ===== */
.sidebar {
width: 260px;
min-height: 100vh;
background: var(--bg-sidebar);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--bg-card-border);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.sidebar-close-btn:hover {
background: var(--bg-card-border);
color: var(--text-primary);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.25rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
transition: all var(--transition);
position: relative;
overflow: hidden;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 0 4px 4px 0;
transform: scaleY(0);
transition: transform var(--transition);
opacity: 0;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateX(4px);
}
.nav-item.active {
background: rgba(139, 92, 246, 0.12);
color: var(--accent-hover);
}
[data-theme="light"] .nav-item.active {
background: rgba(99, 102, 241, 0.18);
}
.nav-item.active::before {
transform: scaleY(1);
opacity: 1;
}
.nav-item svg {
transition: transform var(--transition);
}
.nav-item:hover svg,
.nav-item.active svg {
transform: scale(1.15) rotate(-5deg);
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
}
.btn-back {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: background var(--transition), color var(--transition);
position: relative;
}
.btn-back:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ===== Main ===== */
.main {
flex: 1;
margin-left: 260px;
min-height: 100vh;
transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Desktop Collapse State */
@media (min-width: 769px) {
.sidebar.collapsed {
width: 74px;
}
.sidebar.collapsed .logo span {
display: none;
}
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .btn-back span {
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%) translateX(-10px);
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: var(--text-primary);
padding: 0.5rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .sidebar.collapsed .nav-item span,
[data-theme="light"] .sidebar.collapsed .btn-back span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sidebar.collapsed .nav-item:hover span,
.sidebar.collapsed .btn-back:hover span {
opacity: 1;
visibility: visible;
transform: translateY(-50%) translateX(0);
}
.sidebar.collapsed .sidebar-close-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .logo {
justify-content: center;
padding: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.75rem 0;
overflow: visible;
}
.sidebar.collapsed .btn-back {
justify-content: center;
padding: 0.65rem 0;
}
.sidebar.collapsed .sidebar-header {
flex-direction: column;
gap: 1.5rem;
padding: 1.25rem 0;
}
.main.sidebar-collapsed {
margin-left: 74px;
}
.main.sidebar-collapsed .menu-toggle {
display: none;
}
}
.topbar {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--bg-card-border);
transition: border-color 0.4s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.topbar h1 {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
flex: 1;
}
.content {
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: fadeIn 0.2s ease;
}
/* ===== Mobile Menu Toggle ===== */
.menu-toggle {
display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.menu-toggle:hover {
background: var(--bg-hover);
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
z-index: 9;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--transition), visibility var(--transition);
}
/* ===== Responsive Mobile ===== */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.main {
margin-left: 0;
}
.topbar {
padding: 1rem 1.25rem;
}
.content {
padding: 1.25rem;
}
.menu-toggle,
.sidebar-overlay {
display: block;
}
.sidebar-overlay.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}

View File

@@ -0,0 +1,153 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0f;
--bg-sidebar: rgba(255, 255, 255, 0.02);
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-placeholder: #475569;
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius-sm: 10px;
--radius-md: 16px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* ===== Light Theme ===== */
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-sidebar: rgba(255, 255, 255, 0.7);
--bg-card: rgba(255, 255, 255, 0.75);
--bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-placeholder: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #d946ef;
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
transition: background 0.4s ease, color 0.4s ease;
}
/* ===== Animations ===== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Theme Toggle ===== */
.theme-toggle {
width: 42px;
height: 42px;
border: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
z-index: 100;
flex-shrink: 0;
}
.theme-toggle svg {
width: 20px;
height: 20px;
transition: transform 0.4s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px var(--accent-glow);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle--fixed {
position: fixed;
top: 1.25rem;
right: 1.25rem;
}
/* ===== Settings Placeholder ===== */
.settings-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 4rem 2rem;
animation: fadeIn 0.4s ease both;
}
.settings-placeholder .icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(236, 72, 153, 0.15));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
}
.settings-placeholder h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.settings-placeholder p {
color: var(--text-secondary);
font-size: 0.95rem;
max-width: 400px;
line-height: 1.6;
}

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройки — Magistr</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="general">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Общие настройки</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/admin/" class="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Main -->
<main class="main">
<header class="topbar">
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 id="page-title">Загрузка...</h1>
</header>
<section class="content" id="app-content">
<!-- Content loaded via JS -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
// Settings page main.js
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
// Auth check
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
window.location.href = '/';
}
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Configuration
const ROUTES = {
general: { title: 'Общие настройки', file: 'views/general.html' },
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main');
// Init sidebar state from localStorage
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Menu Toggle (Hamburger)
menuToggle.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
});
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
try {
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Failed to load view');
const html = await response.text();
appContent.innerHTML = html;
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div style="padding:1rem;color:var(--error);">Ошибка загрузки: ${e.message}</div>`;
console.error(e);
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Load default tab
switchTab('general');

View File

@@ -0,0 +1,11 @@
<div class="settings-placeholder">
<div class="icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
<h2>Общие настройки</h2>
<p>Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.</p>
</div>

View File

@@ -0,0 +1,75 @@
<div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность аудиторий</h2>
</div>
<div class="filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
<div class="form-group">
<label>Корпус</label>
<div class="custom-multi-select">
<div class="select-box" id="building-box">
<span class="select-text" id="building-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="building-menu">
<div id="building-checkboxes" class="checkbox-group-vertical"></div>
</div>
</div>
</div>
<div class="form-group">
<label>Вместимость</label>
<div class="custom-multi-select">
<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 class="form-group">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="equipment-box">
<span class="select-text" id="equipment-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="equipment-menu">
<div id="equipment-checkboxes" class="checkbox-group-vertical"></div>
</div>
</div>
</div>
<div class="form-group" style="max-width: 200px;">
<label>Дата</label>
<input type="date" id="workload-date">
</div>
</div>
<!-- Table Container -->
<div class="workload-grid-container">
<table class="workload-table" id="workload-table">
<thead>
<tr id="workload-header-row">
<th class="top-left-cell">
<span class="top-label">Аудитория</span>
<span class="bottom-label">Время</span>
</th>
<!-- Rendered by JS -->
</tr>
</thead>
<tbody id="workload-tbody">
<!-- Rendered by JS -->
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,180 @@
<div class="card create-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>Запрос расписания кафедры</h2>
<button id="btn-create-schedule" class="btn-primary" style="margin-top: 0;">Создать запись</button>
</div>
<form id="department-schedule-form">
<div class="form-row">
<div class="form-group">
<label for="filter-department">Кафедра</label>
<select id="filter-department" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Семестр</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="radio" name="semesterType" value="autumn" id="sem-autumn" required>
<span class="checkbox-btn">Осенний</span>
</label>
<label class="btn-checkbox">
<input type="radio" name="semesterType" value="spring" id="sem-spring" required>
<span class="checkbox-btn">Весенний</span>
</label>
</div>
</div>
<div class="form-group">
<label for="filter-period">Учебный год</label>
<select id="filter-period" required>
<option value="">Выберите...</option>
<option value="2026-2027">2026/2027</option>
<option value="2025-2026">2025/2026</option>
<option value="2024-2025">2024/2025</option>
<option value="2023-2024">2023/2024</option>
<option value="2022-2023">2022/2023</option>
</select>
</div>
<button type="submit" class="btn-primary" style="align-self: flex-end;">Запросить</button>
</div>
<div class="form-alert" id="schedule-form-alert" role="alert"></div>
</form>
</div>
<!-- ===== Общий оверлей для обеих модалок ===== -->
<div class="cs-overlay" id="cs-overlay">
<div class="cs-overlay-scroll">
<!-- Модалка 1: Форма создания записи -->
<div class="cs-modal cs-modal-form card" id="modal-create-schedule">
<div class="cs-modal-header">
<h2>Создать запись (к/ф)</h2>
<button class="btn-close-panel" id="modal-create-schedule-close" title="Закрыть (Esc)">&times;</button>
</div>
<form id="create-schedule-form">
<input type="hidden" id="cs-department-id" value="">
<div class="form-row"
style="align-items: flex-start; gap: 1rem; flex-wrap: wrap; width: 100%; justify-content: space-between;">
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-period">Учебный год</label>
<select id="cs-period" required>
<option value="">Выберите...</option>
<option value="2026-2027">2026/2027</option>
<option value="2025-2026">2025/2026</option>
<option value="2024-2025">2024/2025</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label>Семестр</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="radio" name="csSemesterType" value="autumn" required>
<span class="checkbox-btn">Осенний</span>
</label>
<label class="btn-checkbox">
<input type="radio" name="csSemesterType" value="spring" required>
<span class="checkbox-btn">Весенний</span>
</label>
</div>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-group">Группа</label>
<select id="cs-group" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-subject">Дисциплина</label>
<select id="cs-subject" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-lesson-type">Вид занятий</label>
<select id="cs-lesson-type" required>
<option value="">Выберите тип</option>
<option value="1">Лекция</option>
<option value="2">Практическая работа</option>
<option value="3">Лабораторная работа</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 150px;">
<label for="cs-hours">Часов (семестр)</label>
<input type="number" id="cs-hours" required min="1" max="500" placeholder="Например: 36">
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label>Деление на подгруппы</label>
<div style="display: flex; gap: 0.5rem; align-items: center; height: 42px;">
<label class="btn-checkbox" style="width:100%;">
<input type="checkbox" id="cs-division" value="true">
<span class="checkbox-btn" style="width:100%; text-align:center;">Есть деление</span>
</label>
</div>
</div>
<div class="form-group" style="flex: 1 1 250px;">
<label for="cs-teacher">Преподаватель</label>
<select id="cs-teacher" required>
<option value="">Выберите преподавателя</option>
</select>
</div>
<div class="form-group" style="flex: 0 0 auto; display:flex; align-items: flex-end;">
<button type="submit" class="btn-primary" style="white-space: nowrap;">Добавить в список</button>
</div>
</div>
<div class="form-alert" id="create-schedule-alert" role="alert" style="margin-top: 1rem;"></div>
</form>
</div>
<!-- Модалка 2: Таблица подготовленных записей -->
<div class="cs-modal cs-modal-table card" id="modal-view-schedules" style="display: none;">
<div class="cs-modal-header">
<h2>Подготовленные записи</h2>
<div style="display:flex; gap: 0.75rem; align-items:center;">
<button id="btn-save-schedules" class="btn-primary">Сохранить в БД</button>
</div>
</div>
<div class="form-alert" id="save-schedules-alert" role="alert" style="margin-bottom: 1rem;"></div>
<div class="table-wrap">
<table id="prepared-schedules-table">
<thead>
<tr>
<th>Уч. год</th>
<th>Семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид</th>
<th>Часы</th>
<th>Деление</th>
<th>Преподаватель</th>
<th>Действие</th>
</tr>
</thead>
<tbody id="prepared-schedules-tbody">
<tr>
<td colspan="9" class="loading-row">Нет записей</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="table-wrap" id="schedule-blocks-container">
<!-- Сгенерированные блоки таблиц будут появляться здесь -->
</div>

View File

@@ -0,0 +1,80 @@
<!-- ===== 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>
</tr>
</thead>
<tbody id="departments-tbody">
<tr>
<td colspan="3" 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>
</tr>
</thead>
<tbody id="specialties-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -17,6 +17,18 @@
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="new-group-department">ID кафедры</label>
<input type="number" id="new-group-department" placeholder="ID" required>
</div>
<div class="form-group">
<label for="new-group-yearStartStudy">Год начала обучения</label>
<input type="number" id="new-group-yearStartStudy" required pattern="^20\d{2}$" maxlength="3" placeholder="2026">
</div>
<div class="form-group">
<label for="new-group-speciality-code">Код специальности</label>
<input type="text" id="new-group-speciality-code" placeholder="09.03.01" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
@@ -41,12 +53,15 @@
<th>Название</th>
<th>Численность (чел.)</th>
<th>Форма обучения</th>
<th>ID кафедры</th>
<th>Курс</th>
<th>Код специальности</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
<td colspan="8" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>

View File

@@ -1,5 +1,8 @@
<div class="card">
<div class="card-header-row">
<h2>Расписание занятий</h2>
<button class="btn-primary" id="sch-btn-add-lesson">Добавить занятие</button>
</div>
<div class="table-wrap">
<table id="schedule-table">
<thead>
@@ -35,9 +38,142 @@
</thead>
<tbody id="schedule-tbody">
<tr>
<td colspan="8" class="loading-row">Загрузка...</td>
<td colspan="11" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ===== Оверлей для модалок добавления занятия ===== -->
<div class="cs-overlay" id="sch-overlay">
<div class="cs-overlay-scroll">
<!-- Модалка 1: Форма добавления -->
<div class="cs-modal cs-modal-form card" id="sch-modal-form">
<div class="cs-modal-header">
<h2>Добавить занятие</h2>
<button class="btn-close-panel" id="sch-modal-close">&times;</button>
</div>
<form id="sch-add-lesson-form">
<div class="form-row" style="align-items: flex-end; gap: 1rem; flex-wrap: wrap; width: 100%; justify-content: space-between;">
<!-- Преподаватель -->
<div class="form-group" style="flex: 0 1 auto; max-width: 220px">
<label for="sch-teacher">Преподаватель</label>
<select id="sch-teacher" required>
<option value="">Выберите преподавателя</option>
</select>
</div>
<!-- Группа -->
<div class="form-group" style="flex: 0 1 auto; max-width: 190px">
<label for="sch-group">Группа</label>
<select id="sch-group" required>
<option value="">Выберите группу</option>
</select>
</div>
<!-- Дисциплина -->
<div class="form-group" style="flex: 0 1 auto; max-width: 220px">
<label for="sch-discipline">Дисциплина</label>
<select id="sch-discipline" required>
<option value="">Выберите дисциплину</option>
</select>
</div>
<!-- Аудитория -->
<div class="form-group" style="flex: 0 1 auto; max-width: 215px">
<label for="sch-classroom">Аудитория</label>
<select id="sch-classroom" required>
<option value="">Выберите аудиторию</option>
</select>
</div>
<!-- День недели -->
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
<label for="sch-day">День недели</label>
<select id="sch-day" required>
<option value="">Выберите день</option>
<option value="Понедельник">Понедельник</option>
<option value="Вторник">Вторник</option>
<option value="Среда">Среда</option>
<option value="Четверг">Четверг</option>
<option value="Пятница">Пятница</option>
<option value="Суббота">Суббота</option>
</select>
</div>
<!-- Неделя -->
<div class="form-group" style="flex: 0 1 auto; max-width: 192px">
<label>Неделя</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="checkbox" name="schWeekType" value="Верхняя" id="sch-week-upper">
<span class="checkbox-btn">Верхняя</span>
</label>
<label class="btn-checkbox">
<input type="checkbox" name="schWeekType" value="Нижняя" id="sch-week-lower">
<span class="checkbox-btn">Нижняя</span>
</label>
</div>
</div>
<!-- Тип занятия -->
<div class="form-group" style="flex: 0 1 auto; max-width: 160px">
<label for="sch-type">Тип занятия</label>
<select id="sch-type" required>
<option value="">Выберите тип</option>
<option value="Практическая работа">Практическая</option>
<option value="Лекция">Лекция</option>
<option value="Лабораторная работа">Лабораторная</option>
</select>
</div>
<!-- Формат занятия -->
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
<label>Формат занятия</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="radio" name="schLessonFormat" value="Очно" id="sch-format-offline" checked>
<span class="checkbox-btn">Очно</span>
</label>
<label class="btn-checkbox">
<input type="radio" name="schLessonFormat" value="Онлайн" id="sch-format-online">
<span class="checkbox-btn">Онлайн</span>
</label>
</div>
</div>
<!-- Время занятия -->
<div class="form-group" style="flex: 0 0 auto; max-width: 235px">
<label for="sch-time">Время занятия</label>
<select id="sch-time" required disabled>
<option value="">Сначала выберите день</option>
</select>
</div>
<!-- Кнопка Сохранить -->
<div class="form-group" style="flex: 0 0 auto;">
<button type="submit" class="btn-primary" style="white-space: nowrap;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="sch-add-alert" role="alert" style="margin-top: 1rem;"></div>
</form>
</div>
<!-- Модалка 2: Занятия выбранного преподавателя -->
<div class="cs-modal cs-modal-table card" id="sch-modal-lessons" style="display:none;">
<div class="cs-modal-header">
<h2 id="sch-teacher-name">Занятия преподавателя</h2>
</div>
<div class="lessons-container" id="sch-lessons-container">
<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>
</div>
</div>
</div>
</div>

View File

@@ -7,6 +7,14 @@
<label for="new-subject-name">Название дисциплины</label>
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
</div>
<div class="form-group">
<label for="new-subject-code">Код предмета</label>
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
</div>
<div class="form-group">
<label for="new-subject-department">Идентификатор кафедры</label>
<input type="number" id="new-subject-department" placeholder="ID кафедры" required>
</div>
<button type="submit" class="btn-primary">Добавить</button>
</div>
<div class="form-alert" id="create-subject-alert" role="alert"></div>
@@ -43,12 +51,14 @@
<tr>
<th>ID</th>
<th>Название</th>
<th>Код предмета</th>
<th>Кафедра (ID)</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
<td colspan="5" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>

View File

@@ -19,6 +19,18 @@
<option value="ADMIN">Администратор</option>
</select>
</div>
<div class="form-group">
<label for="new-fullname">ФИО пользователя</label>
<input type="text" id="new-fullname" placeholder="Иванов Иван Иванович" required>
</div>
<div class="form-group">
<label for="new-jobtitle">Должность</label>
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
</div>
<div class="form-group">
<label for="new-department">ID Кафедры</label>
<input type="number" id="new-department" placeholder="ID" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-alert" role="alert"></div>
@@ -33,24 +45,32 @@
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>ФИО</th>
<th>Должность</th>
<th>Кафедра</th>
<th>Роль</th>
<th>Действия</th>
<th colspan="2">Действия</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
<td colspan="8" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add Lesson Modal -->
<div class="modal-overlay" id="modal-add-lesson">
<div class="modal-content card">
<!-- ===== Оверлей для модалок добавления/просмотра занятий ===== -->
<div class="cs-overlay" id="users-overlay">
<div class="cs-overlay-scroll">
<!-- Модалка 1: Форма добавления -->
<div class="cs-modal cs-modal-form card" id="modal-add-lesson">
<div class="cs-modal-header">
<h2>Добавить занятие</h2>
<button class="modal-close" id="modal-add-lesson-close">&times;</button>
<button class="btn-close-panel" id="modal-add-lesson-close">&times;</button>
</div>
<form id="add-lesson-form">
<input type="hidden" id="lesson-user-id">
@@ -95,7 +115,7 @@
</select>
</div>
<!-- Тип недели (ВЕРТИКАЛЬНО) -->
<!-- Тип недели -->
<div class="form-group" style="flex: 0 1 auto; max-width: 192px">
<label>Неделя</label>
<div style="display: flex; gap: 0.2rem;">
@@ -121,7 +141,7 @@
</select>
</div>
<!-- Формат занятия (ВЕРТИКАЛЬНО) -->
<!-- Формат занятия -->
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
<label>Формат занятия</label>
<div style="display: flex; gap: 0.2rem;">
@@ -144,29 +164,26 @@
</select>
</div>
<!-- Кнопка Сохранить (в том же ряду) -->
<!-- Кнопка Сохранить -->
<div class="form-group" style="flex: 0 0 auto;">
<button type="submit" class="btn-primary" style="white-space: nowrap;">Сохранить</button>
</div>
</div> <!-- Закрытие form-row -->
</div>
<div class="form-alert" id="add-lesson-alert" role="alert" style="margin-top: 1rem;"></div>
</form>
</div>
<!-- View Teacher Lessons Modal -->
<div class="modal-overlay" id="modal-view-lessons">
<div class="modal-content view-lessons-modal">
<div class="modal-header">
<h2 id="modal-teacher-name">Занятия преподавателя</h2>
<button class="modal-close" id="modal-view-lessons-close">&times;</button>
</div>
<!-- Модалка 2: Просмотр занятий преподавателя -->
<div class="cs-modal cs-modal-table card" id="modal-view-lessons" style="display:none;">
<div class="cs-modal-header">
<h2 id="modal-teacher-name">Занятия преподавателя</h2>
</div>
<div class="lessons-container" id="lessons-container">
<!-- Фильтры по дням (добавим позже) -->
<div class="loading-lessons">Загрузка занятий...</div>
</div>
</div>
</div>
</div>
<div id="modal-backdrop"></div>

View File

@@ -1,6 +1,34 @@
(() => {
'use strict';
// --- OpenTelemetry Frontend Instrumentation ---
// Загружаем OTel только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => {
const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http');
const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web');
const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation');
const { Resource } = await import('https://esm.sh/@opentelemetry/resources');
const exporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces'
});
const provider = new WebTracerProvider({
resource: new Resource({ 'service.name': 'magistr-frontend' }),
});
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register();
registerInstrumentations({
instrumentations: [getWebAutoInstrumentations()]
});
console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде.");
}).catch(e => console.error("Ошибка загрузки OTel:", e));
}
// ----------------------------------------------
const form = document.getElementById('login-form');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
@@ -115,6 +143,7 @@
if (data.token) localStorage.setItem('token', data.token);
if (data.role) localStorage.setItem('role', data.role);
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
const redirect = data.redirect || '/';
setTimeout(() => { window.location.href = redirect; }, 400);