Compare commits
30 Commits
7ce0d1e501
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb023c07fb | ||
|
|
d2b9d0c5ea | ||
|
|
813e81be70 | ||
|
|
e92aa74048 | ||
|
|
48e8d4e631 | ||
|
|
c7145de95a | ||
|
|
c7594c4380 | ||
| ac69a57290 | |||
| c82e3feaed | |||
|
|
3cdb8614cb | ||
|
|
73995f86f8 | ||
|
|
cd6cc6f5f7 | ||
| 2be2534a1e | |||
| b14d937062 | |||
| 9d06c99d06 | |||
|
|
522bc97b8c | ||
|
|
d0a8148fa0 | ||
|
|
0b9d063266 | ||
|
|
6f33e23e17 | ||
|
|
bfdcb58c7d | ||
|
|
e015758caf | ||
|
|
6be8db0cd0 | ||
|
|
7a2c385257 | ||
| f7483e7aeb | |||
|
|
55da934545 | ||
|
|
e71bcee9b5 | ||
|
|
e03a68b7a8 | ||
|
|
fcd7baac71 | ||
|
|
491807cd94 | ||
|
|
81e91e056f |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
glob:
|
||||
description:
|
||||
---
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
Контекст проекта:
|
||||
|
||||
Backend: Java 17, Spring Framework. Учитывай возможности этой версии языка и стандарты фреймворка.
|
||||
|
||||
Frontend: HTML, CSS, JavaScript.
|
||||
|
||||
Правила написания кода и комментариев:
|
||||
|
||||
Проверка: Перед написанием кода изучи проект, его структуру и используемые технологии. Не предлагай решения, которые не соответствуют текущей архитектуре или стеку.
|
||||
|
||||
Язык: Все комментарии и объяснения должны быть строго на русском языке.
|
||||
|
||||
Комментирование кода: Оставляй комментарии, объясняющие, за что отвечает та или иная часть кода. Перед крупными или смысловыми блоками обязательно ставь поясняющие метки (например: ``, /* таблица subjects */, // логика обработки subjects).
|
||||
|
||||
Обоснование решений: При написании нового кода кратко и максимально понятно объясняй, почему мы используем именно это решение, а не другое.
|
||||
|
||||
Современные подходы: На фронтенде используй самые современные и актуальные подходы (например, Flexbox, CSS Grid, семантические теги).
|
||||
|
||||
Правила работы с ошибками (Обучающий режим):
|
||||
|
||||
Если ты находишь ошибку в моем коде, не пиши сразу готовый исправленный код.
|
||||
|
||||
Дай мне точную подсказку, чтобы я мог сам найти и исправить баг (например: "У тебя не закрыт тег в 15 строке", "Ты забыл поставить аннотацию в контроллере Spring" или "Проверь отступы в таком-то классе"). Моя цель — научиться.
|
||||
|
||||
Правила работы с дизайном (UI/UX):
|
||||
|
||||
Перед добавлением новых стилей всегда сначала изучай, какие стили уже используются в проекте, чтобы сохранять единообразие.
|
||||
|
||||
Если ты видишь, что текущий дизайн откровенно плох, нелогичен или устарел — смело предлагай свои идеи по улучшению (цветовая палитра, отступы, шрифты). Я открыт к предложениям по улучшению визуала.
|
||||
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/`, обновить все вхождения
|
||||
@@ -26,6 +26,7 @@ magistr/
|
||||
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
|
||||
├── frontend/ # Статические файлы
|
||||
│ ├── admin/ # Интерфейс администратора
|
||||
│ │ └── settings/ # Страница настроек (отдельный SPA)
|
||||
│ ├── teacher/ # Интерфейс преподавателя
|
||||
│ └── student/ # Интерфейс студента
|
||||
├── docs/ # 📖 Документация проекта
|
||||
@@ -85,3 +86,4 @@ docker compose logs -f backend
|
||||
| [`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: Формат (Очно/Онлайн)
|
||||
- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты)
|
||||
- [ ] Удаление правила с подтверждением
|
||||
@@ -1,77 +0,0 @@
|
||||
# Руководство Backend-разработчика Magistr
|
||||
|
||||
Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами.
|
||||
|
||||
Здесь описано, как тут всё устроено, чтобы вы ничего не сломали.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура мультитенантности
|
||||
|
||||
Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента).
|
||||
|
||||
- **Как приложение понимает, к какой базе обращаться?**
|
||||
Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`).
|
||||
В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`.
|
||||
|
||||
- **Как переключаются базы данных?**
|
||||
Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету.
|
||||
|
||||
> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе.
|
||||
|
||||
---
|
||||
|
||||
## 2. Динамическое управление тенантами (Kubernetes / ConfigMap)
|
||||
|
||||
Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**.
|
||||
|
||||
Список тенантов не зашит в код:
|
||||
- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`.
|
||||
- В классе `DatabaseController` находится API для добавления нового тенанта из админки.
|
||||
- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`.
|
||||
- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных.
|
||||
|
||||
---
|
||||
|
||||
## 3. Базы данных и Миграции (Flyway)
|
||||
|
||||
Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`).
|
||||
Структурой баз данных правит **Flyway**.
|
||||
|
||||
Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта.
|
||||
|
||||
### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД:
|
||||
|
||||
Если вам нужно добавить новую таблицу, колонку или изменить тип поля:
|
||||
|
||||
1. **Запрещено трогать старые файлы миграций!**
|
||||
Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`.
|
||||
|
||||
2. **Как правильно добавить таблицу?**
|
||||
- Зайдите в папку `src/main/resources/db/migration/`.
|
||||
- Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`.
|
||||
- Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`).
|
||||
- Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт.
|
||||
|
||||
3. **Что если локально я накосячил в V2?**
|
||||
Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**:
|
||||
`DELETE FROM flyway_schema_history WHERE version = '2';`
|
||||
Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую.
|
||||
|
||||
---
|
||||
|
||||
## 4. Как запускать проект локально
|
||||
|
||||
В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Соберется и запустится:
|
||||
- Фронтенд
|
||||
- Бэкенд
|
||||
- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле).
|
||||
|
||||
Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети).
|
||||
Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway.
|
||||
|
||||
Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!
|
||||
@@ -38,14 +38,15 @@ public class AuthController {
|
||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null));
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
String token = UUID.randomUUID().toString();
|
||||
String roleName = user.getRole().name();
|
||||
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
|
||||
Long departmentId = user.getDepartmentId();
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect));
|
||||
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Year;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -39,15 +41,21 @@ public class GroupController {
|
||||
List<StudentGroup> groups = groupRepository.findAll();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
.map(g -> new GroupResponse(
|
||||
.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(),
|
||||
g.getCourse()
|
||||
))
|
||||
course,
|
||||
semester,
|
||||
g.getSpecialityCode()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
logger.info("Получено {} групп", response.size());
|
||||
return response;
|
||||
@@ -69,9 +77,27 @@ public class GroupController {
|
||||
.body("Группы для указанной кафедры не найдены");
|
||||
}
|
||||
|
||||
logger.info("Найдено {} групп для кафедры с ID - {}", groups.size(), departmentId);
|
||||
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();
|
||||
|
||||
return ResponseEntity.ok(groups);
|
||||
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)
|
||||
@@ -81,8 +107,8 @@ public class GroupController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, course = {}",
|
||||
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getCourse());
|
||||
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()) {
|
||||
String errorMessage = "Название группы обязательно";
|
||||
@@ -109,8 +135,18 @@ public class GroupController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (request.getCourse() == null || request.getCourse() == 0) {
|
||||
String 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));
|
||||
}
|
||||
@@ -125,7 +161,8 @@ public class GroupController {
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setCourse(request.getCourse());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpecialityCode(request.getSpecialityCode());
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||
@@ -137,7 +174,8 @@ public class GroupController {
|
||||
group.getEducationForm().getId(),
|
||||
group.getEducationForm().getName(),
|
||||
group.getDepartmentId(),
|
||||
group.getCourse()));
|
||||
group.getYearStartStudy(),
|
||||
group.getSpecialityCode()));
|
||||
} catch (Exception e ) {
|
||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.ScheduleData;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.ScheduleDataRepository;
|
||||
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.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
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/scheduledata")
|
||||
@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) {
|
||||
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
|
||||
@GetMapping("/allList")
|
||||
public List<ScheduleData> getAllScheduleDataList() {
|
||||
logger.info("Получен запрос на получение списка данных расписаний");
|
||||
try {
|
||||
@@ -33,7 +48,6 @@ public class ScheduleDataController {
|
||||
.map(s -> new ScheduleData(
|
||||
s.getId(),
|
||||
s.getDepartmentId(),
|
||||
s.getSemester(),
|
||||
s.getGroupId(),
|
||||
s.getSubjectsId(),
|
||||
s.getLessonTypeId(),
|
||||
@@ -51,4 +65,219 @@ public class ScheduleDataController {
|
||||
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", "Запись удалена"));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.CreateUserRequest;
|
||||
import com.magistr.app.dto.UserResponse;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -22,11 +24,13 @@ public class UserController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
|
||||
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -36,14 +40,19 @@ public class UserController {
|
||||
List<User> users = userRepository.findAll();
|
||||
|
||||
List<UserResponse> response = users.stream()
|
||||
.map(u -> new UserResponse(
|
||||
.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(),
|
||||
u.getDepartmentId()
|
||||
))
|
||||
departmentName);
|
||||
})
|
||||
.toList();
|
||||
logger.info("Получено {} пользователей", response.size());
|
||||
return response;
|
||||
@@ -62,14 +71,19 @@ public class UserController {
|
||||
List<User> users = userRepository.findByRole(Role.TEACHER);
|
||||
|
||||
List<UserResponse> response = users.stream()
|
||||
.map(u -> new UserResponse(
|
||||
.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(),
|
||||
u.getDepartmentId()
|
||||
))
|
||||
departmentName);
|
||||
})
|
||||
.toList();
|
||||
logger.info("Получено {} преподавателей", response.size());
|
||||
return response;
|
||||
|
||||
@@ -6,7 +6,8 @@ public class CreateGroupRequest {
|
||||
private Long groupSize;
|
||||
private Long educationFormId;
|
||||
private Long departmentId;
|
||||
private Integer course;
|
||||
private Integer yearStartStudy;
|
||||
private Long specialityCode;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
@@ -40,11 +41,19 @@ public class CreateGroupRequest {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Integer getCourse() {
|
||||
return course;
|
||||
public Integer getYearStartStudy() {
|
||||
return yearStartStudy;
|
||||
}
|
||||
|
||||
public void setCourse(Integer course) {
|
||||
this.course = course;
|
||||
public void setYearStartStudy(Integer yearStartStudy) {
|
||||
this.yearStartStudy = yearStartStudy;
|
||||
}
|
||||
|
||||
public Long getSpecialityCode() {
|
||||
return specialityCode;
|
||||
}
|
||||
|
||||
public void setSpecialityCode(Long specialityCode) {
|
||||
this.specialityCode = specialityCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
public class CreateScheduleDataRequest {
|
||||
private Long id;
|
||||
private Long departmentId;
|
||||
private Long semester;
|
||||
private Long groupId;
|
||||
private Long subjectsId;
|
||||
private Long lessonTypeId;
|
||||
private Long numberOfHours;
|
||||
private Boolean isDivision;
|
||||
private Boolean division;
|
||||
private Long teacherId;
|
||||
private String semesterType;
|
||||
private SemesterType semesterType;
|
||||
private String period;
|
||||
|
||||
public Long getId() {
|
||||
@@ -29,14 +30,6 @@ public class CreateScheduleDataRequest {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Long semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
@@ -70,11 +63,11 @@ public class CreateScheduleDataRequest {
|
||||
}
|
||||
|
||||
public Boolean getDivision() {
|
||||
return isDivision;
|
||||
return division;
|
||||
}
|
||||
|
||||
public void setDivision(Boolean division) {
|
||||
isDivision = division;
|
||||
this.division = division;
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
@@ -85,11 +78,11 @@ public class CreateScheduleDataRequest {
|
||||
this.teacherId = teacherId;
|
||||
}
|
||||
|
||||
public String getSemesterType() {
|
||||
public SemesterType getSemesterType() {
|
||||
return semesterType;
|
||||
}
|
||||
|
||||
public void setSemesterType(String semesterType) {
|
||||
public void setSemesterType(SemesterType semesterType) {
|
||||
this.semesterType = semesterType;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class GroupResponse {
|
||||
|
||||
private Long id;
|
||||
@@ -8,9 +11,12 @@ public class GroupResponse {
|
||||
private Long educationFormId;
|
||||
private String educationFormName;
|
||||
private Long departmentId;
|
||||
private Integer yearStartStudy;
|
||||
private Integer course;
|
||||
private Integer semester;
|
||||
private Long specialityCode;
|
||||
|
||||
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course) {
|
||||
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Integer semester, Long specialityCode) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.groupSize = groupSize;
|
||||
@@ -18,6 +24,19 @@ public class GroupResponse {
|
||||
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() {
|
||||
@@ -47,4 +66,16 @@ public class GroupResponse {
|
||||
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 role;
|
||||
private String redirect;
|
||||
private Long departmentId;
|
||||
|
||||
public LoginResponse() {
|
||||
}
|
||||
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect) {
|
||||
public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.token = token;
|
||||
this.role = role;
|
||||
this.redirect = redirect;
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
@@ -58,4 +60,12 @@ public class LoginResponse {
|
||||
public void setRedirect(String redirect) {
|
||||
this.redirect = redirect;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +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 semester;
|
||||
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 isDivision;
|
||||
private Boolean division;
|
||||
private Long teacherId;
|
||||
private String semesterType;
|
||||
private String teacherName;
|
||||
private String teacherJobTitle;
|
||||
private SemesterType semesterType;
|
||||
private String period;
|
||||
|
||||
public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, 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.semester = semester;
|
||||
this.groupId = groupId;
|
||||
this.subjectsId = subjectsId;
|
||||
this.lessonTypeId = lessonTypeId;
|
||||
this.numberOfHours = numberOfHours;
|
||||
this.isDivision = isDivision;
|
||||
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 void setId(Long id) {
|
||||
this.id = id;
|
||||
public String getSpecialityCode() {
|
||||
return specialityCode;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Long semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(Long groupId) {
|
||||
this.groupId = groupId;
|
||||
public String getGroupName() {
|
||||
return groupName;
|
||||
}
|
||||
|
||||
public Integer getGroupCourse() {
|
||||
return groupCourse;
|
||||
}
|
||||
|
||||
public Integer getGroupSemester() {
|
||||
return groupSemester;
|
||||
}
|
||||
|
||||
public Long getSubjectsId() {
|
||||
return subjectsId;
|
||||
}
|
||||
|
||||
public void setSubjectsId(Long subjectsId) {
|
||||
this.subjectsId = subjectsId;
|
||||
public String getSubjectName() {
|
||||
return subjectName;
|
||||
}
|
||||
|
||||
public Long getLessonTypeId() {
|
||||
return lessonTypeId;
|
||||
}
|
||||
|
||||
public void setLessonTypeId(Long lessonTypeId) {
|
||||
this.lessonTypeId = lessonTypeId;
|
||||
public String getLessonType() {
|
||||
return lessonType;
|
||||
}
|
||||
|
||||
public Long getNumberOfHours() {
|
||||
return numberOfHours;
|
||||
}
|
||||
|
||||
public void setNumberOfHours(Long numberOfHours) {
|
||||
this.numberOfHours = numberOfHours;
|
||||
}
|
||||
|
||||
public Boolean getDivision() {
|
||||
return isDivision;
|
||||
}
|
||||
|
||||
public void setDivision(Boolean division) {
|
||||
isDivision = division;
|
||||
return division;
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
return teacherId;
|
||||
}
|
||||
|
||||
public void setTeacherId(Long teacherId) {
|
||||
this.teacherId = teacherId;
|
||||
public String getTeacherName() {
|
||||
return teacherName;
|
||||
}
|
||||
|
||||
public String getSemesterType() {
|
||||
public String getTeacherJobTitle() {
|
||||
return teacherJobTitle;
|
||||
}
|
||||
|
||||
public SemesterType getSemesterType() {
|
||||
return semesterType;
|
||||
}
|
||||
|
||||
public void setSemesterType(String semesterType) {
|
||||
this.semesterType = semesterType;
|
||||
}
|
||||
|
||||
public String getPeriod() {
|
||||
return period;
|
||||
}
|
||||
|
||||
public void setPeriod(String period) {
|
||||
this.period = period;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,21 @@ public class UserResponse {
|
||||
private String role;
|
||||
private String fullName;
|
||||
private String jobTitle;
|
||||
private String departmentName;
|
||||
private Long departmentId;
|
||||
|
||||
public UserResponse() {
|
||||
}
|
||||
|
||||
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.role = role;
|
||||
this.fullName = fullName;
|
||||
this.jobTitle = jobTitle;
|
||||
this.departmentName = departmentName;
|
||||
}
|
||||
|
||||
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
@@ -36,47 +46,27 @@ public class UserResponse {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(String role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getJobTitle() {
|
||||
return jobTitle;
|
||||
}
|
||||
|
||||
public void setJobTitle(String jobTitle) {
|
||||
this.jobTitle = jobTitle;
|
||||
public String getDepartmentName() {
|
||||
return departmentName;
|
||||
}
|
||||
|
||||
public Long getDepartmentId() {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,6 @@ public class ScheduleData {
|
||||
@Column(name="department_id", nullable = false)
|
||||
private Long departmentId;
|
||||
|
||||
@Column(name="semester", nullable = false)
|
||||
private Long semester;
|
||||
|
||||
@Column(name="group_id", nullable = false)
|
||||
private Long groupId;
|
||||
|
||||
@@ -29,28 +26,28 @@ public class ScheduleData {
|
||||
private Long numberOfHours;
|
||||
|
||||
@Column(name="is_division", nullable = false)
|
||||
private Boolean isDivision;
|
||||
private Boolean division;
|
||||
|
||||
@Column(name="teacher_id", nullable = false)
|
||||
private Long teacherId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name="semester_type", nullable = false)
|
||||
private String semesterType;
|
||||
private SemesterType semesterType;
|
||||
|
||||
@Column(name="period", nullable = false)
|
||||
private String period;
|
||||
|
||||
public ScheduleData() {}
|
||||
|
||||
public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) {
|
||||
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.semester = semester;
|
||||
this.groupId = groupId;
|
||||
this.subjectsId = subjectsId;
|
||||
this.lessonTypeId = lessonTypeId;
|
||||
this.numberOfHours = numberOfHours;
|
||||
this.isDivision = isDivision;
|
||||
this.division = division;
|
||||
this.teacherId = teacherId;
|
||||
this.semesterType = semesterType;
|
||||
this.period = period;
|
||||
@@ -72,14 +69,6 @@ public class ScheduleData {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Long getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Long semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public Long getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
@@ -113,11 +102,11 @@ public class ScheduleData {
|
||||
}
|
||||
|
||||
public Boolean getDivision() {
|
||||
return isDivision;
|
||||
return division;
|
||||
}
|
||||
|
||||
public void setDivision(Boolean division) {
|
||||
isDivision = division;
|
||||
this.division = division;
|
||||
}
|
||||
|
||||
public Long getTeacherId() {
|
||||
@@ -128,11 +117,11 @@ public class ScheduleData {
|
||||
this.teacherId = teacherId;
|
||||
}
|
||||
|
||||
public String getSemesterType() {
|
||||
public SemesterType getSemesterType() {
|
||||
return semesterType;
|
||||
}
|
||||
|
||||
public void setSemesterType(String semesterType) {
|
||||
public void setSemesterType(SemesterType semesterType) {
|
||||
this.semesterType = semesterType;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum SemesterType {
|
||||
spring,
|
||||
autumn
|
||||
}
|
||||
@@ -23,8 +23,11 @@ public class StudentGroup {
|
||||
@Column(name = "department_id", nullable = false)
|
||||
private Long departmentId;
|
||||
|
||||
@Column(name = "course", nullable = false)
|
||||
private Integer course;
|
||||
@Column(name="specialty_code", nullable = false)
|
||||
private Long specialityCode;
|
||||
|
||||
@Column(name="year_start_study", nullable = false)
|
||||
private Integer yearStartStudy;
|
||||
|
||||
public StudentGroup() {
|
||||
}
|
||||
@@ -69,11 +72,19 @@ public class StudentGroup {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public Integer getCourse() {
|
||||
return course;
|
||||
public Long getSpecialityCode() {
|
||||
return specialityCode;
|
||||
}
|
||||
|
||||
public void setCourse(Integer course) {
|
||||
this.course = course;
|
||||
public void setSpecialityCode(Long specialityCode) {
|
||||
this.specialityCode = specialityCode;
|
||||
}
|
||||
|
||||
public Integer getYearStartStudy() {
|
||||
return yearStartStudy;
|
||||
}
|
||||
|
||||
public void setYearStartStudy(Integer yearStartStudy) {
|
||||
this.yearStartStudy = yearStartStudy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -1,7 +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,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));
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,15 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
||||
group_size BIGINT NOT NULL,
|
||||
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
course INT CHECK (course BETWEEN 1 AND 6),
|
||||
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, course)
|
||||
VALUES ('ИВТ-21-1', 25, 1, 1, 3),
|
||||
('ИБ-41м', 15, 2, 1, 2)
|
||||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study)
|
||||
VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025),
|
||||
('ИБ-41м', 15, 2, 1, 1, 2024)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
@@ -238,7 +239,6 @@ INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesso
|
||||
CREATE TABLE IF NOT EXISTS schedule_data (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
semester INT NOT NULL,
|
||||
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),
|
||||
@@ -249,10 +249,17 @@ CREATE TABLE IF NOT EXISTS schedule_data (
|
||||
period VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
|
||||
VALUES (1, 1, 1, 1, 3, 2, true, 1, 'Весенний', '2024/2025'),
|
||||
(2, 4, 2, 3, 2, 1, false, 2, 'Осенний', '2025/2026'),
|
||||
(3, 5, 1, 2, 1, 3, true, 1, 'Весенний', '2023/2024');
|
||||
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
|
||||
@@ -279,7 +286,6 @@ 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.semester 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 'Идентификатор типа занятия';
|
||||
@@ -318,7 +324,6 @@ 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.course IS 'Курс';
|
||||
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';
|
||||
|
||||
COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
|
||||
|
||||
@@ -8,6 +8,9 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
@@ -32,6 +35,11 @@ services:
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: supersecretpassword
|
||||
POSTGRES_DB: app_db
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
|
||||
84
docs/API.md
84
docs/API.md
@@ -53,8 +53,8 @@
|
||||
**Ответ:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN" },
|
||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER" }
|
||||
{ "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
|
||||
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
|
||||
]
|
||||
```
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
|
||||
Список только преподавателей (роль `TEACHER`).
|
||||
|
||||
### `GET /api/users/teachers/{departmentId}`
|
||||
|
||||
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
|
||||
|
||||
### `POST /api/users`
|
||||
|
||||
Создание пользователя.
|
||||
@@ -69,16 +73,21 @@
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"username": "Новый преподаватель",
|
||||
"password": "password123",
|
||||
"role": "TEACHER"
|
||||
"username": "teacher1",
|
||||
"password": "password",
|
||||
"role": "TEACHER",
|
||||
"fullName": "Test Teacher",
|
||||
"jobTitle": "Proffessor",
|
||||
"departmentId": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Валидация:**
|
||||
- `username` — обязателен
|
||||
- `username` — обязателен и уникален
|
||||
- `password` — минимум 4 символа
|
||||
- `role` — `ADMIN`, `TEACHER` или `STUDENT`
|
||||
- `fullName` — обязателен
|
||||
- `departmentId` — обязателен
|
||||
|
||||
### `DELETE /api/users/{id}`
|
||||
|
||||
@@ -135,6 +144,7 @@
|
||||
```
|
||||
|
||||
**Валидация:**
|
||||
|
||||
| Поле | Правило |
|
||||
|------|---------|
|
||||
| `teacherId` | Обязателен, ≠ 0 |
|
||||
@@ -147,10 +157,36 @@
|
||||
| `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}`
|
||||
|
||||
Удаление занятия.
|
||||
@@ -175,20 +211,30 @@
|
||||
"name": "ИВТ-21-1",
|
||||
"groupSize": 25,
|
||||
"educationFormId": 1,
|
||||
"educationFormName": "Бакалавриат"
|
||||
"educationFormName": "Бакалавриат",
|
||||
"departmentId": 1,
|
||||
"course": 3,
|
||||
"specialityCode": 1
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/groups/{departmentId}`
|
||||
|
||||
Список всех групп привязанных к конкретной кафедре.
|
||||
|
||||
### `POST /api/groups`
|
||||
|
||||
Создание группы.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ИБ-31м",
|
||||
"groupSize": 20,
|
||||
"educationFormId": 2
|
||||
"name": "ИВТ-11",
|
||||
"groupSize": 12,
|
||||
"educationFormId": 1,
|
||||
"departmentId": 1,
|
||||
"course": 2,
|
||||
"specialityCode": 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -249,10 +295,26 @@
|
||||
|
||||
Список всех дисциплин.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Физика",
|
||||
"code": null,
|
||||
"departmentId": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/subjects/{departmentId}`
|
||||
|
||||
Список всех дисциплин привязанных к кафедре.
|
||||
|
||||
### `POST /api/subjects`
|
||||
|
||||
```json
|
||||
{ "name": "Физика" }
|
||||
{
|
||||
"name": "Физика",
|
||||
"code": null,
|
||||
"departmentId": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/subjects/{id}`
|
||||
|
||||
@@ -28,12 +28,14 @@ frontend/
|
||||
│ │ ├── main.css # CSS-переменные, цвета, типографика
|
||||
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
|
||||
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
|
||||
│ │ └── modals.css # Модальные окна
|
||||
│ │ ├── modals.css # Модальные окна
|
||||
│ │ ├── department.css # Стили кафедры
|
||||
│ │ └── departments-data.css # Стили создания кафедры/специальности
|
||||
│ ├── js/
|
||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
||||
│ │ ├── utils.js # Утилиты
|
||||
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия)
|
||||
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
|
||||
│ │ └── views/ # Модули представлений
|
||||
│ │ ├── users.js # Управление пользователями
|
||||
│ │ ├── groups.js # Управление группами
|
||||
@@ -42,16 +44,30 @@ frontend/
|
||||
│ │ ├── equipments.js # Управление оборудованием
|
||||
│ │ ├── edu-forms.js # Формы обучения
|
||||
│ │ ├── schedule.js # Расписание занятий
|
||||
│ │ └── database.js # Управление тенантами
|
||||
│ └── views/ # HTML-шаблоны представлений
|
||||
│ ├── users.html
|
||||
│ ├── groups.html
|
||||
│ ├── classrooms.html
|
||||
│ ├── subjects.html
|
||||
│ ├── equipments.html
|
||||
│ ├── edu-forms.html
|
||||
│ ├── schedule.html
|
||||
│ └── database.html
|
||||
│ │ ├── 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 # Просмотр расписания
|
||||
@@ -92,6 +108,17 @@ frontend/
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `schedule` | Расписание | `/api/users/lessons` |
|
||||
| `database` | Тенанты | `/api/database` |
|
||||
| `department` | Кафедры | `/api/departments` |
|
||||
| `departments-data` | Создание кафедры/специальности | `/api/departments` |
|
||||
|
||||
### Страница настроек (`/admin/settings/`)
|
||||
|
||||
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
|
||||
|
||||
- Доступ: через dropdown «Настройки» в footer боковой панели админки
|
||||
- Кнопка «Назад в панель» для возврата в `/admin/`
|
||||
- Текущие вкладки:
|
||||
- **Общие настройки** — заглушка (в разработке)
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +184,7 @@ export function isAuthenticatedAsAdmin() {
|
||||
|
||||
### Выход
|
||||
|
||||
Кнопка «Выйти» очищает `localStorage` и перенаправляет на `/`.
|
||||
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -165,12 +192,14 @@ export function isAuthenticatedAsAdmin() {
|
||||
|
||||
### Модульный подход
|
||||
|
||||
Стили разделены на 4 файла (порядок подключения важен):
|
||||
Стили разделены на модульные файлы (порядок подключения важен):
|
||||
|
||||
1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема
|
||||
2. **`layout.css`** — Sidebar, topbar, content area, responsive
|
||||
3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы
|
||||
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`** — Стили создания кафедры/специальности
|
||||
|
||||
### Темизация
|
||||
|
||||
@@ -194,10 +223,34 @@ CSS-переменные позволяют поддерживать светл
|
||||
|
||||
---
|
||||
|
||||
## Боковая панель (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
|
||||
- Sidebar скрывается на экранах < 768px, выезжает как overlay
|
||||
- Появляется кнопка-гамбургер (`#menu-toggle`)
|
||||
- Sidebar выезжает как overlay
|
||||
- Кнопка-крестик закрывает sidebar на всех устройствах
|
||||
- Таблицы получают горизонтальный скролл
|
||||
|
||||
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`).
|
||||
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 select {
|
||||
.filter-row input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-input);
|
||||
@@ -85,20 +85,22 @@
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
.form-group input::placeholder,
|
||||
.filter-row input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
.filter-row input:focus {
|
||||
background: var(--bg-input-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.form-group input:focus::placeholder {
|
||||
.form-group input:focus::placeholder,
|
||||
.filter-row input:focus::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -114,34 +116,187 @@ input[type="number"] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
/* Select Base Style */
|
||||
.form-group select,
|
||||
.filter-row select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2.25rem;
|
||||
/* ===== Premium Custom Dropdown Styles ===== */
|
||||
.custom-select-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group select option,
|
||||
.filter-row select option {
|
||||
background: #1a1a2e;
|
||||
.custom-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.filter-row .custom-select-trigger {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.custom-select-trigger:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.custom-select-trigger:focus,
|
||||
.custom-select-wrapper.open .custom-select-trigger {
|
||||
background: var(--bg-input-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.filter-row .custom-select-wrapper.open .custom-select-trigger,
|
||||
.filter-row .custom-select-trigger:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.custom-select-trigger.placeholder-active .custom-select-text {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.custom-select-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-select-icon {
|
||||
margin-left: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.custom-select-wrapper.open .custom-select-icon {
|
||||
transform: rotate(180deg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.custom-select-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
padding: 0.5rem;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
transform-origin: top center;
|
||||
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-select-wrapper.open .custom-select-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Dropdown */
|
||||
.custom-select-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-select-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.custom-select-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.custom-select-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.custom-select-item {
|
||||
padding: 0.6rem 0.8rem;
|
||||
margin-bottom: 0.15rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-select-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.custom-select-item:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.custom-select-item.selected {
|
||||
background: var(--accent-glow);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.custom-select-item.selected:hover {
|
||||
background: var(--accent-glow);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
.custom-select-item.disabled {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.custom-select-item.placeholder-item {
|
||||
display: none; /* Hide placeholder options in the actual dropdown list naturally */
|
||||
}
|
||||
|
||||
/* Light theme selects */
|
||||
[data-theme="light"] .form-group input,
|
||||
[data-theme="light"] .form-group select,
|
||||
[data-theme="light"] .filter-row select {
|
||||
[data-theme="light"] .filter-row input,
|
||||
[data-theme="light"] .custom-select-trigger {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] .form-group select option,
|
||||
[data-theme="light"] .filter-row select option {
|
||||
background: #fff;
|
||||
color: #1a1a2e;
|
||||
[data-theme="light"] .custom-select-menu {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
|
||||
}
|
||||
|
||||
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .custom-select-item.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Filter Row */
|
||||
@@ -172,7 +327,7 @@ input[type="number"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
.filter-row input {
|
||||
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid transparent;
|
||||
@@ -182,7 +337,7 @@ input[type="number"] {
|
||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.filter-row select:focus {
|
||||
.filter-row input:focus {
|
||||
background-color: var(--bg-input-focus);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
@@ -230,26 +385,33 @@ input[type="number"] {
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
padding: 0.5rem;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all var(--transition);
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
transform-origin: top center;
|
||||
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
|
||||
}
|
||||
|
||||
[data-theme="light"] .custom-multi-select .dropdown-menu {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
|
||||
}
|
||||
|
||||
.dropdown-menu.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.dropdown-menu.open {
|
||||
@@ -261,26 +423,102 @@ input[type="number"] {
|
||||
.checkbox-group-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 200px;
|
||||
gap: 0.25rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.checkbox-group-vertical::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.checkbox-group-vertical::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.checkbox-group-vertical::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
padding: 0.5rem 0.5rem 0.5rem 2.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--accent);
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkbox-item .checkmark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.6rem;
|
||||
transform: translateY(-50%);
|
||||
height: 1.15rem;
|
||||
width: 1.15rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkbox-item:hover input ~ .checkmark {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-item input:focus ~ .checkmark {
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.checkbox-item input:checked ~ .checkmark {
|
||||
background-color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.checkmark::after {
|
||||
content: "";
|
||||
display: none;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.checkbox-item input:checked ~ .checkmark::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
@@ -754,3 +992,44 @@ tbody tr:hover {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ===== Theme Toggle Button ===== */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||
z-index: 100;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.theme-toggle--fixed {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
}
|
||||
@@ -1,3 +1,82 @@
|
||||
/* ===== Оверлей для модалок создания записей (к/ф) ===== */
|
||||
.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;
|
||||
@@ -233,3 +312,33 @@ details.table-item .content td{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,34 @@
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
z-index: 1000;
|
||||
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.sidebar-close-btn:hover {
|
||||
background: var(--bg-card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -99,7 +120,7 @@
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
.btn-settings {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -116,16 +137,189 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
.btn-settings:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-dropdown.open .settings-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Settings Dropdown Menu */
|
||||
.settings-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
padding: 0.5rem;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
transform-origin: bottom center;
|
||||
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
|
||||
}
|
||||
|
||||
[data-theme="light"] .settings-menu {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
|
||||
}
|
||||
|
||||
.settings-dropdown.open .settings-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.settings-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
|
||||
width: 100%;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.settings-menu-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-menu-item--danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.settings-menu-item--danger:hover {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--bg-card-border);
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
/* Desktop Collapse State */
|
||||
@media (min-width: 769px) {
|
||||
.sidebar.collapsed {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo span,
|
||||
.sidebar.collapsed .settings-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item span,
|
||||
.sidebar.collapsed .btn-settings span {
|
||||
position: absolute;
|
||||
left: calc(100% + 10px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-10px);
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar.collapsed .nav-item span,
|
||||
[data-theme="light"] .sidebar.collapsed .btn-settings span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item:hover span,
|
||||
.sidebar.collapsed .btn-settings:hover span {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-close-btn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .btn-settings {
|
||||
justify-content: center;
|
||||
padding: 0.65rem 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.main.sidebar-collapsed {
|
||||
margin-left: 74px;
|
||||
}
|
||||
|
||||
.main.sidebar-collapsed .menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar {
|
||||
@@ -180,7 +374,9 @@
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition), visibility var(--transition);
|
||||
}
|
||||
|
||||
/* ===== Responsive Mobile ===== */
|
||||
@@ -212,5 +408,7 @@
|
||||
|
||||
.sidebar-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" href="css/modals.css">
|
||||
<link rel="stylesheet" href="css/department.css">
|
||||
<link rel="stylesheet" href="css/departments-data.css">
|
||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -36,6 +37,11 @@
|
||||
</svg>
|
||||
<span>Magistr</span>
|
||||
</div>
|
||||
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item" data-tab="users">
|
||||
@@ -46,7 +52,7 @@
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
Пользователи
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="department">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
@@ -57,14 +63,14 @@
|
||||
<path d="M8 11h0M12 11h0M16 11h0" />
|
||||
<path d="M10 21v-4h4v4" />
|
||||
</svg>
|
||||
Кафедра
|
||||
<span>Кафедра</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="departments-data">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
Создание кафедры/специальности
|
||||
<span>Создание кафедры/специальности</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="groups">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -72,7 +78,7 @@
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
Группы
|
||||
<span>Группы</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -82,7 +88,7 @@
|
||||
<line x1="9" y1="7" x2="17" y2="7" />
|
||||
<line x1="9" y1="11" x2="15" y2="11" />
|
||||
</svg>
|
||||
Формы обучения
|
||||
<span>Формы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="equipments">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -90,14 +96,14 @@
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||
</svg>
|
||||
Оборудование
|
||||
<span>Оборудование</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="classrooms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
Аудитории
|
||||
<span>Аудитории</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="subjects">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -105,7 +111,7 @@
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
Дисциплины
|
||||
<span>Дисциплины</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -114,7 +120,15 @@
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
Расписание занятий
|
||||
<span>Расписание занятий</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="auditorium-workload">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</svg>
|
||||
<span>Загруженность аудиторий</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="database">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -122,12 +136,35 @@
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
База данных
|
||||
<span>База данных</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-logout" id="btn-logout">
|
||||
<div class="settings-dropdown" id="settings-dropdown">
|
||||
<button class="btn-settings" id="btn-settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
<span>Настройки</span>
|
||||
<svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="settings-menu" id="settings-menu">
|
||||
<a href="/admin/settings/" class="settings-menu-item">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
Общие настройки
|
||||
</a>
|
||||
<div class="settings-menu-divider"></div>
|
||||
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
@@ -136,6 +173,8 @@
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 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,7 +1,22 @@
|
||||
import './otel.js';
|
||||
// OTel: загружаем только на продакшене (не на localhost)
|
||||
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
||||
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
||||
}
|
||||
|
||||
import { isAuthenticatedAsAdmin } from './api.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
|
||||
// Auth check
|
||||
if (!isAuthenticatedAsAdmin()) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Global initialization for Custom Selects
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAllCustomDropdowns(document.body);
|
||||
startDropdownAutoObserver();
|
||||
});
|
||||
|
||||
import { initUsers } from './views/users.js';
|
||||
import { initGroups } from './views/groups.js';
|
||||
@@ -13,6 +28,7 @@ import {initSchedule} from "./views/schedule.js";
|
||||
import {initDatabase} from "./views/database.js";
|
||||
import {initDepartment} from "./views/department.js";
|
||||
import {initDepartmentsData} from "./views/departments-data.js";
|
||||
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
|
||||
|
||||
// Configuration
|
||||
const ROUTES = {
|
||||
@@ -23,6 +39,7 @@ const ROUTES = {
|
||||
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
|
||||
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
||||
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
|
||||
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload },
|
||||
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
|
||||
department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment },
|
||||
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
|
||||
@@ -37,7 +54,9 @@ const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const menuToggle = document.getElementById('menu-toggle');
|
||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
// Initial auth check
|
||||
if (!isAuthenticatedAsAdmin()) {
|
||||
@@ -48,16 +67,56 @@ if (!isAuthenticatedAsAdmin()) {
|
||||
applyRippleEffect();
|
||||
closeAllDropdownsOnOutsideClick();
|
||||
|
||||
// Menu Toggle
|
||||
// Init sidebar state from localStorage on load
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// Menu Toggle (Hamburger)
|
||||
menuToggle.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.toggle('open');
|
||||
sidebarOverlay.classList.toggle('open');
|
||||
} else {
|
||||
sidebar.classList.remove('collapsed');
|
||||
main.classList.remove('sidebar-collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Sidebar Close (X button)
|
||||
sidebarCloseBtn?.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
});
|
||||
|
||||
sidebarOverlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Settings Dropdown
|
||||
const settingsDropdown = document.getElementById('settings-dropdown');
|
||||
const btnSettings = document.getElementById('btn-settings');
|
||||
|
||||
btnSettings.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
settingsDropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!settingsDropdown.contains(e.target)) {
|
||||
settingsDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,456 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
export async function initDepartment() { }
|
||||
// Ключ для хранения данных в 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export function renderEquipmentCheckboxes(equipments, containerId, textId, check
|
||||
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
|
||||
return `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
|
||||
<input type="checkbox" value="${eq.id}" ${isChecked}>
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`}).join('');
|
||||
updateSelectText(containerId, textId);
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function initGroups() {
|
||||
populateEfSelects(educationForms);
|
||||
await loadGroups();
|
||||
} catch (e) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки данных</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Ошибка загрузки данных</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function initGroups() {
|
||||
allGroups = await api.get('/api/groups');
|
||||
applyGroupFilter();
|
||||
} catch (e) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет групп</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(g => `
|
||||
@@ -72,6 +72,7 @@ export async function initGroups() {
|
||||
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
|
||||
<td>${g.departmentId || '-'}</td>
|
||||
<td>${g.course || '-'}</td>
|
||||
<td>${escapeHtml(g.specialityCode || '-')}</td>
|
||||
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
@@ -84,12 +85,14 @@ export async function initGroups() {
|
||||
const educationFormId = newGroupEfSelect.value;
|
||||
const departmentId = document.getElementById('new-group-department').value;
|
||||
const course = document.getElementById('new-group-course').value;
|
||||
const specialityCode = document.getElementById('new-group-speciality-code').value.trim();
|
||||
|
||||
if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
|
||||
if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; }
|
||||
if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; }
|
||||
if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; }
|
||||
if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; }
|
||||
if (!specialityCode) { showAlert('create-group-alert', 'Введите код специальности', 'error'); return; }
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/groups', {
|
||||
@@ -97,7 +100,8 @@ export async function initGroups() {
|
||||
groupSize: Number(groupSize),
|
||||
educationFormId: Number(educationFormId),
|
||||
departmentId: Number(departmentId),
|
||||
course: Number(course)
|
||||
course: Number(course),
|
||||
specialityCode: specialityCode
|
||||
});
|
||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success');
|
||||
createGroupForm.reset();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
export async function initSchedule() {
|
||||
const tbody = document.getElementById('schedule-tbody');
|
||||
@@ -20,7 +20,6 @@ export async function initSchedule() {
|
||||
|
||||
// ===================== Фильтрация =====================
|
||||
|
||||
// Извлечение отображаемого значения поля для фильтрации
|
||||
function getDisplayValue(lesson, key) {
|
||||
switch (key) {
|
||||
case 'teacher':
|
||||
@@ -38,20 +37,17 @@ export async function initSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
// Собрать уникальные значения из данных
|
||||
function getUniqueValues(key) {
|
||||
const vals = new Set();
|
||||
lessonsData.forEach(lesson => {
|
||||
vals.add(getDisplayValue(lesson, key));
|
||||
});
|
||||
// Для дней — сортируем по порядку
|
||||
if (key === 'day') {
|
||||
return [...vals].sort((a, b) => (dayOrder[a.toLowerCase()] ?? 99) - (dayOrder[b.toLowerCase()] ?? 99));
|
||||
}
|
||||
return [...vals].sort((a, b) => a.localeCompare(b, 'ru'));
|
||||
}
|
||||
|
||||
// Применить все фильтры
|
||||
function applyFilters(lessons) {
|
||||
return lessons.filter(lesson => {
|
||||
for (const key of Object.keys(activeFilters)) {
|
||||
@@ -79,7 +75,6 @@ export async function initSchedule() {
|
||||
|
||||
function onDocumentClick(e) {
|
||||
if (currentPopup && !currentPopup.contains(e.target)) {
|
||||
// Проверяем, не кликнули ли по иконке фильтра
|
||||
if (!e.target.closest('.filter-icon')) {
|
||||
closePopup();
|
||||
}
|
||||
@@ -87,7 +82,6 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function openFilterPopup(th, filterKey) {
|
||||
// Если уже открыт этот же — закрыть
|
||||
if (currentPopup && currentPopup.dataset.filterKey === filterKey) {
|
||||
closePopup();
|
||||
return;
|
||||
@@ -97,19 +91,16 @@ export async function initSchedule() {
|
||||
const uniqueValues = getUniqueValues(filterKey);
|
||||
const currentFilter = activeFilters[filterKey];
|
||||
|
||||
// Создаём попап
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'filter-popup';
|
||||
popup.dataset.filterKey = filterKey;
|
||||
|
||||
// Поисковое поле
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.className = 'filter-search';
|
||||
searchInput.placeholder = 'Поиск...';
|
||||
popup.appendChild(searchInput);
|
||||
|
||||
// Кнопки «Выбрать все» / «Сбросить»
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'filter-btn-row';
|
||||
|
||||
@@ -133,7 +124,6 @@ export async function initSchedule() {
|
||||
btnRow.appendChild(btnNone);
|
||||
popup.appendChild(btnRow);
|
||||
|
||||
// Список чекбоксов
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'filter-list';
|
||||
|
||||
@@ -146,7 +136,6 @@ export async function initSchedule() {
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = val;
|
||||
// Если фильтр активен — отмечаем только выбранные; если нет — все отмечены
|
||||
cb.checked = currentFilter ? currentFilter.has(val) : true;
|
||||
|
||||
const span = document.createElement('span');
|
||||
@@ -160,7 +149,6 @@ export async function initSchedule() {
|
||||
|
||||
popup.appendChild(listWrap);
|
||||
|
||||
// Кнопка «Применить»
|
||||
const btnApply = document.createElement('button');
|
||||
btnApply.className = 'filter-btn-apply';
|
||||
btnApply.textContent = 'Применить';
|
||||
@@ -171,7 +159,6 @@ export async function initSchedule() {
|
||||
if (cb.checked) selected.add(cb.value);
|
||||
});
|
||||
|
||||
// Если все выбраны — снимаем фильтр
|
||||
if (selected.size === uniqueValues.length) {
|
||||
delete activeFilters[filterKey];
|
||||
th.classList.remove('filter-active');
|
||||
@@ -185,7 +172,6 @@ export async function initSchedule() {
|
||||
});
|
||||
popup.appendChild(btnApply);
|
||||
|
||||
// Поиск по чекбоксам
|
||||
searchInput.addEventListener('input', () => {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
listWrap.querySelectorAll('.filter-item').forEach(item => {
|
||||
@@ -194,28 +180,22 @@ export async function initSchedule() {
|
||||
});
|
||||
});
|
||||
|
||||
// Предотвращаем всплытие кликов внутри попапа (чтобы не срабатывала сортировка th)
|
||||
popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// Позиционируем попап под th
|
||||
th.style.position = 'relative';
|
||||
th.appendChild(popup);
|
||||
currentPopup = popup;
|
||||
|
||||
// Фокус на поиск
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
|
||||
// Закрытие по клику вне
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', onDocumentClick, true);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Обработчики кликов по заголовкам с фильтрами (клик по всей ячейке)
|
||||
table.querySelectorAll('thead th.filterable').forEach(th => {
|
||||
th.addEventListener('click', (e) => {
|
||||
// Не открываем попап при клике внутри самого попапа
|
||||
if (e.target.closest('.filter-popup')) return;
|
||||
const filterKey = th.dataset.filterKey;
|
||||
openFilterPopup(th, filterKey);
|
||||
@@ -249,7 +229,6 @@ export async function initSchedule() {
|
||||
case 'week':
|
||||
return (lesson.week || '').toLowerCase();
|
||||
case 'time': {
|
||||
// Составной ключ: день + время для правильной сортировки
|
||||
const d = (lesson.day || '').toLowerCase();
|
||||
const dayNum = dayOrder[d] ?? 99;
|
||||
const t = lesson.time || '99:99';
|
||||
@@ -287,10 +266,8 @@ export async function initSchedule() {
|
||||
});
|
||||
}
|
||||
|
||||
// Навешиваем обработчики клика на заголовки (сортировка)
|
||||
table.querySelectorAll('thead th.sortable').forEach(th => {
|
||||
th.addEventListener('click', (e) => {
|
||||
// Не сортируем, если кликнули по иконке фильтра или внутри попапа
|
||||
if (e.target.closest('.filter-icon') || e.target.closest('.filter-popup')) return;
|
||||
|
||||
const key = th.dataset.sortKey;
|
||||
@@ -310,7 +287,7 @@ export async function initSchedule() {
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== Загрузка и рендер =====================
|
||||
// ===================== Загрузка и рендер таблицы =====================
|
||||
|
||||
async function loadSchedule() {
|
||||
try {
|
||||
@@ -318,21 +295,20 @@ export async function initSchedule() {
|
||||
lessonsData = lessons;
|
||||
renderSchedule(lessons);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="11" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSchedule(lessons) {
|
||||
if (!lessons || !lessons.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет занятий</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="11" class="loading-row">Нет занятий</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Сначала фильтруем, потом сортируем
|
||||
const filtered = applyFilters(lessons);
|
||||
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет занятий по выбранным фильтрам</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="11" class="loading-row">Нет занятий по выбранным фильтрам</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,5 +342,343 @@ export async function initSchedule() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ===================== Модалки добавления занятия =====================
|
||||
|
||||
const overlay = document.getElementById('sch-overlay');
|
||||
const modalForm = document.getElementById('sch-modal-form');
|
||||
const modalLessons = document.getElementById('sch-modal-lessons');
|
||||
const btnAddLesson = document.getElementById('sch-btn-add-lesson');
|
||||
const btnClose = document.getElementById('sch-modal-close');
|
||||
const addForm = document.getElementById('sch-add-lesson-form');
|
||||
|
||||
const schTeacherSelect = document.getElementById('sch-teacher');
|
||||
const schGroupSelect = document.getElementById('sch-group');
|
||||
const schDisciplineSelect = document.getElementById('sch-discipline');
|
||||
const schClassroomSelect = document.getElementById('sch-classroom');
|
||||
const schDaySelect = document.getElementById('sch-day');
|
||||
const schTimeSelect = document.getElementById('sch-time');
|
||||
const schTypeSelect = document.getElementById('sch-type');
|
||||
const schWeekUpper = document.getElementById('sch-week-upper');
|
||||
const schWeekLower = document.getElementById('sch-week-lower');
|
||||
const schFormatOffline = document.getElementById('sch-format-offline');
|
||||
|
||||
const schTeacherName = document.getElementById('sch-teacher-name');
|
||||
const schLessonsContainer = document.getElementById('sch-lessons-container');
|
||||
|
||||
let groups = [];
|
||||
let subjects = [];
|
||||
let classrooms = [];
|
||||
let teachers = [];
|
||||
|
||||
const weekdaysTimes = [
|
||||
"8:00-9:30", "9:40-11:10", "11:40-13:10",
|
||||
"13:20-14:50", "15:00-16:30", "16:50-18:20", "18:30-19:00"
|
||||
];
|
||||
const saturdayTimes = [
|
||||
"8:20-9:50", "10:00-11:30", "11:40-13:10", "13:20-14:50"
|
||||
];
|
||||
|
||||
// ===== Загрузка справочников =====
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups = await api.get('/api/groups');
|
||||
schGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
groups.map(g => {
|
||||
let text = escapeHtml(g.name);
|
||||
if (g.groupSize) text += ` (числ: ${g.groupSize} чел.)`;
|
||||
return `<option value="${g.id}">${text}</option>`;
|
||||
}).join('');
|
||||
} catch (e) { console.error('Ошибка загрузки групп:', e); }
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
try {
|
||||
subjects = await api.get('/api/subjects');
|
||||
schDisciplineSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
||||
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||
} catch (e) { console.error('Ошибка загрузки дисциплин:', e); }
|
||||
}
|
||||
|
||||
async function loadClassrooms() {
|
||||
try {
|
||||
classrooms = await api.get('/api/classrooms');
|
||||
renderClassroomOptions();
|
||||
} catch (e) { console.error('Ошибка загрузки аудиторий:', e); }
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
try {
|
||||
teachers = await api.get('/api/users/teachers');
|
||||
schTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)}</option>`).join('');
|
||||
} catch (e) { console.error('Ошибка загрузки преподавателей:', e); }
|
||||
}
|
||||
|
||||
function renderClassroomOptions() {
|
||||
if (!classrooms || classrooms.length === 0) {
|
||||
schClassroomSelect.innerHTML = '<option value="">Нет доступных аудиторий</option>';
|
||||
return;
|
||||
}
|
||||
const selectedGroupId = schGroupSelect.value;
|
||||
const selectedGroup = groups?.find(g => g.id == selectedGroupId);
|
||||
const groupSize = selectedGroup?.groupSize || 0;
|
||||
|
||||
schClassroomSelect.innerHTML = '<option value="">Выберите аудиторию</option>' +
|
||||
classrooms.map(c => {
|
||||
let text = escapeHtml(c.name);
|
||||
if (c.capacity) text += ` (вместимость: ${c.capacity} чел.)`;
|
||||
if (c.isAvailable === false) {
|
||||
text += ` ❌ Занята`;
|
||||
} else if (selectedGroupId && groupSize > 0 && c.capacity && groupSize > c.capacity) {
|
||||
text += ` ⚠️ Недостаточно места`;
|
||||
}
|
||||
return `<option value="${c.id}">${text}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
schGroupSelect.addEventListener('change', () => renderClassroomOptions());
|
||||
|
||||
function updateTimeOptions(dayValue) {
|
||||
let times = [];
|
||||
if (dayValue === "Суббота") {
|
||||
times = saturdayTimes;
|
||||
} else if (dayValue && dayValue !== '') {
|
||||
times = weekdaysTimes;
|
||||
} else {
|
||||
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
||||
schTimeSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
schTimeSelect.innerHTML = '<option value="">Выберите время</option>' +
|
||||
times.map(t => `<option value="${t}">${t}</option>`).join('');
|
||||
schTimeSelect.disabled = false;
|
||||
}
|
||||
|
||||
schDaySelect.addEventListener('change', function () {
|
||||
updateTimeOptions(this.value);
|
||||
});
|
||||
|
||||
// ===== Автозаполнение преподавателя из фильтра =====
|
||||
function getFilteredTeacherId() {
|
||||
const teacherFilter = activeFilters['teacher'];
|
||||
if (teacherFilter && teacherFilter.size === 1) {
|
||||
const teacherName = [...teacherFilter][0];
|
||||
// Сопоставляем по username, fullName и их комбинациям
|
||||
const match = teachers.find(t =>
|
||||
t.username === teacherName ||
|
||||
t.fullName === teacherName ||
|
||||
(t.fullName || t.username) === teacherName
|
||||
);
|
||||
return match ? String(match.id) : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ===== Загрузка занятий преподавателя =====
|
||||
async function loadTeacherLessons(teacherId) {
|
||||
const teacher = teachers.find(t => t.id == teacherId);
|
||||
const name = teacher ? (teacher.fullName || teacher.username) : '';
|
||||
schTeacherName.textContent = name
|
||||
? `Занятия преподавателя: ${name}`
|
||||
: 'Занятия преподавателя';
|
||||
|
||||
modalLessons.style.display = '';
|
||||
schLessonsContainer.innerHTML = '<div class="loading-lessons">Загрузка занятий...</div>';
|
||||
|
||||
try {
|
||||
const lessons = await api.get(`/api/users/lessons/${teacherId}`);
|
||||
|
||||
if (!lessons || !Array.isArray(lessons) || lessons.length === 0) {
|
||||
schLessonsContainer.innerHTML = '<div class="no-lessons">У преподавателя пока нет занятий</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const daysOrder = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||
const lessonsByDay = {};
|
||||
lessons.forEach(l => {
|
||||
if (!lessonsByDay[l.day]) lessonsByDay[l.day] = [];
|
||||
lessonsByDay[l.day].push(l);
|
||||
});
|
||||
Object.keys(lessonsByDay).forEach(day => {
|
||||
lessonsByDay[day].sort((a, b) => a.time.localeCompare(b.time));
|
||||
});
|
||||
|
||||
let html = '';
|
||||
daysOrder.forEach(day => {
|
||||
if (!lessonsByDay[day]) return;
|
||||
html += `<div class="lesson-day-divider">${day}</div>`;
|
||||
lessonsByDay[day].forEach(lesson => {
|
||||
html += `
|
||||
<div class="lesson-card">
|
||||
<div class="lesson-card-header">
|
||||
<span class="lesson-group">${escapeHtml(lesson.groupName)}</span>
|
||||
<span class="lesson-time">${escapeHtml(lesson.time)}</span>
|
||||
</div>
|
||||
<div class="lesson-card-body">
|
||||
<div class="lesson-subject">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-details">
|
||||
<span class="lesson-detail-item">${escapeHtml(lesson.typeLesson)}</span>
|
||||
<span class="lesson-detail-item">${escapeHtml(lesson.lessonFormat)}</span>
|
||||
<span class="lesson-detail-item">${escapeHtml(lesson.week)}</span>
|
||||
<span class="lesson-detail-item">${escapeHtml(lesson.classroomName)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
});
|
||||
|
||||
schLessonsContainer.innerHTML = html;
|
||||
} catch (e) {
|
||||
schLessonsContainer.innerHTML = `<div class="no-lessons">Ошибка загрузки: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== При смене преподавателя — подгрузить его занятия =====
|
||||
schTeacherSelect.addEventListener('change', function () {
|
||||
const teacherId = this.value;
|
||||
if (teacherId) {
|
||||
loadTeacherLessons(teacherId);
|
||||
} else {
|
||||
modalLessons.style.display = 'none';
|
||||
schLessonsContainer.innerHTML = '<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Открытие / закрытие оверлея =====
|
||||
function openOverlay() {
|
||||
// Автозаполнение преподавателя из фильтра таблицы
|
||||
const autoTeacherId = getFilteredTeacherId();
|
||||
if (autoTeacherId) {
|
||||
schTeacherSelect.value = autoTeacherId;
|
||||
loadTeacherLessons(autoTeacherId);
|
||||
}
|
||||
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
overlay.classList.remove('open');
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
addForm.reset();
|
||||
schTeacherSelect.value = '';
|
||||
schGroupSelect.value = '';
|
||||
schDisciplineSelect.value = '';
|
||||
schClassroomSelect.value = '';
|
||||
schDaySelect.value = '';
|
||||
schTypeSelect.value = '';
|
||||
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
||||
schTimeSelect.disabled = true;
|
||||
if (schWeekUpper) schWeekUpper.checked = false;
|
||||
if (schWeekLower) schWeekLower.checked = false;
|
||||
if (schFormatOffline) schFormatOffline.checked = true;
|
||||
modalLessons.style.display = 'none';
|
||||
schLessonsContainer.innerHTML = '<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>';
|
||||
hideAlert('sch-add-alert');
|
||||
}
|
||||
|
||||
btnAddLesson.addEventListener('click', openOverlay);
|
||||
btnClose.addEventListener('click', closeOverlay);
|
||||
|
||||
// Закрытие по клику на оверлей (мимо модалок)
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay || e.target.classList.contains('cs-overlay-scroll')) {
|
||||
closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие по Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && overlay.classList.contains('open')) {
|
||||
closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Отправка формы =====
|
||||
addForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('sch-add-alert');
|
||||
|
||||
const teacherId = schTeacherSelect.value;
|
||||
const groupId = schGroupSelect.value;
|
||||
const subjectId = schDisciplineSelect.value;
|
||||
const classroomId = schClassroomSelect.value;
|
||||
const lessonType = schTypeSelect.value;
|
||||
const dayOfWeek = schDaySelect.value;
|
||||
const timeSlot = schTimeSelect.value;
|
||||
const lessonFormat = document.querySelector('input[name="schLessonFormat"]:checked')?.value;
|
||||
|
||||
if (!teacherId) { showAlert('sch-add-alert', 'Выберите преподавателя', 'error'); return; }
|
||||
if (!groupId) { showAlert('sch-add-alert', 'Выберите группу', 'error'); return; }
|
||||
if (!subjectId) { showAlert('sch-add-alert', 'Выберите дисциплину', 'error'); return; }
|
||||
if (!classroomId) { showAlert('sch-add-alert', 'Выберите аудиторию', 'error'); return; }
|
||||
if (!dayOfWeek) { showAlert('sch-add-alert', 'Выберите день недели', 'error'); return; }
|
||||
if (!timeSlot) { showAlert('sch-add-alert', 'Выберите время', 'error'); return; }
|
||||
|
||||
const weekUpperChecked = schWeekUpper?.checked || false;
|
||||
const weekLowerChecked = schWeekLower?.checked || false;
|
||||
|
||||
if (!weekUpperChecked && !weekLowerChecked) {
|
||||
showAlert('sch-add-alert', 'Не выбран тип недели', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let weekType = null;
|
||||
if (weekUpperChecked && weekLowerChecked) weekType = 'Обе';
|
||||
else if (weekUpperChecked) weekType = 'Верхняя';
|
||||
else if (weekLowerChecked) weekType = 'Нижняя';
|
||||
|
||||
try {
|
||||
await api.post('/api/users/lessons/create', {
|
||||
teacherId: parseInt(teacherId),
|
||||
groupId: parseInt(groupId),
|
||||
subjectId: parseInt(subjectId),
|
||||
classroomId: parseInt(classroomId),
|
||||
typeLesson: lessonType,
|
||||
lessonFormat: lessonFormat,
|
||||
day: dayOfWeek,
|
||||
week: weekType,
|
||||
time: timeSlot
|
||||
});
|
||||
|
||||
showAlert('sch-add-alert', 'Занятие добавлено ✓', 'success');
|
||||
|
||||
// Очистить все поля кроме преподавателя (для массового добавления)
|
||||
schGroupSelect.selectedIndex = 0;
|
||||
schDisciplineSelect.selectedIndex = 0;
|
||||
schClassroomSelect.selectedIndex = 0;
|
||||
schTypeSelect.selectedIndex = 0;
|
||||
schDaySelect.selectedIndex = 0;
|
||||
schTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
||||
schTimeSelect.disabled = true;
|
||||
schWeekUpper.checked = false;
|
||||
schWeekLower.checked = false;
|
||||
document.querySelector('input[name="schLessonFormat"][value="Очно"]').checked = true;
|
||||
|
||||
// Обновить занятия преподавателя в модалке 2
|
||||
if (teacherId) {
|
||||
await loadTeacherLessons(teacherId);
|
||||
}
|
||||
|
||||
// Обновить основную таблицу
|
||||
await loadSchedule();
|
||||
|
||||
setTimeout(() => {
|
||||
hideAlert('sch-add-alert');
|
||||
}, 4000);
|
||||
} catch (err) {
|
||||
showAlert('sch-add-alert', err.message || 'Ошибка добавления занятия', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== Инициализация =====================
|
||||
await Promise.all([
|
||||
loadSchedule(),
|
||||
loadGroups(),
|
||||
loadSubjects(),
|
||||
loadClassrooms(),
|
||||
loadTeachers()
|
||||
]);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'b
|
||||
export async function initUsers() {
|
||||
const usersTbody = document.getElementById('users-tbody');
|
||||
const createForm = document.getElementById('create-form');
|
||||
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||
|
||||
// ===== Оверлей (cs-overlay) =====
|
||||
const usersOverlay = document.getElementById('users-overlay');
|
||||
|
||||
// ===== 1-е модальное окно: Добавить занятие =====
|
||||
const modalAddLesson = document.getElementById('modal-add-lesson');
|
||||
@@ -28,7 +30,6 @@ export async function initUsers() {
|
||||
|
||||
// ===== 2-е модальное окно: Просмотр занятий =====
|
||||
const modalViewLessons = document.getElementById('modal-view-lessons');
|
||||
const modalViewLessonsClose = document.getElementById('modal-view-lessons-close');
|
||||
const lessonsContainer = document.getElementById('lessons-container');
|
||||
const modalTeacherName = document.getElementById('modal-teacher-name');
|
||||
|
||||
@@ -56,36 +57,6 @@ export async function initUsers() {
|
||||
"13:20-14:50"
|
||||
];
|
||||
|
||||
// =========================================================
|
||||
// СИНХРОНИЗАЦИЯ ВЫСОТЫ 1-й МОДАЛКИ -> CSS переменная
|
||||
// =========================================================
|
||||
const addLessonContent = document.querySelector('#modal-add-lesson .modal-content');
|
||||
|
||||
function setAddLessonHeightVar(px) {
|
||||
const h = Math.max(0, Math.ceil(px || 0));
|
||||
document.documentElement.style.setProperty('--add-lesson-height', `${h}px`);
|
||||
}
|
||||
|
||||
function syncAddLessonHeight() {
|
||||
if (!addLessonContent) return;
|
||||
|
||||
if (!modalAddLesson?.classList.contains('open')) {
|
||||
// если первая модалка закрыта — "шапки" нет
|
||||
setAddLessonHeightVar(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setAddLessonHeightVar(addLessonContent.getBoundingClientRect().height);
|
||||
}
|
||||
|
||||
// Авто-обновление при любом изменении размеров первой модалки
|
||||
if (addLessonContent && 'ResizeObserver' in window) {
|
||||
const ro = new ResizeObserver(() => syncAddLessonHeight());
|
||||
ro.observe(addLessonContent);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => syncAddLessonHeight());
|
||||
|
||||
// =========================================================
|
||||
// Загрузка справочников
|
||||
// =========================================================
|
||||
@@ -213,7 +184,7 @@ export async function initUsers() {
|
||||
<td>${escapeHtml(u.username)}</td>
|
||||
<td>${escapeHtml(u.fullName || '-')}</td>
|
||||
<td>${escapeHtml(u.jobTitle || '-')}</td>
|
||||
<td>${u.departmentId || '-'}</td>
|
||||
<td>${u.departmentName || '-'}</td>
|
||||
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
|
||||
<td>
|
||||
<button class="btn-delete" data-id="${u.id}">Удалить</button>
|
||||
@@ -225,25 +196,15 @@ export async function initUsers() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateBackdrop() {
|
||||
if(!modalBackdrop) return;
|
||||
const anyOpen =
|
||||
modalAddLesson?.classList.contains('open') ||
|
||||
modalViewLessons?.classList.contains('open');
|
||||
|
||||
modalBackdrop.classList.toggle('open', anyOpen);
|
||||
// ===== Открытие / закрытие оверлея =====
|
||||
function openOverlay() {
|
||||
if (usersOverlay) usersOverlay.classList.add('open');
|
||||
}
|
||||
// Клик мимо модалок закроет их, если не надо, то закомментить этот код
|
||||
modalBackdrop?.addEventListener('click', () => {
|
||||
if (modalAddLesson?.classList.contains('open')) {
|
||||
modalAddLesson.classList.remove('open');
|
||||
function closeOverlay() {
|
||||
if (usersOverlay) usersOverlay.classList.remove('open');
|
||||
if (modalViewLessons) modalViewLessons.style.display = 'none';
|
||||
resetLessonForm();
|
||||
syncAddLessonHeight();
|
||||
}
|
||||
if (modalViewLessons?.classList.contains('open')) {
|
||||
closeViewLessonsModal();
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 1-я модалка: добавление занятия
|
||||
@@ -270,9 +231,7 @@ export async function initUsers() {
|
||||
lessonDaySelect.value = '';
|
||||
updateTimeOptions('');
|
||||
|
||||
modalAddLesson.classList.add('open');
|
||||
updateBackdrop();
|
||||
requestAnimationFrame(() => syncAddLessonHeight());
|
||||
openOverlay();
|
||||
}
|
||||
|
||||
addLessonForm.addEventListener('submit', async (e) => {
|
||||
@@ -289,15 +248,20 @@ export async function initUsers() {
|
||||
|
||||
const lessonFormat = document.querySelector('input[name="lessonFormat"]:checked')?.value;
|
||||
|
||||
if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
|
||||
if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
|
||||
if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
|
||||
if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
|
||||
if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); requestAnimationFrame(() => syncAddLessonHeight()); return; }
|
||||
if (!groupId) { showAlert('add-lesson-alert', 'Выберите группу', 'error'); return; }
|
||||
if (!subjectId) { showAlert('add-lesson-alert', 'Выберите дисциплину', 'error'); return; }
|
||||
if (!classroomId) { showAlert('add-lesson-alert', 'Выберите аудиторию', 'error'); return; }
|
||||
if (!dayOfWeek) { showAlert('add-lesson-alert', 'Выберите день недели', 'error'); return; }
|
||||
if (!timeSlot) { showAlert('add-lesson-alert', 'Выберите время', 'error'); return; }
|
||||
|
||||
const weekUpperChecked = weekUpper?.checked || false;
|
||||
const weekLowerChecked = weekLower?.checked || false;
|
||||
|
||||
if (!weekUpperChecked && !weekLowerChecked) {
|
||||
showAlert('add-lesson-alert', 'Не выбран тип недели', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let weekType = null;
|
||||
if (weekUpperChecked && weekLowerChecked) weekType = 'Обе';
|
||||
else if (weekUpperChecked) weekType = 'Верхняя';
|
||||
@@ -316,57 +280,45 @@ export async function initUsers() {
|
||||
time: timeSlot
|
||||
});
|
||||
|
||||
if (modalViewLessons?.classList.contains('open') && currentLessonsTeacherId == userId) {
|
||||
if (modalViewLessons?.style.display !== 'none' && currentLessonsTeacherId == userId) {
|
||||
await loadTeacherLessons(currentLessonsTeacherId, currentLessonsTeacherName);
|
||||
}
|
||||
|
||||
showAlert('add-lesson-alert', 'Занятие добавлено', 'success');
|
||||
showAlert('add-lesson-alert', 'Занятие добавлено ✓', 'success');
|
||||
|
||||
lessonGroupSelect.value = '';
|
||||
lessonDisciplineSelect.value = '';
|
||||
lessonClassroomSelect.value = '';
|
||||
lessonTypeSelect.value = '';
|
||||
lessonDaySelect.value = '';
|
||||
lessonTimeSelect.value = '';
|
||||
lessonGroupSelect.selectedIndex = 0;
|
||||
lessonDisciplineSelect.selectedIndex = 0;
|
||||
lessonClassroomSelect.selectedIndex = 0;
|
||||
lessonTypeSelect.selectedIndex = 0;
|
||||
lessonDaySelect.selectedIndex = 0;
|
||||
lessonTimeSelect.innerHTML = '<option value="">Сначала выберите день</option>';
|
||||
lessonTimeSelect.disabled = true;
|
||||
|
||||
weekUpper.checked = false;
|
||||
weekLower.checked = false;
|
||||
document.querySelector('input[name="lessonFormat"][value="Очно"]').checked = true;
|
||||
|
||||
requestAnimationFrame(() => syncAddLessonHeight());
|
||||
|
||||
setTimeout(() => {
|
||||
hideAlert('add-lesson-alert');
|
||||
syncAddLessonHeight();
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
showAlert('add-lesson-alert', err.message || 'Ошибка добавления занятия', 'error');
|
||||
requestAnimationFrame(() => syncAddLessonHeight());
|
||||
}
|
||||
});
|
||||
|
||||
lessonDaySelect.addEventListener('change', function () {
|
||||
updateTimeOptions(this.value);
|
||||
requestAnimationFrame(() => syncAddLessonHeight());
|
||||
});
|
||||
|
||||
if (modalAddLessonClose) {
|
||||
modalAddLessonClose.addEventListener('click', () => {
|
||||
modalAddLesson.classList.remove('open');
|
||||
resetLessonForm();
|
||||
syncAddLessonHeight();
|
||||
updateBackdrop();
|
||||
});
|
||||
modalAddLessonClose.addEventListener('click', () => closeOverlay());
|
||||
}
|
||||
|
||||
if (modalAddLesson) {
|
||||
modalAddLesson.addEventListener('click', (e) => {
|
||||
if (e.target === modalAddLesson) {
|
||||
modalAddLesson.classList.remove('open');
|
||||
resetLessonForm();
|
||||
syncAddLessonHeight();
|
||||
updateBackdrop();
|
||||
// Клик по оверлею (мимо модалок) закрывает всё
|
||||
if (usersOverlay) {
|
||||
usersOverlay.querySelector('.cs-overlay-scroll')?.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('cs-overlay-scroll')) {
|
||||
closeOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -481,48 +433,20 @@ export async function initUsers() {
|
||||
currentLessonsTeacherId = teacherId;
|
||||
currentLessonsTeacherName = teacherName || '';
|
||||
|
||||
if (modalViewLessons) modalViewLessons.style.display = '';
|
||||
loadTeacherLessons(teacherId, teacherName);
|
||||
|
||||
requestAnimationFrame(() => syncAddLessonHeight());
|
||||
|
||||
modalViewLessons.classList.add('open');
|
||||
updateBackdrop();
|
||||
// document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeViewLessonsModal() {
|
||||
modalViewLessons.classList.remove('open');
|
||||
updateBackdrop();
|
||||
// document.body.style.overflow = '';
|
||||
|
||||
if (modalViewLessons) modalViewLessons.style.display = 'none';
|
||||
currentLessonsTeacherId = null;
|
||||
currentLessonsTeacherName = '';
|
||||
}
|
||||
|
||||
if (modalViewLessonsClose) {
|
||||
modalViewLessonsClose.addEventListener('click', closeViewLessonsModal);
|
||||
}
|
||||
|
||||
if (modalViewLessons) {
|
||||
modalViewLessons.addEventListener('click', (e) => {
|
||||
if (e.target === modalViewLessons) closeViewLessonsModal();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
|
||||
if (modalAddLesson?.classList.contains('open')) {
|
||||
modalAddLesson.classList.remove('open');
|
||||
resetLessonForm();
|
||||
syncAddLessonHeight();
|
||||
updateBackdrop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalViewLessons?.classList.contains('open')) {
|
||||
closeViewLessonsModal();
|
||||
return;
|
||||
if (usersOverlay?.classList.contains('open')) {
|
||||
closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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>
|
||||
@@ -1,193 +1,180 @@
|
||||
<div class="card">
|
||||
<h2>Кафедра</h2>
|
||||
|
||||
<div class="filter-row" style="gap:.75rem;">
|
||||
<label for="recordsSearch">Поиск</label>
|
||||
<input
|
||||
id="recordsSearch"
|
||||
class="records-search"
|
||||
type="search"
|
||||
placeholder="Группа, дисциплина, преподаватель…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="button" class="btn-delete" id="recordsSearchClear">Сброс</button>
|
||||
<div class="card create-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2>Запрос расписания кафедры</h2>
|
||||
<button id="btn-create-schedule" class="btn-primary" style="margin-top: 0;">Создать запись</button>
|
||||
</div>
|
||||
<form id="department-schedule-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="filter-department">Кафедра</label>
|
||||
<select id="filter-department" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Семестр</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="semesterType" value="autumn" id="sem-autumn" required>
|
||||
<span class="checkbox-btn">Осенний</span>
|
||||
</label>
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="semesterType" value="spring" id="sem-spring" required>
|
||||
<span class="checkbox-btn">Весенний</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="filter-period">Учебный год</label>
|
||||
<select id="filter-period" required>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="2026-2027">2026/2027</option>
|
||||
<option value="2025-2026">2025/2026</option>
|
||||
<option value="2024-2025">2024/2025</option>
|
||||
<option value="2023-2024">2023/2024</option>
|
||||
<option value="2022-2023">2022/2023</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" style="align-self: flex-end;">Запросить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="schedule-form-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ===== Общий оверлей для обеих модалок ===== -->
|
||||
<div class="cs-overlay" id="cs-overlay">
|
||||
<div class="cs-overlay-scroll">
|
||||
|
||||
<div class="table-wrap">
|
||||
|
||||
<!-- Таблица 1 -->
|
||||
<details class="table-item">
|
||||
<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>
|
||||
<!-- Модалка 1: Форма создания записи -->
|
||||
<div class="cs-modal cs-modal-form card" id="modal-create-schedule">
|
||||
<div class="cs-modal-header">
|
||||
<h2>Создать запись (к/ф)</h2>
|
||||
<button class="btn-close-panel" id="modal-create-schedule-close" title="Закрыть (Esc)">×</button>
|
||||
</div>
|
||||
<div class="title title-multiline">
|
||||
<span class="title-main">Данные к составлению расписания</span>
|
||||
<span class="title-sub">Кафедра: <b>Информационная безопасность</b></span>
|
||||
<span class="title-sub">Факультет: <b>ФиПИ</b></span>
|
||||
<span class="title-sub">Семестр: <b>весенний</b></span>
|
||||
<span class="title-sub">Уч. год: <b>2024/2025</b></span>
|
||||
</div>
|
||||
<div class="meta">3 записи</div>
|
||||
</summary>
|
||||
<form id="create-schedule-form">
|
||||
<input type="hidden" id="cs-department-id" value="">
|
||||
<div class="form-row"
|
||||
style="align-items: flex-start; gap: 1rem; flex-wrap: wrap; width: 100%; justify-content: space-between;">
|
||||
|
||||
<div class="content">
|
||||
<table>
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label for="cs-period">Учебный год</label>
|
||||
<select id="cs-period" required>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="2026-2027">2026/2027</option>
|
||||
<option value="2025-2026">2025/2026</option>
|
||||
<option value="2024-2025">2024/2025</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label>Семестр</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="csSemesterType" value="autumn" required>
|
||||
<span class="checkbox-btn">Осенний</span>
|
||||
</label>
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="csSemesterType" value="spring" required>
|
||||
<span class="checkbox-btn">Весенний</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label for="cs-group">Группа</label>
|
||||
<select id="cs-group" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label for="cs-subject">Дисциплина</label>
|
||||
<select id="cs-subject" required>
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label for="cs-lesson-type">Вид занятий</label>
|
||||
<select id="cs-lesson-type" required>
|
||||
<option value="">Выберите тип</option>
|
||||
<option value="1">Лекция</option>
|
||||
<option value="2">Практическая работа</option>
|
||||
<option value="3">Лабораторная работа</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 150px;">
|
||||
<label for="cs-hours">Часов (семестр)</label>
|
||||
<input type="number" id="cs-hours" required min="1" max="500" placeholder="Например: 36">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 180px;">
|
||||
<label>Деление на подгруппы</label>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; height: 42px;">
|
||||
<label class="btn-checkbox" style="width:100%;">
|
||||
<input type="checkbox" id="cs-division" value="true">
|
||||
<span class="checkbox-btn" style="width:100%; text-align:center;">Есть деление</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1 1 250px;">
|
||||
<label for="cs-teacher">Преподаватель</label>
|
||||
<select id="cs-teacher" required>
|
||||
<option value="">Выберите преподавателя</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 0 0 auto; display:flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn-primary" style="white-space: nowrap;">Добавить в список</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-alert" id="create-schedule-alert" role="alert" style="margin-top: 1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Модалка 2: Таблица подготовленных записей -->
|
||||
<div class="cs-modal cs-modal-table card" id="modal-view-schedules" style="display: none;">
|
||||
<div class="cs-modal-header">
|
||||
<h2>Подготовленные записи</h2>
|
||||
<div style="display:flex; gap: 0.75rem; align-items:center;">
|
||||
<button id="btn-save-schedules" class="btn-primary">Сохранить в БД</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="save-schedules-alert" role="alert" style="margin-bottom: 1rem;"></div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="prepared-schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Специальность</th>
|
||||
<th>Курс и семестр</th>
|
||||
<th>Уч. год</th>
|
||||
<th>Семестр</th>
|
||||
<th>Группа</th>
|
||||
<th>Дисциплина</th>
|
||||
<th>Вид занятий</th>
|
||||
<th>Часов в неделю</th>
|
||||
<th>Деление на подгруппы</th>
|
||||
<th>Фамилия преподавателя</th>
|
||||
<th>Вид</th>
|
||||
<th>Часы</th>
|
||||
<th>Деление</th>
|
||||
<th>Преподаватель</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<!-- 1 строка = 1 запись HARDCODE -->
|
||||
<tbody id="prepared-schedules-tbody">
|
||||
<tr>
|
||||
<td>09.02.07</td>
|
||||
<td>2 курс, 4 семестр</td>
|
||||
<td>ИС-21</td>
|
||||
<td>Базы данных</td>
|
||||
<td>Лабораторная</td>
|
||||
<td>2</td>
|
||||
<td>Да</td>
|
||||
<td>Иванов</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>09.02.07</td>
|
||||
<td>2 курс, 4 семестр</td>
|
||||
<td>ИС-22</td>
|
||||
<td>Операционные системы</td>
|
||||
<td>Практика</td>
|
||||
<td>1</td>
|
||||
<td>Нет</td>
|
||||
<td>Смирнов</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>09.02.07</td>
|
||||
<td>1 курс, 2 семестр</td>
|
||||
<td>ИС-12</td>
|
||||
<td>Алгоритмы</td>
|
||||
<td>Лекция</td>
|
||||
<td>2</td>
|
||||
<td>Нет</td>
|
||||
<td>Кузнецов</td>
|
||||
<td colspan="9" class="loading-row">Нет записей</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Таблица 2 -->
|
||||
<details class="table-item">
|
||||
<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">orders</div>
|
||||
<div class="meta">1 запись</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>
|
||||
<tr>
|
||||
<td>38.02.01</td>
|
||||
<td>1 курс, 1 семестр</td>
|
||||
<td>ЭК-11</td>
|
||||
<td>Экономика</td>
|
||||
<td>Лекция</td>
|
||||
<td>1</td>
|
||||
<td>Нет</td>
|
||||
<td>Петров</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Таблица 3 -->
|
||||
<details class="table-item">
|
||||
<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">products</div>
|
||||
<div class="meta">2 записи</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>
|
||||
<tr>
|
||||
<td>15.02.08</td>
|
||||
<td>3 курс, 6 семестр</td>
|
||||
<td>МС-31</td>
|
||||
<td>Материаловедение</td>
|
||||
<td>Практика</td>
|
||||
<td>3</td>
|
||||
<td>Да</td>
|
||||
<td>Сидоров</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>15.02.08</td>
|
||||
<td>3 курс, 6 семестр</td>
|
||||
<td>МС-32</td>
|
||||
<td>Технология металлов</td>
|
||||
<td>Лабораторная</td>
|
||||
<td>2</td>
|
||||
<td>Да</td>
|
||||
<td>Орлов</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" id="schedule-blocks-container">
|
||||
<!-- Сгенерированные блоки таблиц будут появляться здесь -->
|
||||
</div>
|
||||
@@ -22,8 +22,12 @@
|
||||
<input type="number" id="new-group-department" placeholder="ID" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-course">Курс</label>
|
||||
<input type="number" id="new-group-course" placeholder="1-6" min="1" max="6" required>
|
||||
<label for="new-group-yearStartStudy">Год начала обучения</label>
|
||||
<input type="number" id="new-group-yearStartStudy" required pattern="^20\d{2}$" maxlength="3" placeholder="2026">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-group-speciality-code">Код специальности</label>
|
||||
<input type="text" id="new-group-speciality-code" placeholder="09.03.01" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
</div>
|
||||
@@ -51,12 +55,13 @@
|
||||
<th>Форма обучения</th>
|
||||
<th>ID кафедры</th>
|
||||
<th>Курс</th>
|
||||
<th>Код специальности</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
<td colspan="8" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<div class="card">
|
||||
<div class="card-header-row">
|
||||
<h2>Расписание занятий</h2>
|
||||
<button class="btn-primary" id="sch-btn-add-lesson">Добавить занятие</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="schedule-table">
|
||||
<thead>
|
||||
@@ -35,9 +38,142 @@
|
||||
</thead>
|
||||
<tbody id="schedule-tbody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-row">Загрузка...</td>
|
||||
<td colspan="11" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Оверлей для модалок добавления занятия ===== -->
|
||||
<div class="cs-overlay" id="sch-overlay">
|
||||
<div class="cs-overlay-scroll">
|
||||
|
||||
<!-- Модалка 1: Форма добавления -->
|
||||
<div class="cs-modal cs-modal-form card" id="sch-modal-form">
|
||||
<div class="cs-modal-header">
|
||||
<h2>Добавить занятие</h2>
|
||||
<button class="btn-close-panel" id="sch-modal-close">×</button>
|
||||
</div>
|
||||
|
||||
<form id="sch-add-lesson-form">
|
||||
<div class="form-row" style="align-items: flex-end; gap: 1rem; flex-wrap: wrap; width: 100%; justify-content: space-between;">
|
||||
|
||||
<!-- Преподаватель -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 220px">
|
||||
<label for="sch-teacher">Преподаватель</label>
|
||||
<select id="sch-teacher" required>
|
||||
<option value="">Выберите преподавателя</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Группа -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 190px">
|
||||
<label for="sch-group">Группа</label>
|
||||
<select id="sch-group" required>
|
||||
<option value="">Выберите группу</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Дисциплина -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 220px">
|
||||
<label for="sch-discipline">Дисциплина</label>
|
||||
<select id="sch-discipline" required>
|
||||
<option value="">Выберите дисциплину</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Аудитория -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 215px">
|
||||
<label for="sch-classroom">Аудитория</label>
|
||||
<select id="sch-classroom" required>
|
||||
<option value="">Выберите аудиторию</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- День недели -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
|
||||
<label for="sch-day">День недели</label>
|
||||
<select id="sch-day" required>
|
||||
<option value="">Выберите день</option>
|
||||
<option value="Понедельник">Понедельник</option>
|
||||
<option value="Вторник">Вторник</option>
|
||||
<option value="Среда">Среда</option>
|
||||
<option value="Четверг">Четверг</option>
|
||||
<option value="Пятница">Пятница</option>
|
||||
<option value="Суббота">Суббота</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Неделя -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 192px">
|
||||
<label>Неделя</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
<label class="btn-checkbox">
|
||||
<input type="checkbox" name="schWeekType" value="Верхняя" id="sch-week-upper">
|
||||
<span class="checkbox-btn">Верхняя</span>
|
||||
</label>
|
||||
<label class="btn-checkbox">
|
||||
<input type="checkbox" name="schWeekType" value="Нижняя" id="sch-week-lower">
|
||||
<span class="checkbox-btn">Нижняя</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Тип занятия -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 160px">
|
||||
<label for="sch-type">Тип занятия</label>
|
||||
<select id="sch-type" required>
|
||||
<option value="">Выберите тип</option>
|
||||
<option value="Практическая работа">Практическая</option>
|
||||
<option value="Лекция">Лекция</option>
|
||||
<option value="Лабораторная работа">Лабораторная</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Формат занятия -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
|
||||
<label>Формат занятия</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="schLessonFormat" value="Очно" id="sch-format-offline" checked>
|
||||
<span class="checkbox-btn">Очно</span>
|
||||
</label>
|
||||
<label class="btn-checkbox">
|
||||
<input type="radio" name="schLessonFormat" value="Онлайн" id="sch-format-online">
|
||||
<span class="checkbox-btn">Онлайн</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Время занятия -->
|
||||
<div class="form-group" style="flex: 0 0 auto; max-width: 235px">
|
||||
<label for="sch-time">Время занятия</label>
|
||||
<select id="sch-time" required disabled>
|
||||
<option value="">Сначала выберите день</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка Сохранить -->
|
||||
<div class="form-group" style="flex: 0 0 auto;">
|
||||
<button type="submit" class="btn-primary" style="white-space: nowrap;">Сохранить</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="sch-add-alert" role="alert" style="margin-top: 1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Модалка 2: Занятия выбранного преподавателя -->
|
||||
<div class="cs-modal cs-modal-table card" id="sch-modal-lessons" style="display:none;">
|
||||
<div class="cs-modal-header">
|
||||
<h2 id="sch-teacher-name">Занятия преподавателя</h2>
|
||||
</div>
|
||||
<div class="lessons-container" id="sch-lessons-container">
|
||||
<div class="no-lessons">Выберите преподавателя для просмотра занятий</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-department">ID кафедры</label>
|
||||
<label for="new-department">ID Кафедры</label>
|
||||
<input type="number" id="new-department" placeholder="ID" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
@@ -47,7 +47,7 @@
|
||||
<th>Имя пользователя</th>
|
||||
<th>ФИО</th>
|
||||
<th>Должность</th>
|
||||
<th>ID кафедры</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Роль</th>
|
||||
<th colspan="2">Действия</th>
|
||||
</tr>
|
||||
@@ -61,11 +61,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Lesson Modal -->
|
||||
<div class="modal-overlay" id="modal-add-lesson">
|
||||
<div class="modal-content card">
|
||||
<!-- ===== Оверлей для модалок добавления/просмотра занятий ===== -->
|
||||
<div class="cs-overlay" id="users-overlay">
|
||||
<div class="cs-overlay-scroll">
|
||||
|
||||
<!-- Модалка 1: Форма добавления -->
|
||||
<div class="cs-modal cs-modal-form card" id="modal-add-lesson">
|
||||
<div class="cs-modal-header">
|
||||
<h2>Добавить занятие</h2>
|
||||
<button class="modal-close" id="modal-add-lesson-close">×</button>
|
||||
<button class="btn-close-panel" id="modal-add-lesson-close">×</button>
|
||||
</div>
|
||||
<form id="add-lesson-form">
|
||||
<input type="hidden" id="lesson-user-id">
|
||||
|
||||
@@ -110,7 +115,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Тип недели (ВЕРТИКАЛЬНО) -->
|
||||
<!-- Тип недели -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 192px">
|
||||
<label>Неделя</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
@@ -136,7 +141,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Формат занятия (ВЕРТИКАЛЬНО) -->
|
||||
<!-- Формат занятия -->
|
||||
<div class="form-group" style="flex: 0 1 auto; max-width: 170px">
|
||||
<label>Формат занятия</label>
|
||||
<div style="display: flex; gap: 0.2rem;">
|
||||
@@ -159,29 +164,26 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка Сохранить (в том же ряду) -->
|
||||
<!-- Кнопка Сохранить -->
|
||||
<div class="form-group" style="flex: 0 0 auto;">
|
||||
<button type="submit" class="btn-primary" style="white-space: nowrap;">Сохранить</button>
|
||||
</div>
|
||||
|
||||
</div> <!-- Закрытие form-row -->
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="add-lesson-alert" role="alert" style="margin-top: 1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- View Teacher Lessons Modal -->
|
||||
<div class="modal-overlay" id="modal-view-lessons">
|
||||
<div class="modal-content view-lessons-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-teacher-name">Занятия преподавателя</h2>
|
||||
<button class="modal-close" id="modal-view-lessons-close">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Модалка 2: Просмотр занятий преподавателя -->
|
||||
<div class="cs-modal cs-modal-table card" id="modal-view-lessons" style="display:none;">
|
||||
<div class="cs-modal-header">
|
||||
<h2 id="modal-teacher-name">Занятия преподавателя</h2>
|
||||
</div>
|
||||
<div class="lessons-container" id="lessons-container">
|
||||
<!-- Фильтры по дням (добавим позже) -->
|
||||
<div class="loading-lessons">Загрузка занятий...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-backdrop"></div>
|
||||
@@ -2,7 +2,8 @@
|
||||
'use strict';
|
||||
|
||||
// --- OpenTelemetry Frontend Instrumentation ---
|
||||
// Загружаем OTel Web SDK динамически через esm.sh, чтобы не ломать старый Vanilla JS (без type="module")
|
||||
// Загружаем OTel только на продакшене (не на localhost)
|
||||
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
||||
import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => {
|
||||
const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http');
|
||||
const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web');
|
||||
@@ -10,7 +11,7 @@
|
||||
const { Resource } = await import('https://esm.sh/@opentelemetry/resources');
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: window.location.origin + '/otel/v1/traces' // Трафик пойдет через ваш Caddy Proxy
|
||||
url: window.location.origin + '/otel/v1/traces'
|
||||
});
|
||||
|
||||
const provider = new WebTracerProvider({
|
||||
@@ -25,6 +26,7 @@
|
||||
});
|
||||
console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде.");
|
||||
}).catch(e => console.error("Ошибка загрузки OTel:", e));
|
||||
}
|
||||
// ----------------------------------------------
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
@@ -141,6 +143,7 @@
|
||||
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
||||
|
||||
const redirect = data.redirect || '/';
|
||||
setTimeout(() => { window.location.href = redirect; }, 400);
|
||||
|
||||
Reference in New Issue
Block a user