Compare commits
69 Commits
9e55472de7
...
department
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813e81be70 | ||
|
|
e92aa74048 | ||
|
|
48e8d4e631 | ||
|
|
c7145de95a | ||
|
|
c7594c4380 | ||
| ac69a57290 | |||
| c82e3feaed | |||
|
|
3cdb8614cb | ||
|
|
73995f86f8 | ||
|
|
cd6cc6f5f7 | ||
| 2be2534a1e | |||
| b14d937062 | |||
| 9d06c99d06 | |||
|
|
522bc97b8c | ||
|
|
d0a8148fa0 | ||
|
|
0b9d063266 | ||
|
|
6f33e23e17 | ||
|
|
bfdcb58c7d | ||
|
|
e015758caf | ||
|
|
6be8db0cd0 | ||
|
|
7a2c385257 | ||
| f7483e7aeb | |||
|
|
55da934545 | ||
|
|
e71bcee9b5 | ||
| 7ce0d1e501 | |||
|
|
3861fa05b5 | ||
|
|
599e284ea9 | ||
|
|
ec7e615557 | ||
|
|
9e7b35aa0b | ||
|
|
4915e6f33b | ||
|
|
798d61c7ea | ||
|
|
0817961d97 | ||
|
|
49ca2e17b6 | ||
|
|
c07e49ca98 | ||
|
|
b89d1c7f72 | ||
| 6774ebb766 | |||
| f7fb524bb0 | |||
| d78e675a71 | |||
|
|
8cf086d3e9 | ||
| f39c3d1bbb | |||
|
|
dc1c343174 | ||
| 74fcd07e25 | |||
|
|
8ced8ae669 | ||
|
|
f519650bbb | ||
|
|
7fac9f744d | ||
|
|
18d099460d | ||
|
|
59b6704be9 | ||
|
|
220b99594f | ||
|
|
c10198515c | ||
|
|
a8144acb8b | ||
|
|
04feb5a3c3 | ||
|
|
d69eab1c12 | ||
|
|
f3ea05cd17 | ||
|
|
05fcf86e32 | ||
|
|
9f124c52a5 | ||
| 8df736ae36 | |||
|
|
10c06e726a | ||
|
|
9d2de1faaf | ||
| 24caa148e1 | |||
|
|
59caa9d6cc | ||
|
|
bad1215341 | ||
|
|
ccdc371c3a | ||
|
|
4c2293b620 | ||
|
|
6ea420e529 | ||
|
|
75b1ad166e | ||
|
|
abad3776db | ||
|
|
13b3a5c481 | ||
|
|
3579ef9f1c | ||
|
|
14cc006f06 |
86
.agents/skills/auto-update-docs/SKILL.md
Normal file
86
.agents/skills/auto-update-docs/SKILL.md
Normal 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/`, обновить все вхождения
|
||||||
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
177
.agents/skills/frontend-design/LICENSE.txt
Normal 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
|
||||||
42
.agents/skills/frontend-design/SKILL.md
Normal file
42
.agents/skills/frontend-design/SKILL.md
Normal 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) в разных генерациях кода.
|
||||||
|
|
||||||
|
**ВАЖНО**: Сопоставляйте сложность реализации с эстетическим видением. Максималистские дизайны требуют сложного кода с масштабными анимациями и эффектами. Минималистские или утонченные дизайны требуют сдержанности, точности и крайне внимательного отношения к отступам, типографике и тонким деталям. Элегантность исходит из хорошего воплощения видения.
|
||||||
|
|
||||||
|
Помните: ИИ способен на выдающуюся творческую работу. Не сдерживайтесь, покажите, что можно создать на самом деле, когда вы мыслите нестандартно и полностью привержены особому видению.
|
||||||
0
.gitea/workflows/docker-build.yaml
Normal file → Executable file
0
.gitea/workflows/docker-build.yaml
Normal file → Executable file
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,9 +7,7 @@ backend/build/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
.agents
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
AGENTS.md
|
skills-lock.json
|
||||||
GEMINI.md
|
|
||||||
89
AGENTS.md
Executable file
89
AGENTS.md
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
# AGENTS.md - Руководство для агентных помощников
|
||||||
|
|
||||||
|
## Обзор проекта
|
||||||
|
|
||||||
|
Проект представляет собой систему управления университетским расписанием.
|
||||||
|
- **Backend**: Java 17, Spring Boot 3.2.5 (Мультитенантная архитектура: отдельная БД для каждого клиента)
|
||||||
|
- **Frontend**: Vanilla JavaScript + HTML/CSS (без фреймворков)
|
||||||
|
- **Database**: PostgreSQL (множество БД, управляются через Flyway)
|
||||||
|
- **Локальный URL**: localhost:80
|
||||||
|
- **Продакшн URL**: https://magistr.zuev.company
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура директорий
|
||||||
|
|
||||||
|
```
|
||||||
|
magistr/
|
||||||
|
├── backend/ # Java Spring Boot приложение
|
||||||
|
│ └── src/main/java/com/magistr/app/
|
||||||
|
│ ├── controller/ # REST контроллеры
|
||||||
|
│ ├── model/ # JPA сущности
|
||||||
|
│ ├── dto/ # Data Transfer Objects
|
||||||
|
│ ├── repository/ # Spring Data JPA репозитории
|
||||||
|
│ ├── config/ # Конфигурация приложения
|
||||||
|
│ ├── utils/ # Утилиты
|
||||||
|
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
||||||
|
├── frontend/ # Статические файлы
|
||||||
|
│ ├── admin/ # Интерфейс администратора
|
||||||
|
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||||
|
│ ├── teacher/ # Интерфейс преподавателя
|
||||||
|
│ └── student/ # Интерфейс студента
|
||||||
|
├── docs/ # 📖 Документация проекта
|
||||||
|
├── compose.yaml # Docker Compose конфигурация
|
||||||
|
└── .env # Переменные окружения
|
||||||
|
```
|
||||||
|
|
||||||
|
**Внешние зависимости (родительская директория)**
|
||||||
|
|
||||||
|
На уровень выше расположен конфиг kubernetes `../k8s/`, все файлы сборки на проде расположены там.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый справочник команд
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка и запуск
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Полный сброс БД
|
||||||
|
docker compose down -v && docker compose up -d
|
||||||
|
|
||||||
|
# Логи конкретного сервиса
|
||||||
|
docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее — см. [`docs/README.md`](docs/README.md) и [`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критические правила для агентов
|
||||||
|
|
||||||
|
### Flyway миграции
|
||||||
|
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway.
|
||||||
|
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
||||||
|
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.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
178
SCHEDULE_PROPOSAL.md
Normal 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`: день недели (1–7, Пн–Вс).
|
||||||
|
* `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(1–7), 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 1–6).
|
||||||
|
* `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
218
SCHEDULE_TASKS.md
Normal 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 (1–7)
|
||||||
|
- [ ] Добавить 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 1–6)
|
||||||
|
- [ ] Трансформация `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: Формат (Очно/Онлайн)
|
||||||
|
- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты)
|
||||||
|
- [ ] Удаление правила с подтверждением
|
||||||
@@ -4,9 +4,16 @@ COPY pom.xml .
|
|||||||
RUN mvn dependency:go-offline -B
|
RUN mvn dependency:go-offline -B
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN mvn package -DskipTests -B
|
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
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
|
||||||
|
# Best practice: run as a non-root user
|
||||||
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
|
USER spring:spring
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/target/app.jar app.jar
|
COPY --from=build /app/target/app.jar app.jar
|
||||||
|
COPY --from=build /app/opentelemetry-javaagent.jar opentelemetry-javaagent.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-javaagent:opentelemetry-javaagent.jar", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Flyway Database Migrations -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
@@ -43,6 +49,20 @@
|
|||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-crypto</artifactId>
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package com.magistr.app;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class})
|
||||||
|
@EnableScheduling
|
||||||
public class Application {
|
public class Application {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,50 +1,39 @@
|
|||||||
package com.magistr.app.config;
|
package com.magistr.app.config;
|
||||||
|
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.config.tenant.TenantConfig;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.Optional;
|
/**
|
||||||
|
* При запуске приложения инициализирует БД для каждого тенанта.
|
||||||
|
* Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class DataInitializer implements CommandLineRunner {
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final TenantConfigWatcher configWatcher;
|
||||||
|
|
||||||
public DataInitializer(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
public DataInitializer(TenantRoutingDataSource routingDataSource,
|
||||||
this.userRepository = userRepository;
|
TenantConfigWatcher configWatcher) {
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.routingDataSource = routingDataSource;
|
||||||
|
this.configWatcher = configWatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
Optional<User> existing = userRepository.findByUsername("admin");
|
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
|
||||||
|
|
||||||
if (existing.isEmpty()) {
|
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
|
||||||
User admin = new User();
|
configWatcher.initDatabaseForTenant(tenant);
|
||||||
admin.setUsername("admin");
|
|
||||||
admin.setPassword(passwordEncoder.encode("admin"));
|
|
||||||
admin.setRole(Role.ADMIN);
|
|
||||||
userRepository.save(admin);
|
|
||||||
log.info("Created default admin user");
|
|
||||||
} else {
|
|
||||||
User admin = existing.get();
|
|
||||||
if (!passwordEncoder.matches("admin", admin.getPassword())) {
|
|
||||||
admin.setPassword(passwordEncoder.encode("admin"));
|
|
||||||
admin.setRole(Role.ADMIN);
|
|
||||||
userRepository.save(admin);
|
|
||||||
log.info("Reset admin password (hash was invalid)");
|
|
||||||
} else {
|
|
||||||
log.info("Admin user already exists with correct password");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("Database initialization complete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
127
backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
Executable file
@@ -0,0 +1,127 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет K8s ConfigMap tenants-config через Kubernetes REST API.
|
||||||
|
*
|
||||||
|
* Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token).
|
||||||
|
* При запуске вне K8s (локальная разработка) — просто логирует предупреждение.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ConfigMapUpdater {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class);
|
||||||
|
|
||||||
|
private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
|
||||||
|
private static final String K8S_API_BASE = "https://kubernetes.default.svc";
|
||||||
|
private static final String CONFIGMAP_NAME = "tenants-config";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final boolean runningInK8s;
|
||||||
|
|
||||||
|
public ConfigMapUpdater() {
|
||||||
|
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
|
||||||
|
if (!runningInK8s) {
|
||||||
|
log.info("Not running in K8s — ConfigMap updates will be skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет ConfigMap tenants-config с новым списком тенантов.
|
||||||
|
* @return true если обновление успешно (или мы не в K8s)
|
||||||
|
*/
|
||||||
|
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||||
|
if (!runningInK8s) {
|
||||||
|
log.warn("Not in K8s, skipping ConfigMap update");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String token = Files.readString(Path.of(TOKEN_PATH)).trim();
|
||||||
|
String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim();
|
||||||
|
|
||||||
|
// Формируем JSON для тенантов
|
||||||
|
String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter()
|
||||||
|
.writeValueAsString(tenants);
|
||||||
|
|
||||||
|
// Strategic merge patch для ConfigMap
|
||||||
|
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"data", Map.of("tenants.json", tenantsJson)
|
||||||
|
));
|
||||||
|
|
||||||
|
String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s",
|
||||||
|
K8S_API_BASE, namespace, CONFIGMAP_NAME);
|
||||||
|
|
||||||
|
// Создаём HttpClient с отключённой проверкой сертификатов
|
||||||
|
// (внутри кластера используется self-signed CA)
|
||||||
|
HttpClient client = createInsecureClient();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||||
|
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error updating ConfigMap: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт HttpClient, который доверяет self-signed сертификатам K8s API.
|
||||||
|
*/
|
||||||
|
private HttpClient createInsecureClient() {
|
||||||
|
try {
|
||||||
|
TrustManager[] trustAll = new TrustManager[]{
|
||||||
|
new X509TrustManager() {
|
||||||
|
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||||
|
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
|
||||||
|
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||||
|
sslContext.init(null, trustAll, new SecureRandom());
|
||||||
|
|
||||||
|
return HttpClient.newBuilder()
|
||||||
|
.sslContext(sslContext)
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to create insecure client, using default: {}", e.getMessage());
|
||||||
|
return HttpClient.newHttpClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java
Executable file
41
backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель конфигурации тенанта (университета).
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class TenantConfig {
|
||||||
|
|
||||||
|
private String name; // "ЮЗГУ", "МГУ"
|
||||||
|
private String domain; // "swsu", "mgu" (поддомен)
|
||||||
|
private String url; // "jdbc:postgresql://192.168.1.50:5432/magistr_db"
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
public TenantConfig() {}
|
||||||
|
|
||||||
|
public TenantConfig(String name, String domain, String url, String username, String password) {
|
||||||
|
this.name = name;
|
||||||
|
this.domain = domain;
|
||||||
|
this.url = url;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public void setDomain(String domain) { this.domain = domain; }
|
||||||
|
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public void setUrl(String url) { this.url = url; }
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
}
|
||||||
156
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
156
backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
Executable file
@@ -0,0 +1,156 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Периодически перечитывает tenants.json (mounted ConfigMap).
|
||||||
|
* Если ConfigMap был обновлён через K8s API, этот компонент
|
||||||
|
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
||||||
|
*
|
||||||
|
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TenantConfigWatcher {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||||
|
|
||||||
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
|
private final DataSource dataSource;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||||
|
private String lastConfigHash = "";
|
||||||
|
|
||||||
|
public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) {
|
||||||
|
this.routingDataSource = routingDataSource;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||||
|
public void watchForChanges() {
|
||||||
|
try {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (!file.exists()) return;
|
||||||
|
|
||||||
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
|
String hash = Integer.toHexString(content.hashCode());
|
||||||
|
|
||||||
|
if (hash.equals(lastConfigHash)) {
|
||||||
|
return; // Ничего не изменилось
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
|
||||||
|
lastConfigHash = hash;
|
||||||
|
|
||||||
|
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||||
|
syncTenants(newTenants);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error watching tenants config: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода).
|
||||||
|
*/
|
||||||
|
public void refreshHash() {
|
||||||
|
try {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (file.exists()) {
|
||||||
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
|
lastConfigHash = Integer.toHexString(content.hashCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to refresh config hash: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизирует in-memory тенантов с конфигом из файла.
|
||||||
|
*/
|
||||||
|
private void syncTenants(List<TenantConfig> newTenants) {
|
||||||
|
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
|
||||||
|
Set<String> newDomains = newTenants.stream()
|
||||||
|
.map(t -> t.getDomain().toLowerCase())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// Добавить новые тенанты
|
||||||
|
for (TenantConfig tenant : newTenants) {
|
||||||
|
String domain = tenant.getDomain().toLowerCase();
|
||||||
|
if (!current.containsKey(domain)) {
|
||||||
|
log.info("Adding new tenant '{}' from ConfigMap update", domain);
|
||||||
|
routingDataSource.addTenant(tenant);
|
||||||
|
// Инициализируем БД для нового тенанта
|
||||||
|
initDatabaseForTenant(tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить тенанты, которых больше нет в конфиге
|
||||||
|
for (String existingDomain : new ArrayList<>(current.keySet())) {
|
||||||
|
if (!newDomains.contains(existingDomain)) {
|
||||||
|
log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain);
|
||||||
|
routingDataSource.removeTenant(existingDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет миграции Flyway для конкретного тенанта пи подключении.
|
||||||
|
* Если БД уже существует, но история Flyway пуста —
|
||||||
|
* делает baseline (считает V1_init.sql уже выполненным).
|
||||||
|
*/
|
||||||
|
public void initDatabaseForTenant(TenantConfig tenant) {
|
||||||
|
String domain = tenant.getDomain();
|
||||||
|
try {
|
||||||
|
TenantContext.setCurrentTenant(domain);
|
||||||
|
|
||||||
|
log.info("[{}] Starting Flyway migrations...", domain);
|
||||||
|
|
||||||
|
// Получаем DataSource конкретно для этого тенанта
|
||||||
|
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
|
||||||
|
if (tenantDs == null) {
|
||||||
|
// Если ещё не resolve'нулся (первый запуск), берём обёртку
|
||||||
|
tenantDs = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
org.flywaydb.core.Flyway flyway = org.flywaydb.core.Flyway.configure()
|
||||||
|
.dataSource(tenantDs)
|
||||||
|
.baselineOnMigrate(true)
|
||||||
|
.baselineVersion("1")
|
||||||
|
.load();
|
||||||
|
|
||||||
|
flyway.migrate();
|
||||||
|
log.info("[{}] Flyway migrations completed successfully", domain);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
|
||||||
|
} finally {
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java
Executable file
22
backend/src/main/java/com/magistr/app/config/tenant/TenantContext.java
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThreadLocal хранилище текущего тенанта (домена).
|
||||||
|
* Устанавливается в TenantInterceptor на каждый HTTP-запрос.
|
||||||
|
*/
|
||||||
|
public class TenantContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static String getCurrentTenant() {
|
||||||
|
return CURRENT_TENANT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setCurrentTenant(String tenant) {
|
||||||
|
CURRENT_TENANT.set(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CURRENT_TENANT.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
156
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
156
backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
Executable file
@@ -0,0 +1,156 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||||
|
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||||
|
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация мультитенантного DataSource.
|
||||||
|
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||||
|
*
|
||||||
|
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||||
|
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class TenantDataSourceConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url:}")
|
||||||
|
private String defaultDbUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.username:}")
|
||||||
|
private String defaultDbUsername;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.password:}")
|
||||||
|
private String defaultDbPassword;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public DataSource dataSource() {
|
||||||
|
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||||
|
|
||||||
|
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||||
|
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||||
|
|
||||||
|
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||||
|
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||||
|
TenantConfig defaultTenant = new TenantConfig(
|
||||||
|
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||||
|
);
|
||||||
|
tenants.add(defaultTenant);
|
||||||
|
log.info("No tenants config found, using default datasource: {}", defaultDbUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем тенантов
|
||||||
|
for (TenantConfig tenant : tenants) {
|
||||||
|
try {
|
||||||
|
routingDataSource.addTenant(tenant);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||||
|
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||||
|
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||||
|
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||||
|
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||||
|
|
||||||
|
TenantConfig h2Fallback = new TenantConfig(
|
||||||
|
"H2 Placeholder", "default",
|
||||||
|
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||||
|
"sa", ""
|
||||||
|
);
|
||||||
|
routingDataSource.addTenant(h2Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return routingDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantRoutingDataSource tenantRoutingDataSource(DataSource dataSource) {
|
||||||
|
return (TenantRoutingDataSource) dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
|
||||||
|
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
|
||||||
|
em.setDataSource(dataSource);
|
||||||
|
em.setPackagesToScan("com.magistr.app.model");
|
||||||
|
|
||||||
|
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||||
|
vendorAdapter.setGenerateDdl(false);
|
||||||
|
vendorAdapter.setDatabasePlatform("org.hibernate.dialect.PostgreSQLDialect");
|
||||||
|
em.setJpaVendorAdapter(vendorAdapter);
|
||||||
|
|
||||||
|
Map<String, Object> props = new HashMap<>();
|
||||||
|
props.put("hibernate.hbm2ddl.auto", "none");
|
||||||
|
props.put("hibernate.show_sql", "false");
|
||||||
|
em.setJpaPropertyMap(props);
|
||||||
|
|
||||||
|
return em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
|
||||||
|
return new JpaTransactionManager(emf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.springframework.context.annotation.Lazy
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private TenantRoutingDataSource tenantRoutingDataSource;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
|
||||||
|
TenantInterceptor interceptor = new TenantInterceptor();
|
||||||
|
interceptor.setRoutingDataSource(routingDataSource);
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
|
||||||
|
registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TenantConfig> loadTenantsFromFile() {
|
||||||
|
File file = new File(tenantsConfigPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
log.info("Tenants config file not found: {}", tenantsConfigPath);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||||
|
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
|
||||||
|
return list;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read tenants config: {}", e.getMessage());
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java
Executable file
106
backend/src/main/java/com/magistr/app/config/tenant/TenantInterceptor.java
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Если тенант не настроен в TenantRoutingDataSource —
|
||||||
|
* сразу возвращает HTTP 404 (не допускает fallback на чужой тенант).
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* "swsu.zuev.company" → tenant = "swsu"
|
||||||
|
* "mgu.zuev.company" → tenant = "mgu"
|
||||||
|
* "localhost" → tenant = "default"
|
||||||
|
* "localhost:8080" → tenant = "default"
|
||||||
|
*
|
||||||
|
* API управления тенантами (/api/database/**) пропускается без проверки,
|
||||||
|
* чтобы администратор мог добавлять тенантов с любого домена.
|
||||||
|
*/
|
||||||
|
public class TenantInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantInterceptor.class);
|
||||||
|
|
||||||
|
private TenantRoutingDataSource routingDataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливается после создания бина (из TenantDataSourceConfig).
|
||||||
|
*/
|
||||||
|
public void setRoutingDataSource(TenantRoutingDataSource routingDataSource) {
|
||||||
|
this.routingDataSource = routingDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
String tenant = resolveTenant(host);
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
|
||||||
|
// API управления тенантами — всегда пропускаем
|
||||||
|
// (нужно чтобы админ мог добавить тенант даже если его домен не настроен)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, существует ли тенант
|
||||||
|
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
|
||||||
|
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
|
||||||
|
response.setStatus(404);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
||||||
|
"error", "Тенант не найден",
|
||||||
|
"tenant", tenant,
|
||||||
|
"message", "Домен " + host + " не настроен. Обратитесь к администратору."
|
||||||
|
));
|
||||||
|
return false; // Останавливаем обработку запроса
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantContext.setCurrentTenant(tenant);
|
||||||
|
MDC.put("tenant.id", tenant);
|
||||||
|
Span.current().setAttribute("tenant.id", tenant);
|
||||||
|
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
|
TenantContext.clear();
|
||||||
|
MDC.remove("tenant.id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTenant(String host) {
|
||||||
|
if (host == null || host.isBlank()) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убираем порт (localhost:8080 → localhost)
|
||||||
|
String hostname = host.contains(":") ? host.substring(0, host.indexOf(':')) : host;
|
||||||
|
|
||||||
|
// localhost или IP → default
|
||||||
|
if ("localhost".equalsIgnoreCase(hostname) || hostname.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем первый поддомен: swsu.zuev.company → swsu
|
||||||
|
int firstDot = hostname.indexOf('.');
|
||||||
|
if (firstDot > 0) {
|
||||||
|
return hostname.substring(0, firstDot).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
150
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file
150
backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSource, который переключается между БД разных тенантов.
|
||||||
|
* На каждый запрос determineCurrentLookupKey() возвращает текущий тенант из TenantContext.
|
||||||
|
*/
|
||||||
|
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantRoutingDataSource.class);
|
||||||
|
|
||||||
|
private final Map<String, TenantConfig> tenantConfigs = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Object, Object> dataSources = new ConcurrentHashMap<>();
|
||||||
|
private boolean initialized = false;
|
||||||
|
|
||||||
|
public TenantRoutingDataSource() {
|
||||||
|
// Устанавливаем пустой map чтобы afterPropertiesSet не падал
|
||||||
|
setTargetDataSources(new HashMap<>());
|
||||||
|
setLenientFallback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object determineCurrentLookupKey() {
|
||||||
|
String tenant = TenantContext.getCurrentTenant();
|
||||||
|
|
||||||
|
if (tenant == null) {
|
||||||
|
// Нет HTTP контекста (JPA init, background tasks) — берём первый доступный
|
||||||
|
if (!dataSources.isEmpty()) {
|
||||||
|
return dataSources.keySet().iterator().next().toString();
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP запрос — возвращаем точный ключ тенанта
|
||||||
|
// Если тенанта нет — TenantInterceptor уже вернул 404
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет тенант и создаёт для него HikariCP пул.
|
||||||
|
*/
|
||||||
|
public void addTenant(TenantConfig config) {
|
||||||
|
String domain = config.getDomain().toLowerCase();
|
||||||
|
HikariDataSource ds = createDataSource(config);
|
||||||
|
|
||||||
|
dataSources.put(domain, ds);
|
||||||
|
tenantConfigs.put(domain, config);
|
||||||
|
|
||||||
|
// Обновляем target data sources
|
||||||
|
setTargetDataSources(dataSources);
|
||||||
|
afterPropertiesSet();
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
log.info("Added tenant '{}' -> {}", domain, config.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет тенант и закрывает его пул соединений.
|
||||||
|
*/
|
||||||
|
public void removeTenant(String domain) {
|
||||||
|
domain = domain.toLowerCase();
|
||||||
|
Object removed = dataSources.remove(domain);
|
||||||
|
tenantConfigs.remove(domain);
|
||||||
|
|
||||||
|
if (removed instanceof HikariDataSource ds) {
|
||||||
|
ds.close();
|
||||||
|
log.info("Removed and closed tenant '{}'", domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTargetDataSources(dataSources);
|
||||||
|
afterPropertiesSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет подключение к БД для указанного тенанта.
|
||||||
|
*/
|
||||||
|
public boolean testConnection(String domain) {
|
||||||
|
DataSource ds = (DataSource) dataSources.get(domain.toLowerCase());
|
||||||
|
if (ds == null) return false;
|
||||||
|
|
||||||
|
try (Connection conn = ds.getConnection()) {
|
||||||
|
return conn.isValid(5);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
log.warn("Connection test failed for tenant '{}': {}", domain, e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестирует подключение по произвольным параметрам (без регистрации тенанта).
|
||||||
|
*/
|
||||||
|
public String testExternalConnection(String url, String username, String password) {
|
||||||
|
HikariDataSource ds = new HikariDataSource();
|
||||||
|
ds.setJdbcUrl(url);
|
||||||
|
ds.setUsername(username);
|
||||||
|
ds.setPassword(password);
|
||||||
|
ds.setMaximumPoolSize(1);
|
||||||
|
ds.setConnectionTimeout(5000);
|
||||||
|
|
||||||
|
try (Connection conn = ds.getConnection()) {
|
||||||
|
if (conn.isValid(5)) {
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
return "Подключение не валидно";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return e.getMessage();
|
||||||
|
} finally {
|
||||||
|
ds.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, TenantConfig> getTenantConfigs() {
|
||||||
|
return tenantConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasTenant(String domain) {
|
||||||
|
return tenantConfigs.containsKey(domain.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInitialized() {
|
||||||
|
return initialized && !dataSources.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HikariDataSource createDataSource(TenantConfig config) {
|
||||||
|
HikariDataSource ds = new HikariDataSource();
|
||||||
|
ds.setJdbcUrl(config.getUrl());
|
||||||
|
ds.setUsername(config.getUsername());
|
||||||
|
ds.setPassword(config.getPassword());
|
||||||
|
ds.setPoolName("tenant-" + config.getDomain());
|
||||||
|
ds.setMaximumPoolSize(10);
|
||||||
|
ds.setMinimumIdle(2);
|
||||||
|
ds.setConnectionTimeout(10000);
|
||||||
|
ds.setIdleTimeout(300000);
|
||||||
|
ds.setMaxLifetime(600000);
|
||||||
|
// Не падать при инициализации если БД недоступна
|
||||||
|
ds.setInitializationFailTimeout(-1);
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,14 +38,15 @@ public class AuthController {
|
|||||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(401)
|
.status(401)
|
||||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
|
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = userOpt.get();
|
User user = userOpt.get();
|
||||||
String token = UUID.randomUUID().toString();
|
String token = UUID.randomUUID().toString();
|
||||||
String roleName = user.getRole().name();
|
String roleName = user.getRole().name();
|
||||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
181
backend/src/main/java/com/magistr/app/controller/DatabaseController.java
Executable file
181
backend/src/main/java/com/magistr/app/controller/DatabaseController.java
Executable file
@@ -0,0 +1,181 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.config.tenant.ConfigMapUpdater;
|
||||||
|
import com.magistr.app.config.tenant.TenantConfig;
|
||||||
|
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||||
|
import com.magistr.app.config.tenant.TenantContext;
|
||||||
|
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API управления подключениями к базам данных (тенантами).
|
||||||
|
* Доступно только для ADMIN.
|
||||||
|
*
|
||||||
|
* При добавлении/удалении тенанта:
|
||||||
|
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
|
||||||
|
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
|
||||||
|
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/database")
|
||||||
|
public class DatabaseController {
|
||||||
|
|
||||||
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
|
private final ConfigMapUpdater configMapUpdater;
|
||||||
|
private final TenantConfigWatcher configWatcher;
|
||||||
|
|
||||||
|
public DatabaseController(TenantRoutingDataSource routingDataSource,
|
||||||
|
ConfigMapUpdater configMapUpdater,
|
||||||
|
TenantConfigWatcher configWatcher) {
|
||||||
|
this.routingDataSource = routingDataSource;
|
||||||
|
this.configMapUpdater = configMapUpdater;
|
||||||
|
this.configWatcher = configWatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус текущего подключения (по домену запроса).
|
||||||
|
*/
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> getStatus() {
|
||||||
|
String currentTenant = TenantContext.getCurrentTenant();
|
||||||
|
boolean connected = routingDataSource.testConnection(currentTenant);
|
||||||
|
|
||||||
|
TenantConfig config = routingDataSource.getTenantConfigs().get(currentTenant);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("tenant", currentTenant);
|
||||||
|
result.put("connected", connected);
|
||||||
|
result.put("configured", config != null);
|
||||||
|
if (config != null) {
|
||||||
|
result.put("name", config.getName());
|
||||||
|
result.put("url", config.getUrl());
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех тенантов.
|
||||||
|
*/
|
||||||
|
@GetMapping("/tenants")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getTenants() {
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for (TenantConfig config : routingDataSource.getTenantConfigs().values()) {
|
||||||
|
Map<String, Object> tenant = new HashMap<>();
|
||||||
|
tenant.put("name", config.getName());
|
||||||
|
tenant.put("domain", config.getDomain());
|
||||||
|
tenant.put("url", config.getUrl());
|
||||||
|
tenant.put("username", config.getUsername());
|
||||||
|
tenant.put("connected", routingDataSource.testConnection(config.getDomain()));
|
||||||
|
result.add(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить новый тенант.
|
||||||
|
*/
|
||||||
|
@PostMapping("/tenants")
|
||||||
|
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
if (config.getDomain() == null || config.getDomain().isBlank()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "Домен не может быть пустым");
|
||||||
|
return ResponseEntity.badRequest().body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.getUrl() == null || config.getUrl().isBlank()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "URL базы данных не может быть пустым");
|
||||||
|
return ResponseEntity.badRequest().body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routingDataSource.hasTenant(config.getDomain())) {
|
||||||
|
routingDataSource.removeTenant(config.getDomain());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Добавить в in-memory (мгновенно на этом поде)
|
||||||
|
routingDataSource.addTenant(config);
|
||||||
|
|
||||||
|
// 2. Инициализировать БД (init.sql) если нужно
|
||||||
|
configWatcher.initDatabaseForTenant(config);
|
||||||
|
|
||||||
|
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
|
||||||
|
persistToConfigMap();
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "Ошибка: " + e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить тенант.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/tenants/{domain}")
|
||||||
|
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable String domain) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
if (!routingDataSource.hasTenant(domain)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "Тенант '" + domain + "' не найден");
|
||||||
|
return ResponseEntity.status(404).body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
routingDataSource.removeTenant(domain);
|
||||||
|
persistToConfigMap();
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("message", "Тенант '" + domain + "' удалён");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тест подключения к произвольной БД.
|
||||||
|
*/
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody Map<String, String> params) {
|
||||||
|
String url = params.get("url");
|
||||||
|
String username = params.get("username");
|
||||||
|
String password = params.get("password");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "URL не указан");
|
||||||
|
return ResponseEntity.badRequest().body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
String testResult = routingDataSource.testExternalConnection(url, username, password);
|
||||||
|
boolean success = "OK".equals(testResult);
|
||||||
|
|
||||||
|
result.put("success", success);
|
||||||
|
result.put("message", success ? "Подключение успешно!" : testResult);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет текущий список тенантов в K8s ConfigMap.
|
||||||
|
*/
|
||||||
|
private void persistToConfigMap() {
|
||||||
|
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
|
||||||
|
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
|
||||||
|
if (ok) {
|
||||||
|
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Кафедра удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,14 @@ import com.magistr.app.model.EducationForm;
|
|||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.repository.EducationFormRepository;
|
import com.magistr.app.repository.EducationFormRepository;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Year;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,6 +22,8 @@ import java.util.Optional;
|
|||||||
@RequestMapping("/api/groups")
|
@RequestMapping("/api/groups")
|
||||||
public class GroupController {
|
public class GroupController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||||
|
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
|
||||||
@@ -28,29 +35,120 @@ public class GroupController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<GroupResponse> getAllGroups() {
|
public List<GroupResponse> getAllGroups() {
|
||||||
return groupRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех групп");
|
||||||
.map(g -> new GroupResponse(
|
|
||||||
|
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.getId(),
|
||||||
g.getName(),
|
g.getName(),
|
||||||
g.getGroupSize(),
|
g.getGroupSize(),
|
||||||
g.getEducationForm().getId(),
|
g.getEducationForm().getId(),
|
||||||
g.getEducationForm().getName()))
|
g.getEducationForm().getName(),
|
||||||
|
g.getDepartmentId(),
|
||||||
|
course,
|
||||||
|
semester,
|
||||||
|
g.getSpecialityCode()
|
||||||
|
);
|
||||||
|
})
|
||||||
.toList();
|
.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
|
@PostMapping
|
||||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
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()) {
|
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()) {
|
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) {
|
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) {
|
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());
|
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||||
@@ -62,22 +160,38 @@ public class GroupController {
|
|||||||
group.setName(request.getName().trim());
|
group.setName(request.getName().trim());
|
||||||
group.setGroupSize(request.getGroupSize());
|
group.setGroupSize(request.getGroupSize());
|
||||||
group.setEducationForm(efOpt.get());
|
group.setEducationForm(efOpt.get());
|
||||||
|
group.setDepartmentId(request.getDepartmentId());
|
||||||
|
group.setYearStartStudy(request.getYearStartStudy());
|
||||||
|
group.setSpecialityCode(request.getSpecialityCode());
|
||||||
groupRepository.save(group);
|
groupRepository.save(group);
|
||||||
|
|
||||||
|
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||||
|
|
||||||
return ResponseEntity.ok(new GroupResponse(
|
return ResponseEntity.ok(new GroupResponse(
|
||||||
group.getId(),
|
group.getId(),
|
||||||
group.getName(),
|
group.getName(),
|
||||||
group.getGroupSize(),
|
group.getGroupSize(),
|
||||||
group.getEducationForm().getId(),
|
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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление группы с ID - {}", id);
|
||||||
if (!groupRepository.existsById(id)) {
|
if (!groupRepository.existsById(id)) {
|
||||||
|
logger.info("Группа с ID - {} не найдена", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
groupRepository.deleteById(id);
|
groupRepository.deleteById(id);
|
||||||
|
logger.info("Группа с ID - {} успешно удалена", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", "Запись удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Специальнсть удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.magistr.app.controller;
|
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.model.Subject;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -12,6 +18,8 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/subjects")
|
@RequestMapping("/api/subjects")
|
||||||
public class SubjectController {
|
public class SubjectController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
|
||||||
|
|
||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
|
|
||||||
public SubjectController(SubjectRepository subjectRepository) {
|
public SubjectController(SubjectRepository subjectRepository) {
|
||||||
@@ -20,32 +28,105 @@ public class SubjectController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Subject> getAllSubjects() {
|
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
|
@PostMapping
|
||||||
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
|
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
|
||||||
String name = request.get("name");
|
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
|
||||||
if (name == null || name.isBlank()) {
|
request.getName(), request.getCode(), request.getDepartmentId());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
|
|
||||||
|
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()) {
|
if (subjectRepository.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.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 subject = new Subject();
|
||||||
subject.setName(name.trim());
|
subject.setName(request.getName());
|
||||||
|
subject.setCode(request.getCode());
|
||||||
|
subject.setDepartmentId(request.getDepartmentId());
|
||||||
subjectRepository.save(subject);
|
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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
|
||||||
if (!subjectRepository.existsById(id)) {
|
if (!subjectRepository.existsById(id)) {
|
||||||
|
logger.info("Дисциплина с ID - {} не найдена", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
subjectRepository.deleteById(id);
|
subjectRepository.deleteById(id);
|
||||||
|
logger.info("Дисциплина с ID - {} успешно удалена", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.dto.CreateUserRequest;
|
import com.magistr.app.dto.CreateUserRequest;
|
||||||
import com.magistr.app.dto.UserResponse;
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,44 +22,151 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
public class UserController {
|
public class UserController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final DepartmentRepository departmentRepository;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.departmentRepository = departmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<UserResponse> getAllUsers() {
|
public List<UserResponse> getAllUsers() {
|
||||||
return userRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех пользователей");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
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();
|
.toList();
|
||||||
|
logger.info("Получено {} пользователей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
return userRepository.findByRole(Role.TEACHER).stream()
|
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
|
||||||
|
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();
|
.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
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
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()) {
|
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) {
|
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()) {
|
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;
|
Role role;
|
||||||
try {
|
try {
|
||||||
role = Role.valueOf(request.getRole());
|
role = Role.valueOf(request.getRole());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +174,30 @@ public class UserController {
|
|||||||
user.setUsername(request.getUsername());
|
user.setUsername(request.getUsername());
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
user.setRole(role);
|
user.setRole(role);
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setJobTitle(request.getJobTitle());
|
||||||
|
user.setDepartmentId(request.getDepartmentId());
|
||||||
userRepository.save(user);
|
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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
|
||||||
if (!userRepository.existsById(id)) {
|
if (!userRepository.existsById(id)) {
|
||||||
|
logger.info("Пользователь с ID - {} не найден", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
userRepository.deleteById(id);
|
userRepository.deleteById(id);
|
||||||
|
logger.info("Пользователь с ID - {} успешно удалён", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class CreateGroupRequest {
|
|||||||
private String name;
|
private String name;
|
||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
|
private Long departmentId;
|
||||||
|
private Integer yearStartStudy;
|
||||||
|
private Long specialityCode;
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
@@ -29,4 +32,28 @@ public class CreateGroupRequest {
|
|||||||
public void setEducationFormId(Long educationFormId) {
|
public void setEducationFormId(Long educationFormId) {
|
||||||
this.educationFormId = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class CreateUserRequest {
|
|||||||
private String username;
|
private String username;
|
||||||
private String password;
|
private String password;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public CreateUserRequest() {
|
public CreateUserRequest() {
|
||||||
}
|
}
|
||||||
@@ -32,4 +35,28 @@ public class CreateUserRequest {
|
|||||||
public void setRole(String role) {
|
public void setRole(String role) {
|
||||||
this.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class GroupResponse {
|
public class GroupResponse {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
@@ -7,13 +10,33 @@ public class GroupResponse {
|
|||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
private String educationFormName;
|
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.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.groupSize = groupSize;
|
this.groupSize = groupSize;
|
||||||
this.educationFormId = educationFormId;
|
this.educationFormId = educationFormId;
|
||||||
this.educationFormName = educationFormName;
|
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() {
|
public Long getId() {
|
||||||
@@ -35,4 +58,24 @@ public class GroupResponse {
|
|||||||
public String getEducationFormName() {
|
public String getEducationFormName() {
|
||||||
return educationFormName;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ public class LoginResponse {
|
|||||||
private String token;
|
private String token;
|
||||||
private String role;
|
private String role;
|
||||||
private String redirect;
|
private String redirect;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public LoginResponse() {
|
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.success = success;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
this.redirect = redirect;
|
this.redirect = redirect;
|
||||||
|
this.departmentId = departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
@@ -58,4 +60,12 @@ public class LoginResponse {
|
|||||||
public void setRedirect(String redirect) {
|
public void setRedirect(String redirect) {
|
||||||
this.redirect = redirect;
|
this.redirect = redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java
Normal file
128
backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,41 +1,72 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class UserResponse {
|
public class UserResponse {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private String departmentName;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public UserResponse() {
|
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.id = id;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.role = role;
|
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() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUsername(String username) {
|
|
||||||
this.username = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getRole() {
|
public String getRole() {
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setRole(String role) {
|
public String getFullName() {
|
||||||
this.role = role;
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal file
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal file
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
backend/src/main/java/com/magistr/app/model/ScheduleData.java
Normal file
135
backend/src/main/java/com/magistr/app/model/ScheduleData.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
public enum SemesterType {
|
||||||
|
spring,
|
||||||
|
autumn
|
||||||
|
}
|
||||||
50
backend/src/main/java/com/magistr/app/model/Speciality.java
Normal file
50
backend/src/main/java/com/magistr/app/model/Speciality.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,15 @@ public class StudentGroup {
|
|||||||
@JoinColumn(name = "education_form_id", nullable = false)
|
@JoinColumn(name = "education_form_id", nullable = false)
|
||||||
private EducationForm educationForm;
|
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() {
|
public StudentGroup() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +63,28 @@ public class StudentGroup {
|
|||||||
public void setEducationForm(EducationForm educationForm) {
|
public void setEducationForm(EducationForm educationForm) {
|
||||||
this.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ public class Subject {
|
|||||||
@Column(unique = true, nullable = false, length = 200)
|
@Column(unique = true, nullable = false, length = 200)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "code")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(name = "department_id", nullable = false)
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public Subject() {
|
public Subject() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Subject(Long id, String name) {
|
public Subject(Long id, String name, String code, Long departmentId) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.code = code;
|
||||||
|
this.departmentId = departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -36,4 +44,20 @@ public class Subject {
|
|||||||
public void setName(String name) {
|
public void setName(String name) {
|
||||||
this.name = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ public class User {
|
|||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private Role role = Role.STUDENT;
|
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() {
|
public User() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +63,28 @@ public class User {
|
|||||||
public void setRole(Role role) {
|
public void setRole(Role role) {
|
||||||
this.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,4 +11,6 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
|||||||
Optional<StudentGroup> findByName(String name);
|
Optional<StudentGroup> findByName(String name);
|
||||||
|
|
||||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||||
|
|
||||||
|
List<StudentGroup> findByDepartmentId(Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||||
Optional<Subject> findByName(String name);
|
Optional<Subject> findByName(String name);
|
||||||
|
|
||||||
|
List<Subject> findByDepartmentId(Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
Optional<User> findByUsername(String username);
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
List<User> findByRole(Role role);
|
List<User> findByRole(Role role);
|
||||||
|
|
||||||
|
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
0
backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java
Normal file → Executable file
0
backend/src/main/java/com/magistr/app/utils/TypeAndFormatLessonValidator.java
Normal file → Executable file
@@ -1,15 +1,18 @@
|
|||||||
server.port=8080
|
server.port=8080
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
|
||||||
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
spring.datasource.url=jdbc:postgresql://db:5432/app_db
|
||||||
spring.datasource.username=${POSTGRES_USER}
|
spring.datasource.username=${POSTGRES_USER:myuser}
|
||||||
spring.datasource.password=${POSTGRES_PASSWORD}
|
spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# JPA
|
# JPA
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=none
|
||||||
spring.jpa.show-sql=false
|
spring.jpa.show-sql=false
|
||||||
spring.jpa.open-in-view=false
|
spring.jpa.open-in-view=false
|
||||||
|
|
||||||
#Eta nastroyka otvechayet za vklyucheniye vidimosti logov urovnya DEBUG v logakh BE, poka vyklyuchil chtoby ne zasoryat'. Zapisi INFO otobrazhat'sya budut
|
# Мультитенантность
|
||||||
|
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
||||||
|
|
||||||
#logging.level.root=DEBUG
|
#logging.level.root=DEBUG
|
||||||
|
|
||||||
|
|||||||
391
backend/src/main/resources/db/migration/V1__init.sql
Executable file
391
backend/src/main/resources/db/migration/V1__init.sql
Executable file
@@ -0,0 +1,391 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Инициализация расширений
|
||||||
|
-- ==========================================
|
||||||
|
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');
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Пользователи и роли
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
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, 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;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Образовательные формы
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS education_forms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO education_forms (name) VALUES
|
||||||
|
('Бакалавриат'),
|
||||||
|
('Магистратура'),
|
||||||
|
('Специалитет')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Учебные группы
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS student_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
group_size BIGINT NOT NULL,
|
||||||
|
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||||||
|
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, 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;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS subgroups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
student_capacity INT,
|
||||||
|
UNIQUE(group_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Справочники
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Дисциплины
|
||||||
|
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, 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',
|
||||||
|
duration_minutes INT DEFAULT 90
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO lesson_types (name, color_code) VALUES
|
||||||
|
('Лекция', '#FF6B6B'),
|
||||||
|
('Практика', '#4ECDC4'),
|
||||||
|
('Лабораторная работа', '#45B7D1')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Оборудование
|
||||||
|
CREATE TABLE IF NOT EXISTS equipments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
inventory_number VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO equipments (name) VALUES
|
||||||
|
('Проектор'),
|
||||||
|
('ПК'),
|
||||||
|
('Лаборатория'),
|
||||||
|
('Интерактивная доска'),
|
||||||
|
('Документ-камера'),
|
||||||
|
('Аудиосистема')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Аудитории
|
||||||
|
CREATE TABLE IF NOT EXISTS classrooms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
capacity INT NOT NULL CHECK (capacity > 0),
|
||||||
|
building VARCHAR(50),
|
||||||
|
floor INT,
|
||||||
|
is_available BOOLEAN DEFAULT TRUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||||||
|
('101 Ленинская', 120, 'Главный корпус', 1),
|
||||||
|
('202 IT Lab', 20, 'Корпус IT', 2),
|
||||||
|
('303 Обычная', 30, 'Главный корпус', 3)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||||
|
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||||
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||||
|
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
|
||||||
|
quantity INT DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
notes TEXT,
|
||||||
|
PRIMARY KEY (classroom_id, equipment_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
||||||
|
SELECT c.id, e.id,
|
||||||
|
CASE
|
||||||
|
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
||||||
|
WHEN e.name = 'ПК' THEN 1
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
FROM classrooms c, equipments e
|
||||||
|
WHERE
|
||||||
|
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
||||||
|
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
||||||
|
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||||||
|
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,
|
||||||
|
qualification_level VARCHAR(50),
|
||||||
|
experience_years INT,
|
||||||
|
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,
|
||||||
|
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, subject_id, lesson_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Основная таблица Расписания (Lessons)
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS lessons (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
group_id BIGINT NOT NULL REFERENCES student_groups(id),
|
||||||
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
|
lesson_format VARCHAR(255) NOT NULL,
|
||||||
|
type_lesson VARCHAR(255) NOT NULL,
|
||||||
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
||||||
|
day VARCHAR(255) NOT NULL,
|
||||||
|
week VARCHAR(255) NOT NULL,
|
||||||
|
time VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES
|
||||||
|
(2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'),
|
||||||
|
(1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'),
|
||||||
|
(2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'),
|
||||||
|
(1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'),
|
||||||
|
(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
|
||||||
|
-- ==========================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Комментарии к таблицам и полям (для документации)
|
||||||
|
-- ==========================================
|
||||||
|
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 типа занятия';
|
||||||
9
backend/tenants.json
Executable file
9
backend/tenants.json
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Default (dev)",
|
||||||
|
"domain": "default",
|
||||||
|
"url": "jdbc:postgresql://db:5432/app_db",
|
||||||
|
"username": "myuser",
|
||||||
|
"password": "supersecretpassword"
|
||||||
|
}
|
||||||
|
]
|
||||||
24
compose.yaml
24
compose.yaml
@@ -8,11 +8,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
restart: always
|
restart: always
|
||||||
@@ -23,6 +24,7 @@ services:
|
|||||||
- proxy
|
- proxy
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:alpine3.23
|
image: postgres:alpine3.23
|
||||||
container_name: db
|
container_name: db
|
||||||
@@ -30,21 +32,17 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: myuser
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: supersecretpassword
|
||||||
POSTGRES_DB: app_db
|
POSTGRES_DB: app_db
|
||||||
volumes:
|
|
||||||
- ./db/data:/var/lib/postgresql
|
|
||||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
|
||||||
- CMD-SHELL
|
interval: 5s
|
||||||
- pg_isready -U ${POSTGRES_USER} -d app_db
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
229
db/init/init.sql
229
db/init/init.sql
@@ -1,229 +0,0 @@
|
|||||||
-- ==========================================
|
|
||||||
-- Инициализация расширений
|
|
||||||
-- ==========================================
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Пользователи и роли
|
|
||||||
-- ==========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
password VARCHAR(255) NOT NULL,
|
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
|
|
||||||
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')
|
|
||||||
ON CONFLICT (username) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Образовательные формы
|
|
||||||
-- ==========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS education_forms (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO education_forms (name) VALUES
|
|
||||||
('Бакалавриат'),
|
|
||||||
('Магистратура'),
|
|
||||||
('Специалитет')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Учебные группы
|
|
||||||
-- ==========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS student_groups (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
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),
|
|
||||||
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)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
|
||||||
-- ==========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS subgroups (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
student_capacity INT,
|
|
||||||
UNIQUE(group_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Справочники
|
|
||||||
-- ==========================================
|
|
||||||
|
|
||||||
-- Дисциплины
|
|
||||||
CREATE TABLE IF NOT EXISTS subjects (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(200) UNIQUE NOT NULL,
|
|
||||||
code VARCHAR(20),
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO subjects (name) VALUES
|
|
||||||
('Высшая математика'),
|
|
||||||
('Философия'),
|
|
||||||
('Информатика'),
|
|
||||||
('Базы данных'),
|
|
||||||
('Английский язык')
|
|
||||||
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', -- для цветовой индикации в календаре
|
|
||||||
duration_minutes INT DEFAULT 90
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO lesson_types (name, color_code) VALUES
|
|
||||||
('Лекция', '#FF6B6B'),
|
|
||||||
('Практика', '#4ECDC4'),
|
|
||||||
('Лабораторная работа', '#45B7D1')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Оборудование
|
|
||||||
CREATE TABLE IF NOT EXISTS equipments (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
inventory_number VARCHAR(50)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO equipments (name) VALUES
|
|
||||||
('Проектор'),
|
|
||||||
('ПК'),
|
|
||||||
('Лаборатория'),
|
|
||||||
('Интерактивная доска'),
|
|
||||||
('Документ-камера'),
|
|
||||||
('Аудиосистема')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Аудитории
|
|
||||||
CREATE TABLE IF NOT EXISTS classrooms (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
capacity INT NOT NULL CHECK (capacity > 0),
|
|
||||||
building VARCHAR(50),
|
|
||||||
floor INT,
|
|
||||||
is_available BOOLEAN DEFAULT TRUE,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
|
||||||
('101 Ленинская', 120, 'Главный корпус', 1),
|
|
||||||
('202 IT Lab', 20, 'Корпус IT', 2),
|
|
||||||
('303 Обычная', 30, 'Главный корпус', 3)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
|
||||||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
|
||||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
|
||||||
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
|
|
||||||
quantity INT DEFAULT 1 CHECK (quantity > 0),
|
|
||||||
notes TEXT,
|
|
||||||
PRIMARY KEY (classroom_id, equipment_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Заполнение привязок оборудования с использованием подзапросов
|
|
||||||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
|
||||||
SELECT c.id, e.id,
|
|
||||||
CASE
|
|
||||||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
|
||||||
WHEN e.name = 'ПК' THEN 1
|
|
||||||
ELSE 1
|
|
||||||
END
|
|
||||||
FROM classrooms c, equipments e
|
|
||||||
WHERE
|
|
||||||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
|
||||||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
|
||||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
|
||||||
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,
|
|
||||||
qualification_level VARCHAR(50),
|
|
||||||
experience_years INT,
|
|
||||||
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,
|
|
||||||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (user_id, subject_id, lesson_type_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Основная таблица Расписания (Lessons)
|
|
||||||
-- ==========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS lessons (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
|
||||||
group_id BIGINT NOT NULL REFERENCES student_groups(id),
|
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
|
||||||
lesson_format VARCHAR(255) NOT NULL,
|
|
||||||
type_lesson VARCHAR(255) NOT NULL,
|
|
||||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
|
||||||
day VARCHAR(255) NOT NULL,
|
|
||||||
week VARCHAR(255) NOT NULL,
|
|
||||||
time VARCHAR(255) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES
|
|
||||||
(2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'),
|
|
||||||
(1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'),
|
|
||||||
(2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'),
|
|
||||||
(1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'),
|
|
||||||
(2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'),
|
|
||||||
(1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30');
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Функция обновления timestamp
|
|
||||||
-- ==========================================
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Триггеры для обновления updated_at
|
|
||||||
CREATE TRIGGER update_users_updated_at
|
|
||||||
BEFORE UPDATE ON users
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
|
|
||||||
-- ==========================================
|
|
||||||
-- Комментарии к таблицам и полям (для документации)
|
|
||||||
-- ==========================================
|
|
||||||
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
|
|
||||||
COMMENT ON TABLE lessons IS 'Основное расписание занятий';
|
|
||||||
482
docs/API.md
Normal file
482
docs/API.md
Normal 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
142
docs/ARCHITECTURE.md
Normal 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
149
docs/BUSINESS_LOGIC.md
Normal 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)
|
||||||
|
|
||||||
|
- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6)
|
||||||
|
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `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
362
docs/DATABASE.md
Normal 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(1–6) | Курс |
|
||||||
|
|
||||||
|
#### `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
275
docs/DEVELOPMENT.md
Normal 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
256
docs/FRONTEND.md
Normal 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
137
docs/INFRASTRUCTURE.md
Normal 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
167
docs/LOGGING.md
Normal 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
113
docs/README.md
Normal 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
115
docs/UI_COMPONENTS.md
Normal 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`).
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
FROM httpd:alpine
|
FROM httpd:alpine
|
||||||
COPY . /usr/local/apache2/htdocs/
|
COPY . /usr/local/apache2/htdocs/
|
||||||
|
|
||||||
|
# Set appropriate permissions for the web server to serve static files
|
||||||
|
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||||
|
|||||||
154
frontend/admin/css/auditorium-workload.css
Normal file
154
frontend/admin/css/auditorium-workload.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.filter-row input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
@@ -85,20 +85,22 @@
|
|||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input::placeholder {
|
.form-group input::placeholder,
|
||||||
|
.filter-row input::placeholder {
|
||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
transition: opacity var(--transition);
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group select:focus {
|
.filter-row input:focus {
|
||||||
background: var(--bg-input-focus);
|
background: var(--bg-input-focus);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus::placeholder {
|
.form-group input:focus::placeholder,
|
||||||
|
.filter-row input:focus::placeholder {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,34 +116,187 @@ input[type="number"] {
|
|||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Select Base Style */
|
/* ===== Premium Custom Dropdown Styles ===== */
|
||||||
.form-group select,
|
.custom-select-wrapper {
|
||||||
.filter-row select {
|
position: relative;
|
||||||
cursor: pointer;
|
width: 100%;
|
||||||
appearance: none;
|
user-select: 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");
|
font-family: inherit;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.75rem center;
|
|
||||||
padding-right: 2.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group select option,
|
.custom-select-trigger {
|
||||||
.filter-row select option {
|
display: flex;
|
||||||
background: #1a1a2e;
|
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);
|
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 */
|
/* Light theme selects */
|
||||||
[data-theme="light"] .form-group input,
|
[data-theme="light"] .form-group input,
|
||||||
[data-theme="light"] .form-group select,
|
[data-theme="light"] .filter-row input,
|
||||||
[data-theme="light"] .filter-row select {
|
[data-theme="light"] .custom-select-trigger {
|
||||||
border-color: rgba(0, 0, 0, 0.15);
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .form-group select option,
|
[data-theme="light"] .custom-select-menu {
|
||||||
[data-theme="light"] .filter-row select option {
|
background: rgba(255, 255, 255, 0.95);
|
||||||
background: #fff;
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
|
||||||
color: #1a1a2e;
|
}
|
||||||
|
|
||||||
|
[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 */
|
/* Filter Row */
|
||||||
@@ -172,7 +327,7 @@ input[type="number"] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-row select {
|
.filter-row input {
|
||||||
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -182,7 +337,7 @@ input[type="number"] {
|
|||||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
|
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);
|
background-color: var(--bg-input-focus);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
@@ -230,26 +385,33 @@ input[type="number"] {
|
|||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: calc(100% + 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.5rem;
|
background: rgba(10, 10, 15, 0.95);
|
||||||
background: rgba(15, 23, 42, 0.95);
|
backdrop-filter: blur(16px);
|
||||||
backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-8px) scale(0.98);
|
||||||
transition: all var(--transition);
|
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 {
|
[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 {
|
.dropdown-menu.open {
|
||||||
@@ -261,26 +423,102 @@ input[type="number"] {
|
|||||||
.checkbox-group-vertical {
|
.checkbox-group-vertical {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.25rem;
|
||||||
max-height: 200px;
|
max-height: 250px;
|
||||||
overflow-y: auto;
|
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 {
|
.checkbox-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
position: relative;
|
||||||
|
padding: 0.5rem 0.5rem 0.5rem 2.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-primary);
|
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"] {
|
.checkbox-item input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 1.1rem;
|
height: 0;
|
||||||
height: 1.1rem;
|
width: 0;
|
||||||
accent-color: var(--accent);
|
}
|
||||||
|
|
||||||
|
.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 ===== */
|
/* ===== Buttons ===== */
|
||||||
@@ -755,107 +993,43 @@ tbody tr:hover {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Modal ===== */
|
/* ===== Theme Toggle Button ===== */
|
||||||
.modal-overlay {
|
.theme-toggle {
|
||||||
display: none;
|
width: 42px;
|
||||||
position: fixed;
|
height: 42px;
|
||||||
top: 0;
|
border: none;
|
||||||
left: 0;
|
border-radius: 50%;
|
||||||
right: 0;
|
background: var(--bg-card);
|
||||||
bottom: 0;
|
border: 1px solid var(--bg-card-border);
|
||||||
background: rgba(0, 0, 0, 0.6);
|
color: var(--text-primary);
|
||||||
backdrop-filter: blur(4px);
|
cursor: pointer;
|
||||||
z-index: 1000;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0;
|
backdrop-filter: blur(12px);
|
||||||
transition: opacity var(--transition);
|
-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-overlay.open {
|
.theme-toggle svg {
|
||||||
display: flex;
|
width: 20px;
|
||||||
opacity: 1;
|
height: 20px;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.theme-toggle:hover {
|
||||||
background: var(--bg-primary);
|
transform: scale(1.1);
|
||||||
border: 1px solid var(--bg-card-border);
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
border-radius: var(--radius-md);
|
}
|
||||||
padding: 2rem;
|
|
||||||
width: 90%;
|
.theme-toggle:active {
|
||||||
max-width: 500px;
|
|
||||||
position: relative;
|
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-overlay.open .modal-content {
|
.theme-toggle--fixed {
|
||||||
transform: scale(1);
|
position: fixed;
|
||||||
}
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
.modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-add-lesson {
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--success);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition), transform var(--transition);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-add-lesson:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кнопки-переключатели для недели */
|
|
||||||
.btn-checkbox {
|
|
||||||
display: inline-block;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-checkbox input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--bg-card-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all var(--transition);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-checkbox input:checked+.checkbox-btn {
|
|
||||||
background: var(--success, #10b981);
|
|
||||||
/* используем success или зелёный */
|
|
||||||
border-color: var(--success, #10b981);
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
344
frontend/admin/css/department.css
Normal file
344
frontend/admin/css/department.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/admin/css/departments-data.css
Normal file
11
frontend/admin/css/departments-data.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -12,13 +12,34 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 10;
|
z-index: 1000;
|
||||||
transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
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 {
|
.sidebar-header {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border-bottom: 1px solid var(--bg-card-border);
|
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 {
|
.logo {
|
||||||
@@ -99,7 +120,7 @@
|
|||||||
border-top: 1px solid var(--bg-card-border);
|
border-top: 1px solid var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-logout {
|
.btn-settings {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -116,16 +137,189 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-logout:hover {
|
.btn-settings:hover {
|
||||||
background: rgba(248, 113, 113, 0.1);
|
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);
|
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 ===== */
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 260px;
|
margin-left: 260px;
|
||||||
min-height: 100vh;
|
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 {
|
.topbar {
|
||||||
@@ -180,7 +374,9 @@
|
|||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--transition);
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition), visibility var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive Mobile ===== */
|
/* ===== Responsive Mobile ===== */
|
||||||
@@ -212,5 +408,7 @@
|
|||||||
|
|
||||||
.sidebar-overlay.open {
|
.sidebar-overlay.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
418
frontend/admin/css/modals.css
Normal file
418
frontend/admin/css/modals.css
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
/* ===== Modal (общие стили) ===== */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
/* bottom: 0; */
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open .modal-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Кнопки ===== */
|
||||||
|
.btn-add-lesson {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--success);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), transform var(--transition);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-lesson:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view-lessons {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view-lessons:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Кнопки-переключатели (неделя) ===== */
|
||||||
|
.btn-checkbox {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-checkbox input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-checkbox input:checked + .checkbox-btn {
|
||||||
|
background: var(--success, #10b981);
|
||||||
|
border-color: var(--success, #10b981);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================
|
||||||
|
===== 2-е модальное окно (View Lessons) — ОСНОВНЫЕ ПРАВКИ =====
|
||||||
|
Требования:
|
||||||
|
- слева
|
||||||
|
- ~30% ширины
|
||||||
|
- сверху начинается СРАЗУ под 1-й модалкой
|
||||||
|
- высота = весь остаток до низа экрана
|
||||||
|
- визуально "ниже" 1-й модалки (и по z-index тоже ниже)
|
||||||
|
=========================================================== */
|
||||||
|
|
||||||
|
#modal-view-lessons.modal-overlay {
|
||||||
|
background: transparent !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999; /* ниже чем 1-е (1000) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* В открытом состоянии: прижать влево и опустить вниз на высоту "шапки" */
|
||||||
|
#modal-view-lessons.modal-overlay.open {
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
/* ключевое: высота 1-й модалки приходит из JS через --add-lesson-height */
|
||||||
|
padding-top: var(--add-lesson-height, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель 2-й модалки */
|
||||||
|
#modal-view-lessons .view-lessons-modal {
|
||||||
|
width: 30vw !important;
|
||||||
|
max-width: 30vw !important;
|
||||||
|
min-width: 320px;
|
||||||
|
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2rem;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
/* отключаем "пружинку" от .modal-content */
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
/* ключевое: занимает остаток по высоте */
|
||||||
|
height: calc(100vh - var(--add-lesson-height, 0px));
|
||||||
|
max-height: calc(100vh - var(--add-lesson-height, 0px));
|
||||||
|
|
||||||
|
/* чтобы скролл был внутри, а не у всей модалки */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header во 2-й модалке */
|
||||||
|
#modal-veiw-lessons .modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-view-lessons .modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер занятий: растягивается и скроллится */
|
||||||
|
#modal-view-lessons .lessons-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
/* перебиваем старое ограничение */
|
||||||
|
max-height: none;
|
||||||
|
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Карточки занятий ===== */
|
||||||
|
.lesson-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px dashed var(--bg-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-group {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-time::before {
|
||||||
|
content: "🕒";
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-subject {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-subject::before {
|
||||||
|
content: "📚";
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-detail-item {
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* День недели как разделитель */
|
||||||
|
.lesson-day-divider {
|
||||||
|
margin: 1.5rem 0 1rem 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 2px solid var(--accent-glow);
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-day-divider:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Загрузка/пусто */
|
||||||
|
.loading-lessons,
|
||||||
|
.no-lessons {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Светлая тема */
|
||||||
|
[data-theme="light"] .lesson-card {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .lesson-group {
|
||||||
|
background: rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Адаптивность ===== */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
#modal-view-lessons .view-lessons-modal {
|
||||||
|
width: 40vw !important;
|
||||||
|
max-width: 40vw !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* На мобилке делаем поведение более "обычным" */
|
||||||
|
#modal-view-lessons.modal-overlay.open {
|
||||||
|
padding-top: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-view-lessons .view-lessons-modal {
|
||||||
|
width: 90vw !important;
|
||||||
|
max-width: 90vw !important;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
/* чтобы занимало почти весь экран */
|
||||||
|
height: calc(100vh - 2rem);
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Скролл во 2-й модалке ===== */
|
||||||
|
#modal-view-lessons .lessons-container {
|
||||||
|
scrollbar-width: thin; /* Firefox */
|
||||||
|
scrollbar-color: rgba(99, 102, 241, 0.55) rgba(255, 255, 255, 0.06); /* thumb track */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebKit (Chrome/Edge/Safari) */
|
||||||
|
#modal-view-lessons .lessons-container::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-view-lessons .lessons-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(99, 102, 241, 0.55); /* под accent */
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0); /* чтобы выглядел “тоньше” */
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-view-lessons .lessons-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общий блюр/затемнение за модалками */
|
||||||
|
#modal-backdrop{
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
z-index: 998; /* ниже модалок: 999 и 1000 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-backdrop.open{
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/components.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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -33,6 +37,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Magistr</span>
|
<span>Magistr</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a href="#" class="nav-item" data-tab="users">
|
<a href="#" class="nav-item" data-tab="users">
|
||||||
@@ -43,7 +52,25 @@
|
|||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
</svg>
|
</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>
|
||||||
<a href="#" class="nav-item" data-tab="groups">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
@@ -51,7 +78,7 @@
|
|||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
<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" />
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Группы
|
<span>Группы</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
@@ -61,7 +88,7 @@
|
|||||||
<line x1="9" y1="7" x2="17" y2="7" />
|
<line x1="9" y1="7" x2="17" y2="7" />
|
||||||
<line x1="9" y1="11" x2="15" y2="11" />
|
<line x1="9" y1="11" x2="15" y2="11" />
|
||||||
</svg>
|
</svg>
|
||||||
Формы обучения
|
<span>Формы обучения</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="equipments">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
@@ -69,14 +96,14 @@
|
|||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
<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>
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Оборудование
|
<span>Оборудование</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="classrooms">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
</svg>
|
</svg>
|
||||||
Аудитории
|
<span>Аудитории</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="subjects">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
@@ -84,7 +111,7 @@
|
|||||||
<path d="M12 20h9" />
|
<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" />
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Дисциплины
|
<span>Дисциплины</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="schedule">
|
<a href="#" class="nav-item" data-tab="schedule">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -93,12 +120,51 @@
|
|||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
</svg>
|
</svg>
|
||||||
Расписание занятий
|
<span>Расписание занятий</span>
|
||||||
|
</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">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
<span>База данных</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<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"
|
<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">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
<polyline points="16 17 21 12 16 7" />
|
<polyline points="16 17 21 12 16 7" />
|
||||||
@@ -107,6 +173,8 @@
|
|||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar overlay (mobile) -->
|
<!-- Sidebar overlay (mobile) -->
|
||||||
|
|||||||
222
frontend/admin/js/dropdown.js
Normal file
222
frontend/admin/js/dropdown.js
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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 { isAuthenticatedAsAdmin } from './api.js';
|
||||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.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 { initUsers } from './views/users.js';
|
||||||
import { initGroups } from './views/groups.js';
|
import { initGroups } from './views/groups.js';
|
||||||
@@ -8,6 +25,10 @@ import { initEquipments } from './views/equipments.js';
|
|||||||
import { initClassrooms } from './views/classrooms.js';
|
import { initClassrooms } from './views/classrooms.js';
|
||||||
import { initSubjects } from './views/subjects.js';
|
import { initSubjects } from './views/subjects.js';
|
||||||
import {initSchedule} from "./views/schedule.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
|
// Configuration
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
@@ -17,8 +38,11 @@ const ROUTES = {
|
|||||||
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
|
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
|
||||||
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
|
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
|
||||||
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
||||||
// Новая вкладка
|
|
||||||
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
|
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;
|
let currentTab = null;
|
||||||
@@ -30,7 +54,9 @@ const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
|||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
const menuToggle = document.getElementById('menu-toggle');
|
const menuToggle = document.getElementById('menu-toggle');
|
||||||
|
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||||
const btnLogout = document.getElementById('btn-logout');
|
const btnLogout = document.getElementById('btn-logout');
|
||||||
|
const main = document.querySelector('.main');
|
||||||
|
|
||||||
// Initial auth check
|
// Initial auth check
|
||||||
if (!isAuthenticatedAsAdmin()) {
|
if (!isAuthenticatedAsAdmin()) {
|
||||||
@@ -41,16 +67,56 @@ if (!isAuthenticatedAsAdmin()) {
|
|||||||
applyRippleEffect();
|
applyRippleEffect();
|
||||||
closeAllDropdownsOnOutsideClick();
|
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', () => {
|
menuToggle.addEventListener('click', () => {
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
sidebar.classList.toggle('open');
|
sidebar.classList.toggle('open');
|
||||||
sidebarOverlay.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', () => {
|
sidebarOverlay.addEventListener('click', () => {
|
||||||
sidebar.classList.remove('open');
|
sidebar.classList.remove('open');
|
||||||
sidebarOverlay.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
|
// Logout
|
||||||
btnLogout.addEventListener('click', () => {
|
btnLogout.addEventListener('click', () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
|||||||
47
frontend/admin/js/otel.js
Normal file
47
frontend/admin/js/otel.js
Normal 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.');
|
||||||
153
frontend/admin/js/views/auditorium-workload.js
Normal file
153
frontend/admin/js/views/auditorium-workload.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
157
frontend/admin/js/views/database.js
Executable file
157
frontend/admin/js/views/database.js
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
|
export async function initDatabase() {
|
||||||
|
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||||
|
const addTenantForm = document.getElementById('add-tenant-form');
|
||||||
|
const statusInfo = document.getElementById('db-status-info');
|
||||||
|
const btnTest = document.getElementById('btn-test-connection');
|
||||||
|
|
||||||
|
// === Загрузка статуса текущего подключения ===
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/api/database/status');
|
||||||
|
const statusBadge = data.connected
|
||||||
|
? '<span class="badge badge-available">Online</span>'
|
||||||
|
: '<span class="badge badge-unavailable">Offline</span>';
|
||||||
|
|
||||||
|
statusInfo.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
|
||||||
|
<strong>${escapeHtml(data.tenant || '—')}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
|
||||||
|
<strong>${escapeHtml(data.name || '—')}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
${data.url ? `<div>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
|
||||||
|
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
statusInfo.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки статуса: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Загрузка списка тенантов ===
|
||||||
|
async function loadTenants() {
|
||||||
|
try {
|
||||||
|
const tenants = await api.get('/api/database/tenants');
|
||||||
|
renderTenantsTable(tenants);
|
||||||
|
} catch (e) {
|
||||||
|
tenantsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTenantsTable(tenants) {
|
||||||
|
if (!tenants || !tenants.length) {
|
||||||
|
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantsTbody.innerHTML = tenants.map(t => {
|
||||||
|
const statusBadge = t.connected
|
||||||
|
? '<span class="badge badge-available">Online</span>'
|
||||||
|
: '<span class="badge badge-unavailable">Offline</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(t.name || '—')}</td>
|
||||||
|
<td><code>${escapeHtml(t.domain)}</code></td>
|
||||||
|
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
|
||||||
|
<td>${escapeHtml(t.username || '—')}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td><button class="btn-delete" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест подключения ===
|
||||||
|
btnTest.addEventListener('click', async () => {
|
||||||
|
hideAlert('add-tenant-alert');
|
||||||
|
const url = document.getElementById('tenant-url').value.trim();
|
||||||
|
const username = document.getElementById('tenant-username').value.trim();
|
||||||
|
const password = document.getElementById('tenant-password').value;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnTest.textContent = '...';
|
||||||
|
btnTest.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.post('/api/database/test', { url, username, password });
|
||||||
|
if (result.success) {
|
||||||
|
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
|
||||||
|
} else {
|
||||||
|
showAlert('add-tenant-alert', `✗ ${result.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btnTest.textContent = 'Тест';
|
||||||
|
btnTest.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Добавление тенанта ===
|
||||||
|
addTenantForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('add-tenant-alert');
|
||||||
|
|
||||||
|
const name = document.getElementById('tenant-name').value.trim();
|
||||||
|
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
|
||||||
|
const url = document.getElementById('tenant-url').value.trim();
|
||||||
|
const username = document.getElementById('tenant-username').value.trim();
|
||||||
|
const password = document.getElementById('tenant-password').value;
|
||||||
|
|
||||||
|
if (!name || !domain || !url) {
|
||||||
|
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
|
||||||
|
if (result.success) {
|
||||||
|
showAlert('add-tenant-alert', `Тенант "${escapeHtml(domain)}" добавлен!`, 'success');
|
||||||
|
addTenantForm.reset();
|
||||||
|
loadTenants();
|
||||||
|
loadStatus();
|
||||||
|
} else {
|
||||||
|
showAlert('add-tenant-alert', result.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Удаление тенанта ===
|
||||||
|
tenantsTbody.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.btn-delete');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const domain = btn.dataset.domain;
|
||||||
|
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/database/tenants/${domain}`);
|
||||||
|
loadTenants();
|
||||||
|
loadStatus();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Ошибка: ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
loadStatus();
|
||||||
|
loadTenants();
|
||||||
|
}
|
||||||
456
frontend/admin/js/views/department.js
Normal file
456
frontend/admin/js/views/department.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
103
frontend/admin/js/views/departments-data.js
Normal file
103
frontend/admin/js/views/departments-data.js
Normal 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();
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ export function renderEquipmentCheckboxes(equipments, containerId, textId, check
|
|||||||
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
|
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
|
||||||
return `
|
return `
|
||||||
<label class="checkbox-item">
|
<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>
|
</label>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
updateSelectText(containerId, textId);
|
updateSelectText(containerId, textId);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function initGroups() {
|
|||||||
populateEfSelects(educationForms);
|
populateEfSelects(educationForms);
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
} catch (e) {
|
} 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');
|
allGroups = await api.get('/api/groups');
|
||||||
applyGroupFilter();
|
applyGroupFilter();
|
||||||
} catch (e) {
|
} 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) {
|
function renderGroups(groups) {
|
||||||
if (!groups || !groups.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
groupsTbody.innerHTML = groups.map(g => `
|
groupsTbody.innerHTML = groups.map(g => `
|
||||||
@@ -70,6 +70,9 @@ export async function initGroups() {
|
|||||||
<td>${escapeHtml(g.name)}</td>
|
<td>${escapeHtml(g.name)}</td>
|
||||||
<td>${escapeHtml(g.groupSize)}</td>
|
<td>${escapeHtml(g.groupSize)}</td>
|
||||||
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></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>
|
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
@@ -80,14 +83,27 @@ export async function initGroups() {
|
|||||||
const name = document.getElementById('new-group-name').value.trim();
|
const name = document.getElementById('new-group-name').value.trim();
|
||||||
const groupSize = document.getElementById('new-group-size').value;
|
const groupSize = document.getElementById('new-group-size').value;
|
||||||
const educationFormId = newGroupEfSelect.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 (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
|
||||||
if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; }
|
if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; }
|
||||||
if (!educationFormId) { 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 {
|
try {
|
||||||
const data = await api.post('/api/groups', { name, groupSize, educationFormId: Number(educationFormId) });
|
const data = await api.post('/api/groups', {
|
||||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success');
|
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();
|
createGroupForm.reset();
|
||||||
loadGroups();
|
loadGroups();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
export async function initSchedule() {
|
export async function initSchedule() {
|
||||||
const tbody = document.getElementById('schedule-tbody');
|
const tbody = document.getElementById('schedule-tbody');
|
||||||
@@ -20,7 +20,6 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
// ===================== Фильтрация =====================
|
// ===================== Фильтрация =====================
|
||||||
|
|
||||||
// Извлечение отображаемого значения поля для фильтрации
|
|
||||||
function getDisplayValue(lesson, key) {
|
function getDisplayValue(lesson, key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'teacher':
|
case 'teacher':
|
||||||
@@ -38,20 +37,17 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собрать уникальные значения из данных
|
|
||||||
function getUniqueValues(key) {
|
function getUniqueValues(key) {
|
||||||
const vals = new Set();
|
const vals = new Set();
|
||||||
lessonsData.forEach(lesson => {
|
lessonsData.forEach(lesson => {
|
||||||
vals.add(getDisplayValue(lesson, key));
|
vals.add(getDisplayValue(lesson, key));
|
||||||
});
|
});
|
||||||
// Для дней — сортируем по порядку
|
|
||||||
if (key === 'day') {
|
if (key === 'day') {
|
||||||
return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99));
|
return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99));
|
||||||
}
|
}
|
||||||
return [...vals].sort((a, b) => a.localeCompare(b, 'ru'));
|
return [...vals].sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Применить все фильтры
|
|
||||||
function applyFilters(lessons) {
|
function applyFilters(lessons) {
|
||||||
return lessons.filter(lesson => {
|
return lessons.filter(lesson => {
|
||||||
for (const key of Object.keys(activeFilters)) {
|
for (const key of Object.keys(activeFilters)) {
|
||||||
@@ -79,7 +75,6 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function onDocumentClick(e) {
|
function onDocumentClick(e) {
|
||||||
if (currentPopup && !currentPopup.contains(e.target)) {
|
if (currentPopup && !currentPopup.contains(e.target)) {
|
||||||
// Проверяем, не кликнули ли по иконке фильтра
|
|
||||||
if (!e.target.closest('.filter-icon')) {
|
if (!e.target.closest('.filter-icon')) {
|
||||||
closePopup();
|
closePopup();
|
||||||
}
|
}
|
||||||
@@ -87,7 +82,6 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFilterPopup(th, filterKey) {
|
function openFilterPopup(th, filterKey) {
|
||||||
// Если уже открыт этот же — закрыть
|
|
||||||
if (currentPopup && currentPopup.dataset.filterKey === filterKey) {
|
if (currentPopup && currentPopup.dataset.filterKey === filterKey) {
|
||||||
closePopup();
|
closePopup();
|
||||||
return;
|
return;
|
||||||
@@ -97,19 +91,16 @@ export async function initSchedule() {
|
|||||||
const uniqueValues = getUniqueValues(filterKey);
|
const uniqueValues = getUniqueValues(filterKey);
|
||||||
const currentFilter = activeFilters[filterKey];
|
const currentFilter = activeFilters[filterKey];
|
||||||
|
|
||||||
// Создаём попап
|
|
||||||
const popup = document.createElement('div');
|
const popup = document.createElement('div');
|
||||||
popup.className = 'filter-popup';
|
popup.className = 'filter-popup';
|
||||||
popup.dataset.filterKey = filterKey;
|
popup.dataset.filterKey = filterKey;
|
||||||
|
|
||||||
// Поисковое поле
|
|
||||||
const searchInput = document.createElement('input');
|
const searchInput = document.createElement('input');
|
||||||
searchInput.type = 'text';
|
searchInput.type = 'text';
|
||||||
searchInput.className = 'filter-search';
|
searchInput.className = 'filter-search';
|
||||||
searchInput.placeholder = 'Поиск...';
|
searchInput.placeholder = 'Поиск...';
|
||||||
popup.appendChild(searchInput);
|
popup.appendChild(searchInput);
|
||||||
|
|
||||||
// Кнопки «Выбрать все» / «Сбросить»
|
|
||||||
const btnRow = document.createElement('div');
|
const btnRow = document.createElement('div');
|
||||||
btnRow.className = 'filter-btn-row';
|
btnRow.className = 'filter-btn-row';
|
||||||
|
|
||||||
@@ -133,7 +124,6 @@ export async function initSchedule() {
|
|||||||
btnRow.appendChild(btnNone);
|
btnRow.appendChild(btnNone);
|
||||||
popup.appendChild(btnRow);
|
popup.appendChild(btnRow);
|
||||||
|
|
||||||
// Список чекбоксов
|
|
||||||
const listWrap = document.createElement('div');
|
const listWrap = document.createElement('div');
|
||||||
listWrap.className = 'filter-list';
|
listWrap.className = 'filter-list';
|
||||||
|
|
||||||
@@ -146,7 +136,6 @@ export async function initSchedule() {
|
|||||||
const cb = document.createElement('input');
|
const cb = document.createElement('input');
|
||||||
cb.type = 'checkbox';
|
cb.type = 'checkbox';
|
||||||
cb.value = val;
|
cb.value = val;
|
||||||
// Если фильтр активен — отмечаем только выбранные; если нет — все отмечены
|
|
||||||
cb.checked = currentFilter ? currentFilter.has(val) : true;
|
cb.checked = currentFilter ? currentFilter.has(val) : true;
|
||||||
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
@@ -160,7 +149,6 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
popup.appendChild(listWrap);
|
popup.appendChild(listWrap);
|
||||||
|
|
||||||
// Кнопка «Применить»
|
|
||||||
const btnApply = document.createElement('button');
|
const btnApply = document.createElement('button');
|
||||||
btnApply.className = 'filter-btn-apply';
|
btnApply.className = 'filter-btn-apply';
|
||||||
btnApply.textContent = 'Применить';
|
btnApply.textContent = 'Применить';
|
||||||
@@ -171,7 +159,6 @@ export async function initSchedule() {
|
|||||||
if (cb.checked) selected.add(cb.value);
|
if (cb.checked) selected.add(cb.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Если все выбраны — снимаем фильтр
|
|
||||||
if (selected.size === uniqueValues.length) {
|
if (selected.size === uniqueValues.length) {
|
||||||
delete activeFilters[filterKey];
|
delete activeFilters[filterKey];
|
||||||
th.classList.remove('filter-active');
|
th.classList.remove('filter-active');
|
||||||
@@ -185,7 +172,6 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
popup.appendChild(btnApply);
|
popup.appendChild(btnApply);
|
||||||
|
|
||||||
// Поиск по чекбоксам
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
const query = searchInput.value.toLowerCase();
|
const query = searchInput.value.toLowerCase();
|
||||||
listWrap.querySelectorAll('.filter-item').forEach(item => {
|
listWrap.querySelectorAll('.filter-item').forEach(item => {
|
||||||
@@ -194,28 +180,22 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Предотвращаем всплытие кликов внутри попапа (чтобы не срабатывала сортировка th)
|
|
||||||
popup.addEventListener('click', (e) => e.stopPropagation());
|
popup.addEventListener('click', (e) => e.stopPropagation());
|
||||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
// Позиционируем попап под th
|
|
||||||
th.style.position = 'relative';
|
th.style.position = 'relative';
|
||||||
th.appendChild(popup);
|
th.appendChild(popup);
|
||||||
currentPopup = popup;
|
currentPopup = popup;
|
||||||
|
|
||||||
// Фокус на поиск
|
|
||||||
setTimeout(() => searchInput.focus(), 50);
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
|
||||||
// Закрытие по клику вне
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', onDocumentClick, true);
|
document.addEventListener('click', onDocumentClick, true);
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчики кликов по заголовкам с фильтрами (клик по всей ячейке)
|
|
||||||
table.querySelectorAll('thead th.filterable').forEach(th => {
|
table.querySelectorAll('thead th.filterable').forEach(th => {
|
||||||
th.addEventListener('click', (e) => {
|
th.addEventListener('click', (e) => {
|
||||||
// Не открываем попап при клике внутри самого попапа
|
|
||||||
if (e.target.closest('.filter-popup')) return;
|
if (e.target.closest('.filter-popup')) return;
|
||||||
const filterKey = th.dataset.filterKey;
|
const filterKey = th.dataset.filterKey;
|
||||||
openFilterPopup(th, filterKey);
|
openFilterPopup(th, filterKey);
|
||||||
@@ -249,7 +229,6 @@ export async function initSchedule() {
|
|||||||
case 'week':
|
case 'week':
|
||||||
return (lesson.week || '').toLowerCase();
|
return (lesson.week || '').toLowerCase();
|
||||||
case 'time': {
|
case 'time': {
|
||||||
// Составной ключ: день + время для правильной сортировки
|
|
||||||
const d = (lesson.day || '').toLowerCase();
|
const d = (lesson.day || '').toLowerCase();
|
||||||
const dayNum = dayOrder[d] ?? 99;
|
const dayNum = dayOrder[d] ?? 99;
|
||||||
const t = lesson.time || '99:99';
|
const t = lesson.time || '99:99';
|
||||||
@@ -287,10 +266,8 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Навешиваем обработчики клика на заголовки (сортировка)
|
|
||||||
table.querySelectorAll('thead th.sortable').forEach(th => {
|
table.querySelectorAll('thead th.sortable').forEach(th => {
|
||||||
th.addEventListener('click', (e) => {
|
th.addEventListener('click', (e) => {
|
||||||
// Не сортируем, если кликнули по иконке фильтра или внутри попапа
|
|
||||||
if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return;
|
if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return;
|
||||||
|
|
||||||
const key = th.dataset.sortKey;
|
const key = th.dataset.sortKey;
|
||||||
@@ -310,7 +287,7 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===================== Загрузка и рендер =====================
|
// ===================== Загрузка и рендер таблицы =====================
|
||||||
|
|
||||||
async function loadSchedule() {
|
async function loadSchedule() {
|
||||||
try {
|
try {
|
||||||
@@ -318,21 +295,20 @@ export async function initSchedule() {
|
|||||||
lessonsData = lessons;
|
lessonsData = lessons;
|
||||||
renderSchedule(lessons);
|
renderSchedule(lessons);
|
||||||
} catch (e) {
|
} 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) {
|
function renderSchedule(lessons) {
|
||||||
if (!lessons || !lessons.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сначала фильтруем, потом сортируем
|
|
||||||
const filtered = applyFilters(lessons);
|
const filtered = applyFilters(lessons);
|
||||||
|
|
||||||
if (!filtered.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,5 +342,343 @@ export async function initSchedule() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== Модалки добавления занятия =====================
|
||||||
|
|
||||||
|
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();
|
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()
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
@@ -24,19 +24,21 @@ export async function initSubjects() {
|
|||||||
renderSubjects(allSubjects);
|
renderSubjects(allSubjects);
|
||||||
populateSubjectSelect(allSubjects);
|
populateSubjectSelect(allSubjects);
|
||||||
} catch (e) {
|
} 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) {
|
function renderSubjects(subjects) {
|
||||||
if (!subjects || !subjects.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
subjectsTbody.innerHTML = subjects.map(s => `
|
subjectsTbody.innerHTML = subjects.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${s.id}</td>
|
<td>${s.id}</td>
|
||||||
<td>${escapeHtml(s.name)}</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>
|
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
@@ -100,11 +102,19 @@ export async function initSubjects() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-subject-alert');
|
hideAlert('create-subject-alert');
|
||||||
const name = document.getElementById('new-subject-name').value.trim();
|
const name = document.getElementById('new-subject-name').value.trim();
|
||||||
|
const code = document.getElementById('new-subject-code').value.trim();
|
||||||
|
const departmentId = document.getElementById('new-subject-department').value;
|
||||||
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
|
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 {
|
try {
|
||||||
const data = await api.post('/api/subjects', { name });
|
const data = await api.post('/api/subjects', {
|
||||||
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success');
|
name,
|
||||||
|
code,
|
||||||
|
departmentId: Number(departmentId)
|
||||||
|
});
|
||||||
|
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success');
|
||||||
createSubjectForm.reset();
|
createSubjectForm.reset();
|
||||||
loadSubjects();
|
loadSubjects();
|
||||||
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
|
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ export async function initUsers() {
|
|||||||
const usersTbody = document.getElementById('users-tbody');
|
const usersTbody = document.getElementById('users-tbody');
|
||||||
const createForm = document.getElementById('create-form');
|
const createForm = document.getElementById('create-form');
|
||||||
|
|
||||||
// Элементы модального окна добавления занятия
|
// ===== Оверлей (cs-overlay) =====
|
||||||
|
const usersOverlay = document.getElementById('users-overlay');
|
||||||
|
|
||||||
|
// ===== 1-е модальное окно: Добавить занятие =====
|
||||||
const modalAddLesson = document.getElementById('modal-add-lesson');
|
const modalAddLesson = document.getElementById('modal-add-lesson');
|
||||||
const modalAddLessonClose = document.getElementById('modal-add-lesson-close');
|
const modalAddLessonClose = document.getElementById('modal-add-lesson-close');
|
||||||
const addLessonForm = document.getElementById('add-lesson-form');
|
const addLessonForm = document.getElementById('add-lesson-form');
|
||||||
|
|
||||||
const lessonGroupSelect = document.getElementById('lesson-group');
|
const lessonGroupSelect = document.getElementById('lesson-group');
|
||||||
const lessonDisciplineSelect = document.getElementById('lesson-discipline');
|
const lessonDisciplineSelect = document.getElementById('lesson-discipline');
|
||||||
const lessonClassroomSelect = document.getElementById('lesson-classroom');
|
const lessonClassroomSelect = document.getElementById('lesson-classroom');
|
||||||
@@ -22,15 +26,20 @@ export async function initUsers() {
|
|||||||
const lessonDaySelect = document.getElementById('lesson-day');
|
const lessonDaySelect = document.getElementById('lesson-day');
|
||||||
const weekUpper = document.getElementById('week-upper');
|
const weekUpper = document.getElementById('week-upper');
|
||||||
const weekLower = document.getElementById('week-lower');
|
const weekLower = document.getElementById('week-lower');
|
||||||
// NEW: получаем элемент выбора времени
|
|
||||||
const lessonTimeSelect = document.getElementById('lesson-time');
|
const lessonTimeSelect = document.getElementById('lesson-time');
|
||||||
|
|
||||||
// Переменные для хранения загруженных данных
|
// ===== 2-е модальное окно: Просмотр занятий =====
|
||||||
|
const modalViewLessons = document.getElementById('modal-view-lessons');
|
||||||
|
const lessonsContainer = document.getElementById('lessons-container');
|
||||||
|
const modalTeacherName = document.getElementById('modal-teacher-name');
|
||||||
|
|
||||||
|
let currentLessonsTeacherId = null;
|
||||||
|
let currentLessonsTeacherName = '';
|
||||||
|
// ===== Данные =====
|
||||||
let groups = [];
|
let groups = [];
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
let classrooms = [];
|
let classrooms = [];
|
||||||
|
|
||||||
// NEW: массивы с временными слотами
|
|
||||||
const weekdaysTimes = [
|
const weekdaysTimes = [
|
||||||
"8:00-9:30",
|
"8:00-9:30",
|
||||||
"9:40-11:10",
|
"9:40-11:10",
|
||||||
@@ -48,7 +57,9 @@ export async function initUsers() {
|
|||||||
"13:20-14:50"
|
"13:20-14:50"
|
||||||
];
|
];
|
||||||
|
|
||||||
// Загрузка групп с сервера
|
// =========================================================
|
||||||
|
// Загрузка справочников
|
||||||
|
// =========================================================
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
try {
|
try {
|
||||||
groups = await api.get('/api/groups');
|
groups = await api.get('/api/groups');
|
||||||
@@ -58,7 +69,6 @@ export async function initUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка дисциплин
|
|
||||||
async function loadSubjects() {
|
async function loadSubjects() {
|
||||||
try {
|
try {
|
||||||
subjects = await api.get('/api/subjects');
|
subjects = await api.get('/api/subjects');
|
||||||
@@ -77,31 +87,29 @@ export async function initUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заполнение select группами
|
|
||||||
function renderGroupOptions() {
|
function renderGroupOptions() {
|
||||||
if (!groups || groups.length === 0) {
|
if (!groups || groups.length === 0) {
|
||||||
lessonClassroomSelect.innerHTML = '<option value="">Нет доступных групп</option>';
|
lessonGroupSelect.innerHTML = '<option value="">Нет доступных групп</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
lessonGroupSelect.innerHTML =
|
||||||
|
'<option value="">Выберите группу</option>' +
|
||||||
groups.map(g => {
|
groups.map(g => {
|
||||||
let optionText = escapeHtml(g.name);
|
let optionText = escapeHtml(g.name);
|
||||||
if(g.groupSize) {
|
if (g.groupSize) optionText += ` (численность: ${g.groupSize} чел.)`;
|
||||||
optionText += ` (численность: ${g.groupSize} чел.)`;
|
|
||||||
}
|
|
||||||
return `<option value="${g.id}">${optionText}</option>`;
|
return `<option value="${g.id}">${optionText}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заполнение select дисциплинами
|
|
||||||
function renderSubjectOptions() {
|
function renderSubjectOptions() {
|
||||||
lessonDisciplineSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
lessonDisciplineSelect.innerHTML =
|
||||||
|
'<option value="">Выберите дисциплину</option>' +
|
||||||
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClassroomsOptions() {
|
function renderClassroomsOptions() {
|
||||||
if (!classrooms || classrooms.length ===0) {
|
if (!classrooms || classrooms.length === 0) {
|
||||||
lessonClassroomSelect.innerHTML = '<option value="">Нет доступных аудиторий</option>';
|
lessonClassroomSelect.innerHTML = '<option value="">Нет доступных аудиторий</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -110,32 +118,28 @@ export async function initUsers() {
|
|||||||
const selectedGroup = groups?.find(g => g.id == selectedGroupId);
|
const selectedGroup = groups?.find(g => g.id == selectedGroupId);
|
||||||
const groupSize = selectedGroup?.groupSize || 0;
|
const groupSize = selectedGroup?.groupSize || 0;
|
||||||
|
|
||||||
lessonClassroomSelect.innerHTML = '<option value="">Выберите аудиторию</option>' +
|
lessonClassroomSelect.innerHTML =
|
||||||
|
'<option value="">Выберите аудиторию</option>' +
|
||||||
classrooms.map(c => {
|
classrooms.map(c => {
|
||||||
let optionText = escapeHtml(c.name);
|
let optionText = escapeHtml(c.name);
|
||||||
// Добавление текста с инфой о вместимости чел.
|
|
||||||
if(c.capacity) {
|
|
||||||
optionText += ` (вместимость: ${c.capacity} чел.)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если аудитория занята, то рисуем крестик допом
|
if (c.capacity) optionText += ` (вместимость: ${c.capacity} чел.)`;
|
||||||
|
|
||||||
if (c.isAvailable === false) {
|
if (c.isAvailable === false) {
|
||||||
optionText += ` ❌ Занята`
|
optionText += ` ❌ Занята`;
|
||||||
// Если свободна, но меньше численности группы, отображаем воскл. знак
|
|
||||||
} else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) {
|
} else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) {
|
||||||
optionText += ` ⚠️ Недостаточно места`;
|
optionText += ` ⚠️ Недостаточно места`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return `<option value="${c.id}">${optionText}</option>`;
|
return `<option value="${c.id}">${optionText}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonGroupSelect.addEventListener('change', function() {
|
lessonGroupSelect.addEventListener('change', function () {
|
||||||
renderClassroomsOptions();
|
renderClassroomsOptions();
|
||||||
|
requestAnimationFrame(() => syncAddLessonHeight());
|
||||||
});
|
});
|
||||||
|
|
||||||
// NEW: функция обновления списка времени в зависимости от дня
|
|
||||||
function updateTimeOptions(dayValue) {
|
function updateTimeOptions(dayValue) {
|
||||||
let times = [];
|
let times = [];
|
||||||
if (dayValue === "Суббота") {
|
if (dayValue === "Суббота") {
|
||||||
@@ -148,59 +152,88 @@ export async function initUsers() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonTimeSelect.innerHTML = '<option value="">Выберите время</option>' +
|
lessonTimeSelect.innerHTML =
|
||||||
|
'<option value="">Выберите время</option>' +
|
||||||
times.map(t => `<option value="${t}">${t}</option>`).join('');
|
times.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||||
lessonTimeSelect.disabled = false;
|
lessonTimeSelect.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Пользователи
|
||||||
|
// =========================================================
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const users = await api.get('/api/users');
|
const users = await api.get('/api/users');
|
||||||
renderUsers(users);
|
renderUsers(users);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки: ' + escapeHtml(e.message) + '</td></tr>';
|
usersTbody.innerHTML =
|
||||||
|
'<tr><td colspan="8" class="loading-row">Ошибка загрузки: ' +
|
||||||
|
escapeHtml(e.message) + '</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
if (!users || !users.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
usersTbody.innerHTML = users.map(u => `
|
usersTbody.innerHTML = users.map(u => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${u.id}</td>
|
<td>${u.id}</td>
|
||||||
<td>${escapeHtml(u.username)}</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><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></td>
|
<td>
|
||||||
<td><button class="btn-add-lesson" data-id="${u.id}">Добавить занятие</button></td>
|
<button class="btn-delete" data-id="${u.id}">Удалить</button>
|
||||||
</tr>`).join('');
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-add-lesson" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Добавить занятие</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сброс формы модального окна
|
// ===== Открытие / закрытие оверлея =====
|
||||||
|
function openOverlay() {
|
||||||
|
if (usersOverlay) usersOverlay.classList.add('open');
|
||||||
|
}
|
||||||
|
function closeOverlay() {
|
||||||
|
if (usersOverlay) usersOverlay.classList.remove('open');
|
||||||
|
if (modalViewLessons) modalViewLessons.style.display = 'none';
|
||||||
|
resetLessonForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 1-я модалка: добавление занятия
|
||||||
|
// =========================================================
|
||||||
function resetLessonForm() {
|
function resetLessonForm() {
|
||||||
addLessonForm.reset();
|
addLessonForm.reset();
|
||||||
lessonUserId.value = '';
|
lessonUserId.value = '';
|
||||||
|
|
||||||
if (weekUpper) weekUpper.checked = false;
|
if (weekUpper) weekUpper.checked = false;
|
||||||
if (weekLower) weekLower.checked = false;
|
if (weekLower) weekLower.checked = false;
|
||||||
// NEW: сбрасываем селект времени
|
|
||||||
if (lessonOfflineFormat) lessonOfflineFormat.checked = true;
|
if (lessonOfflineFormat) lessonOfflineFormat.checked = true;
|
||||||
if (lessonOnlineFormat) lessonOnlineFormat.checked = false;
|
if (lessonOnlineFormat) lessonOnlineFormat.checked = false;
|
||||||
|
|
||||||
lessonTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
lessonTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
||||||
lessonTimeSelect.disabled = true;
|
lessonTimeSelect.disabled = true;
|
||||||
|
|
||||||
hideAlert('add-lesson-alert');
|
hideAlert('add-lesson-alert');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Открытие модалки с установкой userId
|
|
||||||
function openAddLessonModal(userId) {
|
function openAddLessonModal(userId) {
|
||||||
lessonUserId.value = userId;
|
lessonUserId.value = userId;
|
||||||
// NEW: сбрасываем выбранный день и время
|
|
||||||
lessonDaySelect.value = '';
|
lessonDaySelect.value = '';
|
||||||
updateTimeOptions('');
|
updateTimeOptions('');
|
||||||
modalAddLesson.classList.add('open');
|
|
||||||
|
openOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик отправки формы добавления занятия
|
|
||||||
addLessonForm.addEventListener('submit', async (e) => {
|
addLessonForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('add-lesson-alert');
|
hideAlert('add-lesson-alert');
|
||||||
@@ -211,48 +244,31 @@ export async function initUsers() {
|
|||||||
const classroomId = lessonClassroomSelect.value;
|
const classroomId = lessonClassroomSelect.value;
|
||||||
const lessonType = lessonTypeSelect.value;
|
const lessonType = lessonTypeSelect.value;
|
||||||
const dayOfWeek = lessonDaySelect.value;
|
const dayOfWeek = lessonDaySelect.value;
|
||||||
const timeSlot = lessonTimeSelect.value; // NEW: получаем выбранное время
|
const timeSlot = lessonTimeSelect.value;
|
||||||
|
|
||||||
const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value;
|
const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value;
|
||||||
|
|
||||||
// Проверка обязательных полей
|
if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; }
|
||||||
if (!groupId) {
|
if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; }
|
||||||
showAlert('add-lesson-alert', 'Выберите группу', 'error');
|
if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; }
|
||||||
return;
|
if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; }
|
||||||
}
|
if (!timeSlot) { 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;
|
|
||||||
}
|
|
||||||
// NEW: проверка времени
|
|
||||||
if (!timeSlot) {
|
|
||||||
showAlert('add-lesson-alert', 'Выберите время', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Определяем выбранный тип недели
|
|
||||||
const weekUpperChecked = weekUpper?.checked || false;
|
const weekUpperChecked = weekUpper?.checked || false;
|
||||||
const weekLowerChecked = weekLower?.checked || false;
|
const weekLowerChecked = weekLower?.checked || false;
|
||||||
let weekType = null;
|
|
||||||
if (weekUpperChecked && weekLowerChecked) {
|
if (!weekUpperChecked && !weekLowerChecked) {
|
||||||
weekType = 'Обе';
|
showAlert('add-lesson-alert', 'Не выбран тип недели', 'error');
|
||||||
} else if (weekUpperChecked) {
|
return;
|
||||||
weekType = 'Верхняя';
|
|
||||||
} else if (weekLowerChecked) {
|
|
||||||
weekType = 'Нижняя';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let weekType = null;
|
||||||
|
if (weekUpperChecked && weekLowerChecked) weekType = 'Обе';
|
||||||
|
else if (weekUpperChecked) weekType = 'Верхняя';
|
||||||
|
else if (weekLowerChecked) weekType = 'Нижняя';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем данные на сервер
|
await api.post('/api/users/lessons/create', {
|
||||||
const response = await api.post('/api/users/lessons/create', {
|
|
||||||
teacherId: parseInt(userId),
|
teacherId: parseInt(userId),
|
||||||
groupId: parseInt(groupId),
|
groupId: parseInt(groupId),
|
||||||
subjectId: parseInt(subjectId),
|
subjectId: parseInt(subjectId),
|
||||||
@@ -261,37 +277,182 @@ export async function initUsers() {
|
|||||||
lessonFormat: lessonFormat,
|
lessonFormat: lessonFormat,
|
||||||
day: dayOfWeek,
|
day: dayOfWeek,
|
||||||
week: weekType,
|
week: weekType,
|
||||||
time: timeSlot // передаём время
|
time: timeSlot
|
||||||
});
|
});
|
||||||
showAlert('add-lesson-alert', 'Занятие добавлено', 'success');
|
|
||||||
|
if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) {
|
||||||
|
await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert('add-lesson-alert', 'Занятие добавлено ✓', 'success');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
modalAddLesson.classList.remove('open');
|
hideAlert('add-lesson-alert');
|
||||||
resetLessonForm();
|
}, 3000);
|
||||||
}, 1500);
|
} catch (err) {
|
||||||
} catch (e) {
|
showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error');
|
||||||
showAlert('add-lesson-alert', e.message || 'Ошибка добавления занятия', 'error');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lessonDaySelect.addEventListener('change', function () {
|
||||||
|
updateTimeOptions(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modalAddLessonClose) {
|
||||||
|
modalAddLessonClose.addEventListener('click', () => closeOverlay());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клик по оверлею (мимо модалок) закрывает всё
|
||||||
|
if (usersOverlay) {
|
||||||
|
usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('cs-overlay-scroll')) {
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Создание пользователя
|
||||||
|
// =========================================================
|
||||||
createForm.addEventListener('submit', async (e) => {
|
createForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-alert');
|
hideAlert('create-alert');
|
||||||
|
|
||||||
const username = document.getElementById('new-username').value.trim();
|
const username = document.getElementById('new-username').value.trim();
|
||||||
const password = document.getElementById('new-password').value;
|
const password = document.getElementById('new-password').value;
|
||||||
const role = document.getElementById('new-role').value;
|
const role = document.getElementById('new-role').value;
|
||||||
if (!username || !password) { showAlert('create-alert', 'Заполните все поля', 'error'); return; }
|
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 || !fullName || !jobTitle || !departmentId) {
|
||||||
|
showAlert('create-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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');
|
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
|
||||||
createForm.reset();
|
createForm.reset();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
showAlert('create-alert', e.message || 'Ошибка соединения', 'error');
|
showAlert('create-alert', err.message || 'Ошибка соединения', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обработчик кликов по таблице
|
// =========================================================
|
||||||
|
// Инициализация
|
||||||
|
// =========================================================
|
||||||
|
await Promise.all([loadUsers(), loadGroups(), loadSubjects(), loadClassrooms()]);
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 2-я модалка: просмотр занятий
|
||||||
|
// =========================================================
|
||||||
|
async function loadTeacherLessons(teacherId, teacherName) {
|
||||||
|
try {
|
||||||
|
lessonsContainer.innerHTML = '<div class="loading-lessons">Загрузка занятий...</div>';
|
||||||
|
|
||||||
|
modalTeacherName.textContent = teacherName
|
||||||
|
? `Занятия преподавателя: ${teacherName}`
|
||||||
|
: 'Занятия преподавателя';
|
||||||
|
|
||||||
|
const lessons = await api.get(`/api/users/lessons/${teacherId}`);
|
||||||
|
|
||||||
|
if (!lessons || lessons.length === 0) {
|
||||||
|
lessonsContainer.innerHTML = '<div class="no-lessons">У преподавателя пока нет занятий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||||
|
const lessonsByDay = {};
|
||||||
|
|
||||||
|
lessons.forEach(lesson => {
|
||||||
|
if (!lessonsByDay[lesson.day]) lessonsByDay[lesson.day] = [];
|
||||||
|
lessonsByDay[lesson.day].push(lesson);
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lessonsContainer.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
lessonsContainer.innerHTML = `<div class="no-lessons">Ошибка загрузки: ${escapeHtml(e.message)}</div>`;
|
||||||
|
console.error('Ошибка загрузки занятий:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openViewLessonsModal(teacherId, teacherName) {
|
||||||
|
currentLessonsTeacherId = teacherId;
|
||||||
|
currentLessonsTeacherName = teacherName || '';
|
||||||
|
|
||||||
|
if (modalViewLessons) modalViewLessons.style.display = '';
|
||||||
|
loadTeacherLessons(teacherId, teacherName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeViewLessonsModal() {
|
||||||
|
if (modalViewLessons) modalViewLessons.style.display = 'none';
|
||||||
|
currentLessonsTeacherId = null;
|
||||||
|
currentLessonsTeacherName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (usersOverlay?.classList.contains('open')) {
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ЕДИНЫЙ обработчик кликов по таблице (ВАЖНО: без дубля)
|
||||||
|
// =========================================================
|
||||||
usersTbody.addEventListener('click', async (e) => {
|
usersTbody.addEventListener('click', async (e) => {
|
||||||
const deleteBtn = e.target.closest('.btn-delete');
|
const deleteBtn = e.target.closest('.btn-delete');
|
||||||
if (deleteBtn) {
|
if (deleteBtn) {
|
||||||
@@ -299,8 +460,8 @@ export async function initUsers() {
|
|||||||
try {
|
try {
|
||||||
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
await api.delete('/api/users/' + deleteBtn.dataset.id);
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
alert(e.message || 'Ошибка удаления');
|
alert(err.message || 'Ошибка удаления');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -308,35 +469,23 @@ export async function initUsers() {
|
|||||||
const addLessonBtn = e.target.closest('.btn-add-lesson');
|
const addLessonBtn = e.target.closest('.btn-add-lesson');
|
||||||
if (addLessonBtn) {
|
if (addLessonBtn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (modalAddLesson) {
|
|
||||||
openAddLessonModal(addLessonBtn.dataset.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// NEW: обработчик изменения дня недели для обновления списка времени
|
const teacherId = addLessonBtn.dataset.id;
|
||||||
lessonDaySelect.addEventListener('change', function() {
|
const teacherName = addLessonBtn.dataset.name;
|
||||||
updateTimeOptions(this.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Закрытие модалки по крестику
|
openAddLessonModal(teacherId);
|
||||||
if (modalAddLessonClose) {
|
openViewLessonsModal(teacherId, teacherName);
|
||||||
modalAddLessonClose.addEventListener('click', () => {
|
|
||||||
modalAddLesson.classList.remove('open');
|
return;
|
||||||
resetLessonForm();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Закрытие по клику на overlay
|
const viewLessonsBtn = e.target.closest('.btn-view-lessons');
|
||||||
if (modalAddLesson) {
|
if (viewLessonsBtn) {
|
||||||
modalAddLesson.addEventListener('click', (e) => {
|
e.preventDefault();
|
||||||
if (e.target === modalAddLesson) {
|
const teacherId = viewLessonsBtn.dataset.id;
|
||||||
modalAddLesson.classList.remove('open');
|
const teacherName = viewLessonsBtn.dataset.name;
|
||||||
resetLessonForm();
|
openViewLessonsModal(teacherId, teacherName);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем все данные при инициализации
|
|
||||||
await Promise.all([loadUsers(), loadGroups(), loadSubjects(), loadClassrooms()]);
|
|
||||||
}
|
}
|
||||||
325
frontend/admin/settings/css/layout.css
Normal file
325
frontend/admin/settings/css/layout.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
frontend/admin/settings/css/main.css
Normal file
153
frontend/admin/settings/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
88
frontend/admin/settings/index.html
Normal file
88
frontend/admin/settings/index.html
Normal 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>
|
||||||
105
frontend/admin/settings/js/main.js
Normal file
105
frontend/admin/settings/js/main.js
Normal 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');
|
||||||
11
frontend/admin/settings/views/general.html
Normal file
11
frontend/admin/settings/views/general.html
Normal 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>
|
||||||
75
frontend/admin/views/auditorium-workload.html
Normal file
75
frontend/admin/views/auditorium-workload.html
Normal 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>
|
||||||
73
frontend/admin/views/database.html
Executable file
73
frontend/admin/views/database.html
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- ===== Database / Tenants Tab ===== -->
|
||||||
|
|
||||||
|
<!-- Текущее подключение -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Текущее подключение</h2>
|
||||||
|
<div id="db-status-info" class="db-status-card">
|
||||||
|
<div class="loading-row">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица тенантов -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Подключённые университеты</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="tenants-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Домен</th>
|
||||||
|
<th>JDBC URL</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenants-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Добавить тенант -->
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Добавить подключение</h2>
|
||||||
|
<form id="add-tenant-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tenant-name">Название университета</label>
|
||||||
|
<input type="text" id="tenant-name" placeholder="ЮЗГУ" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tenant-domain">Поддомен</label>
|
||||||
|
<input type="text" id="tenant-domain" placeholder="swsu" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 0.75rem;">
|
||||||
|
<div class="form-group" style="flex: 3;">
|
||||||
|
<label for="tenant-url">JDBC URL</label>
|
||||||
|
<input type="text" id="tenant-url" placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top: 0.75rem;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tenant-username">Пользователь</label>
|
||||||
|
<input type="text" id="tenant-username" placeholder="postgres" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tenant-password">Пароль</label>
|
||||||
|
<input type="password" id="tenant-password" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-primary" id="btn-test-connection" style="height: fit-content;">
|
||||||
|
Тест
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-primary" style="height: fit-content;">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="add-tenant-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user