diff --git a/.gitignore b/.gitignore
index 9647f9d..42d19e5 100755
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
db/data/
.env
+backend/tenants.json
# Игнорируем временные файлы сборки (на будущее)
backend/target/
@@ -10,4 +11,4 @@ frontend/dist/
.idea/
.vscode/
*.DS_Store
-skills-lock.json
\ No newline at end of file
+skills-lock.json
diff --git a/analysis_results.md b/analysis_results.md
new file mode 100644
index 0000000..44c82ff
--- /dev/null
+++ b/analysis_results.md
@@ -0,0 +1,288 @@
+# Анализ структуры вкладок и предложения по UX/UI оптимизации системы Magistr
+
+В данном документе представлен детальный анализ текущей структуры интерфейса административной панели (SPA) и настроек системы Magistr. Выявлены пересекающиеся области функционала, сформулированы предложения по их объединению, переносу в настройки или удалению, а также предложены новые функциональные модули и технические решения для оптимизации UX/UI и производительности.
+
+---
+
+## 1. Анализ текущей структуры и доступности вкладок
+
+На данный момент в основном SPA-интерфейсе администратора (`/admin/`) зарегистрировано **13 вкладок**, а в дополнительном SPA настроек (`/admin/settings/`) — **2 вкладки**.
+
+В зависимости от роли пользователя, сайдбар фильтруется следующим образом:
+
+| Вкладка (ID) | Описание | Доступность по ролям |
+| :--- | :--- | :--- |
+| `users` | CRUD пользователей и архивирование | `ADMIN` |
+| `groups` | CRUD групп, подгрупп и назначений календарей | `ADMIN` |
+| `edu-forms` | Справочник форм обучения | `ADMIN` |
+| `profiles` | Управление профилями обучения специальностей | `ADMIN` |
+| `equipments` | Каталог оборудования | `ADMIN`, `EDUCATION_OFFICE` |
+| `classrooms` | Список аудиторий с привязкой оборудования | `ADMIN`, `EDUCATION_OFFICE` |
+| `subjects` | CRUD всех дисциплин и привязка преподавателей | `ADMIN` |
+| `department-workspace` | Рабочая область кафедры (дисциплины, импорт, нагрузка) | `ADMIN`, `DEPARTMENT` |
+| `schedule-view` | Просмотр расписаний (read-only) | `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER` |
+| `schedule` | Конструктор правил динамического расписания | `ADMIN`, `EDUCATION_OFFICE` |
+| `academic-calendar` | Календарные графики и Excel-редактор сетки | `ADMIN`, `EDUCATION_OFFICE` |
+| `auditorium-workload` | Матрицы и таблицы загруженности | `ADMIN`, `EDUCATION_OFFICE` |
+| `database` | Управление тенантами (БД клиентов) | `ADMIN` |
+| **Settings** (SPA) | Общие настройки, Управление временными слотами | `ADMIN`, `EDUCATION_OFFICE` (ссылка в подвале) |
+
+---
+
+## 2. Пересечения функционала и проблемные зоны
+
+### 2.1. Избыточное дробление академической структуры
+Для описания структуры обучения сейчас используются четыре отдельные вкладки:
+1. `departments-data` (Создание кафедры/специальности)
+2. `profiles` (Профили обучения специальностей)
+3. `edu-forms` (Формы обучения)
+4. `groups` (В блоке создания группы выбираются форма, кафедра, специальность и профиль)
+
+**В чём проблема:**
+* **Разрыв контекста:** Чтобы завести новую специальность с профилями, администратору сначала нужно пойти во вкладку `departments-data`, создать специальность, затем перейти во вкладку `profiles`, выбрать в селекте эту специальность и добавить профили.
+* **Перегрузка меню:** 13 вкладок в сайдбаре для роли `ADMIN` — это слишком много. Пользователю трудно ориентироваться визуально.
+
+### 2.2. Дублирование и раздвоение управления дисциплинами
+Управление дисциплинами разнесено между вкладками `subjects` (для администратора) и `department-workspace` (для кафедры).
+
+**В чём проблема:**
+* Администратор во вкладке `subjects` видит плоскую таблицу всех дисциплин всех кафедр, но не имеет удобных инструментов фильтрации по кафедре.
+* Форма создания новой дисциплины во вкладке `subjects` требует ввода **числового ID кафедры вручную** (поле `new-subject-department` типа `number`). Это критическая UX-ошибка: администратор не должен помнить числовые ID БД наизусть.
+* Привязка преподавателей к дисциплинам (`teacher_subjects`) находится на вкладке `subjects`. При этом сама нагрузка преподавателей и список преподавателей кафедры отображаются на вкладке `department-workspace`. Логичнее было бы видеть, какие дисциплины ведёт преподаватель, непосредственно при работе с его нагрузкой или в едином справочнике преподавателей/кафедры.
+
+### 2.3. Низкая частота использования справочника оборудования (`equipments`)
+Оборудование существует только как подчиненная сущность для аудиторий (чтобы указать, что в аудитории 101 есть 20 ПК и 1 проектор).
+
+**В чём проблема:**
+* Выделение целой вкладки под простой список («Проектор», «ПК») не оправдано. Список оборудования меняется раз в несколько лет.
+* При этом привязка оборудования к аудиториям происходит на вкладке `classrooms`.
+
+### 2.4. Перегруженность вкладки групп (`groups`)
+На вкладке групп на одном экране выводятся сразу три тяжелые формы и три таблицы:
+1. Создание/редактирование групп + таблица групп.
+2. Создание подгрупп для лабораторных + таблица подгрупп.
+3. Назначение календарного графика группе + таблица назначений.
+
+**В чём проблема:**
+* Экран перегружен элементами управления.
+* Для работы с подгруппами или назначениями графиков конкретной группы пользователю приходится выбирать эту группу из длинного выпадающего списка в отдельной форме, вместо того чтобы выполнять действия в контексте строки этой группы в таблице.
+
+### 2.5. Нахождение системной вкладки «База данных» в общем меню расписания
+Вкладка `database` отвечает за управление тенантами (добавление новых вузов-клиентов, создание для них БД и запуск Flyway миграций).
+
+**В чём проблема:**
+* Это технический функционал уровня Super-Admin / DevOps. Он не имеет отношения к ежедневному планированию расписания. Нахождение его в одном ряду с расписанием и группами нарушает логику интерфейса.
+
+---
+
+## 3. Рекомендации по оптимизации структуры (Что убрать, объединить и перенести)
+
+```mermaid
+graph TD
+ subgraph Было (13 вкладок в меню ADMIN)
+ direction TB
+ T1[database]
+ T2[edu-forms]
+ T3[departments-data]
+ T4[profiles]
+ T5[equipments]
+ T6[classrooms]
+ T7[groups]
+ T8[users]
+ T9[subjects]
+ T10[department-workspace]
+ T11[schedule]
+ T12[schedule-view]
+ T13[academic-calendar]
+ T14[auditorium-workload]
+ end
+
+ subgraph Стало (Сгруппированное меню + настройки)
+ direction TB
+ subgraph Основное меню SPA (Группы вкладок)
+ direction TB
+ DASH[1. Дашборд / Главная]
+
+ subgraph Модуль: Расписание
+ SCH_ED[2. Конструктор расписания]
+ SCH_VI[3. Просмотр расписаний]
+ CAL[4. Календарный график]
+ WORK[5. Загруженность]
+ end
+
+ subgraph Модуль: Структура
+ UNIV[6. Структура вуза Кафедры, Специальности, Профили]
+ GRP[7. Группы и подгруппы]
+ SUBJ[8. Дисциплины]
+ end
+
+ subgraph Модуль: Ресурсы
+ CLASS[9. Аудиторный фонд Аудитории + Оборудование]
+ end
+
+ USR[10. Пользователи]
+ end
+
+ subgraph SPA Настройки (Настройки системы)
+ direction TB
+ SET_GEN[Общие настройки]
+ SET_TIME[Временные слоты сетка]
+ SET_FORM[Формы обучения из edu-forms]
+ SET_DB[Базы данных / Тенанты из database]
+ end
+ end
+
+ T1 -->|Перенести| SET_DB
+ T2 -->|Перенести| SET_FORM
+ T3 -->|Объединить| UNIV
+ T4 -->|Объединить| UNIV
+ T5 -->|Объединить| CLASS
+ T6 -->|Объединить| CLASS
+ T7 -->|Оптимизировать| GRP
+ T8 -->|Оставить| USR
+ T9 -->|Оптимизировать| SUBJ
+ T10 -->|Сохранить для роли Кафедра| SUBJ
+ T11 -->|Оставить| SCH_ED
+ T12 -->|Оставить| SCH_VI
+ T13 -->|Оставить| CAL
+ T14 -->|Оставить| WORK
+```
+
+### 3.1. Перенос в настройки (`/admin/settings/`)
+1. **База данных (`database`)**: Перенести в SPA настроек в раздел «Управление тенантами». Доступ должен быть только у роли `ADMIN`.
+2. **Формы обучения (`edu-forms`)**: Перенести в настройки. Данный справочник заполняется один раз и не требует оперативного доступа из главного меню.
+
+### 3.2. Объединение вкладок
+1. **Объединить Кафедры, Специальности и Профили** в одну вкладку **«Структура вуза»** (`university-structure`):
+ * Интерфейс делится на две вкладки внутри страницы: **«Кафедры»** и **«Специальности»**.
+ * Управление профилями должно происходить прямо внутри списка специальностей. Например, в строке специальности в таблице добавляется кнопка «Профили», открывающая список профилей этой специальности в раскрывающейся строке (accordion) или модальном окне.
+2. **Объединить Аудитории (`classrooms`) и Оборудование (`equipments`)** в одну вкладку **«Аудиторный фонд»**:
+ * Основной экран показывает список аудиторий.
+ * Рядом располагается кнопка «Управление каталогом оборудования», которая открывает модальное окно с простым списком (добавить/удалить тип оборудования). Это избавит от необходимости держать под это отдельную вкладку в сайдбаре.
+
+### 3.3. Реорганизация вкладки групп (`groups`)
+* **Оставить на вкладке только список групп** с возможностью фильтрации по курсу, кафедре и форме обучения.
+* **Вынести подгруппы и назначения календарей из глобальных форм:**
+ * В строке таблицы для каждой группы добавить действия:
+ * `[Подгруппы]` — открывает компактное модальное окно для быстрого создания/редактирования подгрупп (лабораторных) именно для этой группы.
+ * `[Календарь]` — открывает модальное окно назначения календарного учебного графика на учебные годы для этой группы.
+ * Это уберет лишние тяжелые формы с главной страницы и исключит необходимость выбора группы из селектов вручную.
+
+### 3.4. Оптимизация Дисциплин (`subjects`) и Кабинета кафедры
+* **Исправить форму добавления дисциплины:** Заменить ручной числовой ввод ID кафедры (`new-subject-department`) на стандартный выпадающий список (выбор из загруженных кафедр).
+* **Связать привязку преподавателей с дисциплинами:** Вместо двух разрозненных таблиц на вкладке `subjects` («Все дисциплины» и «Привязки преподавателей»), объединить их:
+ * В строке дисциплины выводить список привязанных преподавателей (в виде чипов/badge).
+ * По клику на кнопку «Преподаватели» в строке дисциплины открывать модальное окно привязки/отвязки преподавателей для этой конкретной дисциплины.
+
+---
+
+## 4. Что необходимо добавить (Новый функционал)
+
+### 4.1. Главная страница / Дашборд (`dashboard`)
+При входе в систему администратор и сотрудники учебного отдела должны видеть не сырые таблицы пользователей или расписания, а высокоуровневую аналитическую панель:
+* **Информационные карточки (Метрики):**
+ * Всего активных групп / студентов.
+ * Количество задействованных преподавателей.
+ * Процент занятости аудиторного фонда на текущий день.
+ * Количество запланированных пар на сегодня.
+* **Мониторинг расписания в реальном времени:**
+ * Список идущих прямо сейчас пар (текущий временной слот).
+ * Ближайшие свободные аудитории.
+* **Быстрый переход:** Кнопки быстрого создания правила расписания, добавления переноса/замены.
+
+### 4.2. Центр предупреждений и конфликтов (Red Zone)
+В соответствии с планируемой бизнес-логикой (раздел 5 `docs/BUSINESS_LOGIC.md`), система должна автоматически валидировать расписание и предупреждать о коллизиях.
+Необходима отдельная вкладка или виджет на дашборде **«Конфликты расписания»**:
+* **Критические ошибки (Блокирующие генерацию):**
+ * *Накладки преподавателей:* Преподаватель назначен в один и тот же временной слот в разные аудитории / к разным группам (кроме потоковых лекций).
+ * *Накладки аудиторий:* Две разные пары стоят в одной аудитории в один слот.
+* **Предупреждения (Мягкие проверки):**
+ * *Превышение вместимости:* Численность группы (или сумма подгрупп при совмещенных занятиях) превышает физическую вместимость аудитории.
+ * *Недостаток оборудования:* Дисциплина (например, лабораторная по химии) требует определенного оборудования, которого нет в назначенной аудитории.
+ * *Превышение нагрузки:* Преподаватель превысил недельный лимит часов.
+
+---
+
+## 5. Концепция новой структуры меню (Сайдбара)
+
+Для уменьшения визуального шума сайдбар предлагается сделать **двухуровневым** (сворачиваемые группы) или четко разделить визуальными разделителями:
+
+```
+[ Логотип Magistr ]
+---------------------------
+👤 Кабинет кафедры <-- (Показывается только для роли DEPARTMENT)
+📈 Дашборд <-- (Новый главный экран для ADMIN / EDU_OFFICE)
+
+📁 РАСПИСАНИЕ
+ 🗓 Конструктор правил
+ 🗓 Точечные изменения <-- (Выделенный функционал оверрайдов/переносов)
+ 📅 Календарный график
+ 📊 Загруженность
+ 🔍 Просмотр расписаний
+
+🗂 СУБЪЕКТЫ И РЕСУРСЫ
+ 🏫 Аудиторный фонд <-- (Объединенные Аудитории + Оборудование)
+ 🎓 Группы обучения <-- (Группы + модалки подгрупп/календарей)
+ 🏛 Структура вуза <-- (Кафедры + Специальности + Профили)
+ 📚 Дисциплины <-- (Дисциплины + модалки преподавателей)
+
+⚙️ СИСТЕМА
+ 👥 Пользователи
+ ⚙️ Настройки <-- (Переход в настройки SPA: слоты, БД, формы обучения)
+---------------------------
+[ Профиль / Выход ]
+```
+
+---
+
+## 6. Идеи по оптимизации фронтенда и взаимодействия с API
+
+### 6.1. Кэширование справочников на клиенте
+На данный момент при переключении почти каждой вкладки заново запрашиваются списки кафедр, специальностей, учебных годов и т.д.
+* **Решение:** Реализовать простой кэш-менеджер во фронтенд-клиенте (`api.js` или отдельный `cache.js`):
+ ```javascript
+ const cache = new Map();
+ export async function getCachedData(key, fetchPromise) {
+ if (!cache.has(key)) {
+ cache.set(key, await fetchPromise);
+ }
+ return cache.get(key);
+ }
+ export function clearCache(key) {
+ if (key) cache.delete(key);
+ else cache.clear();
+ }
+ ```
+ Это позволит избежать десятков дублирующих запросов при переключении между вкладками `groups`, `subjects`, `schedule`.
+
+### 6.2. Ленивая инициализация вкладок (Lazy Loading)
+Сейчас в `main.js` импортируются все JS-модули вкладок одновременно при загрузке страницы:
+```javascript
+import { initUsers } from './views/users.js';
+import { initGroups } from './views/groups.js';
+// ...еще 10 импортов
+```
+* **Решение:** Перейти на динамический импорт модулей инициализации при переходе на конкретную вкладку:
+ ```javascript
+ async function switchTab(tab) {
+ // ...
+ const viewModule = await import(`./views/${tab}.js`);
+ viewModule.init();
+ // ...
+ }
+ ```
+ Это уменьшит размер начального JS-бандла и ускорит первую загрузку страницы.
+
+### 6.3. Единый реестр кастомных выпадающих списков
+В проекте реализованы кастомные селекты (выпадающие списки с поддержкой поиска и чекбоксов). При динамической подгрузке HTML-шаблонов часто требуется их повторная инициализация.
+* **Решение:** Создать глобальный шину событий (Event Bus) или MutationObserver, который автоматически подхватывает элементы с классами `.custom-select` или атрибутами `data-select` и инициализирует их без вызова ручного кода `initAllCustomDropdowns()` в каждом отдельном скрипте представления.
+
+---
+
+## Резюме
+
+Предложенные изменения позволят:
+1. Сократить количество пунктов основного меню с **13** до **8-9** логически сгруппированных разделов.
+2. Исключить ручной ввод ID (заменив на селекты) и разгрузить перегруженные интерфейсы (выносом подгрупп и назначений в контекстные модальные окна групп).
+3. Повысить отзывчивость интерфейса за счет клиентского кэширования справочников и ленивой загрузки JS-модулей.
+4. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
diff --git a/audit-buttons-01-login.png b/audit-buttons-01-login.png
new file mode 100644
index 0000000..0126525
Binary files /dev/null and b/audit-buttons-01-login.png differ
diff --git a/audit-buttons-02-dashboard.png b/audit-buttons-02-dashboard.png
new file mode 100644
index 0000000..37379d0
Binary files /dev/null and b/audit-buttons-02-dashboard.png differ
diff --git a/audit-buttons-03-users-form.png b/audit-buttons-03-users-form.png
new file mode 100644
index 0000000..3908c5b
Binary files /dev/null and b/audit-buttons-03-users-form.png differ
diff --git a/audit-buttons-04-settings-menu.png b/audit-buttons-04-settings-menu.png
new file mode 100644
index 0000000..4be70fa
Binary files /dev/null and b/audit-buttons-04-settings-menu.png differ
diff --git a/audit-buttons-05-settings-general.png b/audit-buttons-05-settings-general.png
new file mode 100644
index 0000000..56270c9
Binary files /dev/null and b/audit-buttons-05-settings-general.png differ
diff --git a/audit-buttons-06-teacher.png b/audit-buttons-06-teacher.png
new file mode 100644
index 0000000..2715515
Binary files /dev/null and b/audit-buttons-06-teacher.png differ
diff --git a/audit-buttons-07-student.png b/audit-buttons-07-student.png
new file mode 100644
index 0000000..a67b4a2
Binary files /dev/null and b/audit-buttons-07-student.png differ
diff --git a/audit-buttons-08-modal.png b/audit-buttons-08-modal.png
new file mode 100644
index 0000000..91c9241
Binary files /dev/null and b/audit-buttons-08-modal.png differ
diff --git a/audit-buttons-calendar.png b/audit-buttons-calendar.png
new file mode 100644
index 0000000..7e7860b
Binary files /dev/null and b/audit-buttons-calendar.png differ
diff --git a/audit-buttons-classrooms.png b/audit-buttons-classrooms.png
new file mode 100644
index 0000000..bdaaa0e
Binary files /dev/null and b/audit-buttons-classrooms.png differ
diff --git a/audit-buttons-groups.png b/audit-buttons-groups.png
new file mode 100644
index 0000000..c77a12f
Binary files /dev/null and b/audit-buttons-groups.png differ
diff --git a/audit-buttons-rules.png b/audit-buttons-rules.png
new file mode 100644
index 0000000..39627fc
Binary files /dev/null and b/audit-buttons-rules.png differ
diff --git a/audit-buttons-schedule-view.png b/audit-buttons-schedule-view.png
new file mode 100644
index 0000000..a76f7e3
Binary files /dev/null and b/audit-buttons-schedule-view.png differ
diff --git a/audit-buttons-settings-database.png b/audit-buttons-settings-database.png
new file mode 100644
index 0000000..b82fcd4
Binary files /dev/null and b/audit-buttons-settings-database.png differ
diff --git a/audit-buttons-settings-study-forms.png b/audit-buttons-settings-study-forms.png
new file mode 100644
index 0000000..b5af080
Binary files /dev/null and b/audit-buttons-settings-study-forms.png differ
diff --git a/audit-buttons-settings-time-slots.png b/audit-buttons-settings-time-slots.png
new file mode 100644
index 0000000..79c6eff
Binary files /dev/null and b/audit-buttons-settings-time-slots.png differ
diff --git a/audit-buttons-structure.png b/audit-buttons-structure.png
new file mode 100644
index 0000000..956c41d
Binary files /dev/null and b/audit-buttons-structure.png differ
diff --git a/audit-buttons-subjects.png b/audit-buttons-subjects.png
new file mode 100644
index 0000000..a7b4e42
Binary files /dev/null and b/audit-buttons-subjects.png differ
diff --git a/audit-buttons-users.png b/audit-buttons-users.png
new file mode 100644
index 0000000..b180fc4
Binary files /dev/null and b/audit-buttons-users.png differ
diff --git a/audit-buttons-workload.png b/audit-buttons-workload.png
new file mode 100644
index 0000000..658b076
Binary files /dev/null and b/audit-buttons-workload.png differ
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
index 023c734..a69042e 100755
--- a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java
@@ -1,12 +1,10 @@
package com.magistr.app.config.tenant;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
-import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -43,7 +41,7 @@ public class ConfigMapUpdater {
public ConfigMapUpdater() {
this.runningInK8s = Files.exists(Path.of(TOKEN_PATH));
if (!runningInK8s) {
- log.info("Not running in K8s — ConfigMap updates will be skipped");
+ log.info("Приложение запущено вне K8s — обновление ConfigMap будет пропущено");
}
}
@@ -53,7 +51,7 @@ public class ConfigMapUpdater {
*/
public boolean updateTenantsConfig(List tenants) {
if (!runningInK8s) {
- log.warn("Not in K8s, skipping ConfigMap update");
+ log.warn("Приложение запущено вне K8s, пропускаем обновление ConfigMap");
return true;
}
@@ -87,15 +85,15 @@ public class ConfigMapUpdater {
HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
- log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size());
+ log.info("ConfigMap '{}' успешно обновлён, тенантов: {}", CONFIGMAP_NAME, tenants.size());
return true;
} else {
- log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
+ log.error("Не удалось обновить ConfigMap: HTTP {} — {}", response.statusCode(), response.body());
return false;
}
} catch (Exception e) {
- log.error("Error updating ConfigMap: {}", e.getMessage());
+ log.error("Ошибка при обновлении ConfigMap: {}", e.getMessage());
return false;
}
}
@@ -120,7 +118,7 @@ public class ConfigMapUpdater {
.sslContext(sslContext)
.build();
} catch (Exception e) {
- log.warn("Failed to create insecure client, using default: {}", e.getMessage());
+ log.warn("Не удалось создать клиент без проверки сертификата, используем стандартный: {}", e.getMessage());
return HttpClient.newHttpClient();
}
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
index 6cff252..e27fb62 100755
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
@@ -10,14 +10,11 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
-import java.io.BufferedReader;
import java.io.File;
-import java.io.InputStream;
-import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.Statement;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
import java.util.*;
import java.util.stream.Collectors;
@@ -58,20 +55,20 @@ public class TenantConfigWatcher {
if (!file.exists()) return;
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
- String hash = Integer.toHexString(content.hashCode());
+ String hash = configHash(content);
if (hash.equals(lastConfigHash)) {
return; // Ничего не изменилось
}
- log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash);
+ log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
lastConfigHash = hash;
List newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
} catch (Exception e) {
- log.error("Error watching tenants config: {}", e.getMessage());
+ log.error("Ошибка при проверке конфига тенантов: {}", e.getMessage());
}
}
@@ -83,10 +80,19 @@ public class TenantConfigWatcher {
File file = new File(tenantsConfigPath);
if (file.exists()) {
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
- lastConfigHash = Integer.toHexString(content.hashCode());
+ lastConfigHash = configHash(content);
}
} catch (Exception e) {
- log.warn("Failed to refresh config hash: {}", e.getMessage());
+ log.warn("Не удалось обновить хеш конфига тенантов: {}", e.getMessage());
+ }
+ }
+
+ static String configHash(String content) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 недоступен в текущем окружении", e);
}
}
@@ -103,7 +109,7 @@ public class TenantConfigWatcher {
for (TenantConfig tenant : newTenants) {
String domain = tenant.getDomain().toLowerCase();
if (!current.containsKey(domain)) {
- log.info("Adding new tenant '{}' from ConfigMap update", domain);
+ log.info("Добавляем нового тенанта '{}' из обновлённого ConfigMap", domain);
routingDataSource.addTenant(tenant);
// Инициализируем БД для нового тенанта
initDatabaseForTenant(tenant);
@@ -113,7 +119,7 @@ public class TenantConfigWatcher {
// Удалить тенанты, которых больше нет в конфиге
for (String existingDomain : new ArrayList<>(current.keySet())) {
if (!newDomains.contains(existingDomain)) {
- log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain);
+ log.info("Удаляем тенанта '{}' — его больше нет в ConfigMap", existingDomain);
routingDataSource.removeTenant(existingDomain);
}
}
@@ -129,7 +135,7 @@ public class TenantConfigWatcher {
try {
TenantContext.setCurrentTenant(domain);
- log.info("[{}] Starting Flyway migrations...", domain);
+ log.info("[{}] Запускаем миграции Flyway", domain);
// Получаем DataSource конкретно для этого тенанта
javax.sql.DataSource tenantDs = routingDataSource.getResolvedDataSources().get(domain);
@@ -145,10 +151,10 @@ public class TenantConfigWatcher {
.load();
flyway.migrate();
- log.info("[{}] Flyway migrations completed successfully", domain);
+ log.info("[{}] Миграции Flyway успешно выполнены", domain);
} catch (Exception e) {
- log.error("[{}] Flyway migration failed: {}", domain, e.getMessage());
+ log.error("[{}] Ошибка миграции Flyway: {}", domain, e.getMessage());
} finally {
TenantContext.clear();
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
index 767f897..16d9ff6 100755
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java
@@ -2,7 +2,6 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.magistr.app.config.auth.AuthorizationInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -13,8 +12,6 @@ import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
-import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
@@ -30,7 +27,7 @@ import java.util.*;
* как заглушку, чтобы Spring JPA мог инициализироваться.
*/
@Configuration
-public class TenantDataSourceConfig implements WebMvcConfigurer {
+public class TenantDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(TenantDataSourceConfig.class);
@@ -68,7 +65,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
try {
routingDataSource.addTenant(tenant);
} catch (Exception e) {
- log.error("Failed to add tenant '{}': {}", tenant.getDomain(), e.getMessage());
+ log.error("Не удалось добавить тенанта '{}': {}", tenant.getDomain(), e.getMessage());
}
}
@@ -120,27 +117,6 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
return new JpaTransactionManager(emf);
}
- @org.springframework.context.annotation.Lazy
- @org.springframework.beans.factory.annotation.Autowired
- private TenantRoutingDataSource tenantRoutingDataSource;
-
- @org.springframework.beans.factory.annotation.Autowired
- private AuthorizationInterceptor authorizationInterceptor;
-
- @Bean
- public TenantInterceptor tenantInterceptor(TenantRoutingDataSource routingDataSource) {
- TenantInterceptor interceptor = new TenantInterceptor();
- interceptor.setRoutingDataSource(routingDataSource);
- return interceptor;
- }
-
- @Override
- public void addInterceptors(InterceptorRegistry registry) {
- // Вызываем метод-бин с переданным параметром (будет перехвачен CGLIB)
- registry.addInterceptor(tenantInterceptor(tenantRoutingDataSource)).addPathPatterns("/**");
- registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
- }
-
private List loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
@@ -154,7 +130,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
log.info("Loaded {} tenant(s) from {}", list.size(), tenantsConfigPath);
return list;
} catch (IOException e) {
- log.error("Failed to read tenants config: {}", e.getMessage());
+ log.error("Не удалось прочитать конфиг тенантов: {}", e.getMessage());
return new ArrayList<>();
}
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java
new file mode 100644
index 0000000..2473306
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java
@@ -0,0 +1,33 @@
+package com.magistr.app.config.tenant;
+
+import com.magistr.app.config.auth.AuthorizationInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class TenantWebMvcConfig implements WebMvcConfigurer {
+
+ private final TenantRoutingDataSource tenantRoutingDataSource;
+ private final AuthorizationInterceptor authorizationInterceptor;
+
+ public TenantWebMvcConfig(TenantRoutingDataSource tenantRoutingDataSource,
+ AuthorizationInterceptor authorizationInterceptor) {
+ this.tenantRoutingDataSource = tenantRoutingDataSource;
+ this.authorizationInterceptor = authorizationInterceptor;
+ }
+
+ @Bean
+ public TenantInterceptor tenantInterceptor() {
+ TenantInterceptor interceptor = new TenantInterceptor();
+ interceptor.setRoutingDataSource(tenantRoutingDataSource);
+ return interceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
+ registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java
index 0b5096d..1f29f48 100755
--- a/backend/src/main/java/com/magistr/app/controller/AuthController.java
+++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java
@@ -19,6 +19,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@@ -124,12 +125,12 @@ public class AuthController {
if (user == null) {
return ResponseEntity.status(401).body(Map.of("message", "Требуется вход в систему"));
}
- return ResponseEntity.ok(Map.of(
- "userId", user.id(),
- "username", user.username(),
- "role", user.role().name(),
- "departmentId", user.departmentId()
- ));
+ Map response = new LinkedHashMap<>();
+ response.put("userId", user.id());
+ response.put("username", user.username());
+ response.put("role", user.role().name());
+ response.put("departmentId", user.departmentId());
+ return ResponseEntity.ok(response);
}
private LoginResponse loginResponse(User user, String token) {
diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java
index a59fb43..d8ac277 100644
--- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java
+++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java
@@ -4,9 +4,11 @@ import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto;
+import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository;
+import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat;
@@ -14,8 +16,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
@RestController
@RequestMapping("/api/department")
@@ -25,15 +29,18 @@ public class DepartmentWorkspaceController {
private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository;
+ private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository,
+ TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository;
+ this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.scheduleQueryService = scheduleQueryService;
}
@@ -108,9 +115,25 @@ public class DepartmentWorkspaceController {
}
@GetMapping("/teachers")
- public List> getTeachers(@RequestParam(required = false) Long departmentId) {
+ public List getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
- return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
+ if (effectiveDepartmentId == null) {
+ return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
+ .map(this::toUserResponse)
+ .toList();
+ }
+
+ Map teachersById = new LinkedHashMap<>();
+ teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
+ .stream()
+ .map(TeacherDepartmentAssignment::getTeacher)
+ .filter(User::isActiveRecord)
+ .forEach(teacher -> teachersById.put(teacher.getId(), teacher));
+ userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
+ .forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
+ return teachersById.values().stream()
+ .map(this::toUserResponse)
+ .toList();
}
@GetMapping("/schedule")
@@ -136,6 +159,19 @@ public class DepartmentWorkspaceController {
);
}
+ private UserResponse toUserResponse(User user) {
+ UserResponse response = new UserResponse(
+ user.getId(),
+ user.getUsername(),
+ user.getRole().name(),
+ user.getFullName(),
+ user.getJobTitle(),
+ user.getDepartmentId()
+ );
+ response.setStatus(user.getStatus());
+ return response;
+ }
+
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) {
@@ -148,6 +184,6 @@ public class DepartmentWorkspaceController {
var user = AuthContext.getCurrentUser();
return user == null
|| user.role() == Role.ADMIN
- || (user.role() == Role.DEPARTMENT && departmentId.equals(user.departmentId()));
+ || (user.role() == Role.DEPARTMENT && Objects.equals(departmentId, user.departmentId()));
}
}
diff --git a/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java
new file mode 100644
index 0000000..5b459d5
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java
@@ -0,0 +1,76 @@
+package com.magistr.app.controller;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity> handleIllegalArgument(IllegalArgumentException exception,
+ HttpServletRequest request) {
+ String message = exception.getMessage() == null || exception.getMessage().isBlank()
+ ? "Некорректный запрос"
+ : exception.getMessage();
+ return error(HttpStatus.BAD_REQUEST, message, request);
+ }
+
+ @ExceptionHandler(NoSuchElementException.class)
+ public ResponseEntity> handleNotFound(NoSuchElementException exception,
+ HttpServletRequest request) {
+ String message = exception.getMessage() == null || exception.getMessage().isBlank()
+ ? "Запрошенная запись не найдена"
+ : exception.getMessage();
+ return error(HttpStatus.NOT_FOUND, message, request);
+ }
+
+ @ExceptionHandler({
+ HttpMessageNotReadableException.class,
+ MethodArgumentTypeMismatchException.class
+ })
+ public ResponseEntity> handleBadRequest(Exception exception,
+ HttpServletRequest request) {
+ log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
+ return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleUnexpected(Exception exception,
+ HttpServletRequest request) {
+ log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
+ return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
+ }
+
+ private ResponseEntity> error(HttpStatus status, String message, HttpServletRequest request) {
+ Map body = new LinkedHashMap<>();
+ body.put("timestamp", LocalDateTime.now().toString());
+ body.put("status", status.value());
+ body.put("error", reason(status));
+ body.put("message", message);
+ body.put("path", request.getRequestURI());
+ return ResponseEntity.status(status).body(body);
+ }
+
+ private String reason(HttpStatus status) {
+ return switch (status) {
+ case BAD_REQUEST -> "Некорректный запрос";
+ case NOT_FOUND -> "Не найдено";
+ case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
+ default -> status.name();
+ };
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java
index 1d31f25..25c4f4f 100755
--- a/backend/src/main/java/com/magistr/app/controller/GroupController.java
+++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java
@@ -7,14 +7,15 @@ import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
-import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
+import com.magistr.app.model.StudentGroupStudyState;
import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService;
+import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,6 +23,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -41,6 +43,7 @@ public class GroupController {
private final AcademicCalendarRepository academicCalendarRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
+ private final StudentGroupLifecycleService groupLifecycleService;
public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository,
@@ -49,7 +52,8 @@ public class GroupController {
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
- ScheduleGeneratorService scheduleGeneratorService) {
+ ScheduleGeneratorService scheduleGeneratorService,
+ StudentGroupLifecycleService groupLifecycleService) {
this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository;
@@ -58,6 +62,7 @@ public class GroupController {
this.academicCalendarRepository = academicCalendarRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
+ this.groupLifecycleService = groupLifecycleService;
}
@GetMapping
@@ -67,7 +72,9 @@ public class GroupController {
try {
List groups = includeArchived
? groupRepository.findAll()
- : groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
+ : groupRepository.findAll().stream()
+ .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
+ .toList();
List response = groups.stream()
.map(this::mapToResponse)
@@ -84,7 +91,9 @@ public class GroupController {
public ResponseEntity> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try {
- List groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
+ List groups = groupRepository.findByDepartmentId(departmentId).stream()
+ .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
+ .toList();
if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -264,6 +273,8 @@ public class GroupController {
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile();
+ LocalDate today = LocalDate.now();
+ StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse(
group.getId(),
group.getName(),
@@ -278,7 +289,11 @@ public class GroupController {
speciality.getSpecialityCode(),
speciality.getSpecialityName(),
profile.getId(),
- profile.getName()
+ profile.getName(),
+ group.getStatus(),
+ groupLifecycleService.isAvailableForSelection(group, today),
+ studyState.name(),
+ studyState.getLabel()
);
}
diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java
index defcf6b..eb326af 100644
--- a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java
+++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java
@@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
-import java.util.List;
import java.util.Map;
@RestController
@@ -42,27 +41,17 @@ public class ScheduleController {
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
}
- try {
- List lessons = scheduleQueryService.search(
- groupId,
- teacherId,
- null,
- null,
- null,
- null,
- null,
- null,
- startDate,
- endDate
- );
- return ResponseEntity.ok(lessons);
- } catch (IllegalArgumentException e) {
- logger.info("Ошибка запроса динамического расписания: {}", e.getMessage());
- return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
- } catch (Exception e) {
- logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e);
- return ResponseEntity.internalServerError()
- .body(Map.of("message", "Произошла ошибка при генерации расписания"));
- }
+ return ResponseEntity.ok(scheduleQueryService.search(
+ groupId,
+ teacherId,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ startDate,
+ endDate
+ ));
}
}
diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java
index 5d5c820..b83ebdd 100644
--- a/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java
+++ b/backend/src/main/java/com/magistr/app/controller/ScheduleSearchController.java
@@ -10,7 +10,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
-import java.util.Map;
@RestController
@RequestMapping("/api/schedule/search")
@@ -40,24 +39,17 @@ public class ScheduleSearchController {
) {
logger.info("Расширенный поиск расписания: groupId={}, teacherId={}, classroomId={}, departmentId={}, startDate={}, endDate={}",
groupId, teacherId, classroomId, departmentId, startDate, endDate);
- try {
- return ResponseEntity.ok(scheduleQueryService.search(
- groupId,
- teacherId,
- classroomId,
- departmentId,
- subjectId,
- lessonTypeId,
- timeSlotId,
- parity,
- startDate,
- endDate
- ));
- } catch (IllegalArgumentException e) {
- return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
- } catch (Exception e) {
- logger.error("Ошибка расширенного поиска расписания: {}", e.getMessage(), e);
- return ResponseEntity.internalServerError().body(Map.of("message", "Произошла ошибка при поиске расписания"));
- }
+ return ResponseEntity.ok(scheduleQueryService.search(
+ groupId,
+ teacherId,
+ classroomId,
+ departmentId,
+ subjectId,
+ lessonTypeId,
+ timeSlotId,
+ parity,
+ startDate,
+ endDate
+ ));
}
}
diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java
index ed2d9d4..d683998 100644
--- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java
+++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java
@@ -193,6 +193,16 @@ public class SpecialityController {
));
}
+ @GetMapping("/profiles")
+ public List getAllProfiles() {
+ logger.info("Получен запрос на получение всех профилей обучения");
+ return specialtyProfileRepository.findAll().stream()
+ .filter(profile -> profile.getSpeciality().isActiveRecord())
+ .sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
+ .map(this::toProfileDto)
+ .toList();
+ }
+
@GetMapping("/{specialtyId}/profiles")
public ResponseEntity> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) {
diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java
index e034fd2..dc088d7 100755
--- a/backend/src/main/java/com/magistr/app/controller/UserController.java
+++ b/backend/src/main/java/com/magistr/app/controller/UserController.java
@@ -18,7 +18,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
-import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
@@ -52,182 +51,115 @@ public class UserController {
@GetMapping
public List getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
logger.info("Получен запрос на получение всех пользователей");
- try {
- List users = includeArchived
- ? userRepository.findAll()
- : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
-
- List response = users.stream()
- .map(u -> {
- String departmentName = departmentRepository.findById(u.getDepartmentId())
- .map(Department::getDepartmentName)
- .orElse("Неизвестно");
-
- UserResponse userResponse = new UserResponse(
- u.getId(),
- u.getUsername(),
- u.getRole().name(),
- u.getFullName(),
- u.getJobTitle(),
- departmentName);
- userResponse.setStatus(u.getStatus());
- return userResponse;
- })
- .toList();
- logger.info("Получено {} пользователей", response.size());
- return response;
- } catch (Exception e) {
- logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
- throw e;
- }
-
+ List users = includeArchived
+ ? userRepository.findAll()
+ : userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
+ List response = users.stream()
+ .map(this::toUserResponse)
+ .toList();
+ logger.info("Получено {} пользователей", response.size());
+ return response;
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List getTeachers() {
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
-
- try {
- List users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
-
- List response = users.stream()
- .map(u -> {
- String departmentName = departmentRepository.findById(u.getDepartmentId())
- .map(Department::getDepartmentName)
- .orElse("Неизвестно");
-
- UserResponse userResponse = new UserResponse(
- u.getId(),
- u.getUsername(),
- u.getRole().name(),
- u.getFullName(),
- u.getJobTitle(),
- departmentName);
- userResponse.setStatus(u.getStatus());
- return userResponse;
- })
- .toList();
- logger.info("Получено {} преподавателей", response.size());
- return response;
- } catch (Exception e) {
- logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
- throw e;
- }
+ List response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
+ .map(this::toUserResponse)
+ .toList();
+ logger.info("Получено {} преподавателей", response.size());
+ return response;
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity> getTeachersByDepartmentId(@PathVariable Long departmentId){
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
- try {
- List users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
- Role.TEACHER,
- departmentId,
- LifecycleEntity.STATUS_ARCHIVED
- );
+ List users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
+ Role.TEACHER,
+ departmentId,
+ LifecycleEntity.STATUS_ARCHIVED
+ );
- if (users.isEmpty()) {
- logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
- return ResponseEntity.status(HttpStatus.NOT_FOUND)
- .body("Преподаватели для указанной кафедры не найдены");
- }
-
- logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
-
- List userResponses = users.stream()
- .map( user -> {
-
- return new UserResponse(
- user.getId(),
- user.getRole().name(),
- user.getFullName(),
- user.getJobTitle(),
- user.getDepartmentId()
- );
- }).toList();
-
- return ResponseEntity.ok(userResponses);
- } catch (Exception e) {
- logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
- .body("Произошла ошибка при получении списка преподавателей");
+ if (users.isEmpty()) {
+ logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body("Преподаватели для указанной кафедры не найдены");
}
+
+ logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
+ return ResponseEntity.ok(users.stream()
+ .map(this::toUserResponse)
+ .toList());
}
@PostMapping
public ResponseEntity> createUser(@RequestBody CreateUserRequest request) {
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
- try {
- if (request.getUsername() == null || request.getUsername().isBlank()) {
- String errorMessage = "Имя пользователя обязательно";
- logger.error("Ошибка валидации: {}", errorMessage);
- return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
- }
- if (request.getPassword() == null || request.getPassword().length() < 4) {
- String errorMessage = "Пароль минимум 4 символа";
- logger.error("Ошибка валидации: {}", errorMessage);
- return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
- }
- if (userRepository.findByUsername(request.getUsername()).isPresent()) {
- String errorMessage = "Пользователь уже существует";
- logger.error("Ошибка валидации: {}", errorMessage);
- return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
- }
- if (request.getFullName() == null || request.getFullName().isBlank()) {
- String errorMessage = "Имя пользователя обязательно";
- logger.error("Ошибка валидации: {}", errorMessage);
- return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
- }
- if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
- logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
- request.setJobTitle("Не указано");
- }
- if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
- String errorMessage = "ID кафедры не может быть равен 0 или пустым";
- logger.error("Ошибка валидации: {}", errorMessage);
- return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
- }
-
- Role role;
- try {
- role = Role.valueOf(request.getRole());
- } catch (Exception e) {
- logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
- return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
- }
-
- User user = new User();
- user.setUsername(request.getUsername());
- user.setPassword(passwordEncoder.encode(request.getPassword()));
- user.setRole(role);
- user.setFullName(request.getFullName());
- user.setJobTitle(request.getJobTitle());
- user.setDepartmentId(request.getDepartmentId());
- userRepository.save(user);
- if (role == Role.TEACHER) {
- Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
- if (department != null) {
- TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
- assignment.setTeacher(user);
- assignment.setDepartment(department);
- assignment.setValidFrom(LocalDate.now());
- assignment.setPrimaryAssignment(true);
- assignment.setComment("Начальная кафедра при создании пользователя");
- assignment.setCreatedBy(AuthContext.currentUserId());
- teacherDepartmentAssignmentRepository.save(assignment);
- }
- }
-
- logger.info("Пользователь успешно создан с ID: {}", user.getId());
-
- return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
- } catch (Exception e ) {
- logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
- .body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
+ if (request.getUsername() == null || request.getUsername().isBlank()) {
+ String errorMessage = "Имя пользователя обязательно";
+ logger.error("Ошибка валидации: {}", errorMessage);
+ return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
+ if (request.getPassword() == null || request.getPassword().length() < 4) {
+ String errorMessage = "Пароль минимум 4 символа";
+ logger.error("Ошибка валидации: {}", errorMessage);
+ return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
+ }
+ if (userRepository.findByUsername(request.getUsername()).isPresent()) {
+ String errorMessage = "Пользователь уже существует";
+ logger.error("Ошибка валидации: {}", errorMessage);
+ return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
+ }
+ if (request.getFullName() == null || request.getFullName().isBlank()) {
+ String errorMessage = "Имя пользователя обязательно";
+ logger.error("Ошибка валидации: {}", errorMessage);
+ return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
+ }
+ if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
+ logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
+ request.setJobTitle("Не указано");
+ }
+ if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
+ String errorMessage = "ID кафедры не может быть равен 0 или пустым";
+ logger.error("Ошибка валидации: {}", errorMessage);
+ return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
+ }
+
+ Role role;
+ try {
+ role = Role.valueOf(request.getRole());
+ } catch (IllegalArgumentException | NullPointerException e) {
+ logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
+ return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
+ }
+
+ User user = new User();
+ user.setUsername(request.getUsername());
+ user.setPassword(passwordEncoder.encode(request.getPassword()));
+ user.setRole(role);
+ user.setFullName(request.getFullName());
+ user.setJobTitle(request.getJobTitle());
+ user.setDepartmentId(request.getDepartmentId());
+ userRepository.save(user);
+ if (role == Role.TEACHER) {
+ Department department = departmentRepository.findById(user.getDepartmentId()).orElse(null);
+ if (department != null) {
+ TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
+ assignment.setTeacher(user);
+ assignment.setDepartment(department);
+ assignment.setValidFrom(LocalDate.now());
+ assignment.setPrimaryAssignment(true);
+ assignment.setComment("Начальная кафедра при создании пользователя");
+ assignment.setCreatedBy(AuthContext.currentUserId());
+ teacherDepartmentAssignmentRepository.save(assignment);
+ }
+ }
+
+ logger.info("Пользователь успешно создан с ID: {}", user.getId());
+ return ResponseEntity.ok(toUserResponse(user));
}
@DeleteMapping("/{id}")
@@ -252,8 +184,7 @@ public class UserController {
}
user.restore();
userRepository.save(user);
- return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
- user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
+ return ResponseEntity.ok(toUserResponse(user));
}
@GetMapping("/{id}/department-history")
@@ -322,11 +253,29 @@ public class UserController {
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
- .map(user -> new UserResponse(user.getId(), user.getUsername(), user.getRole().name(),
- user.getFullName(), user.getJobTitle(), user.getDepartmentId()))
+ .map(this::toUserResponse)
.toList());
}
+ private UserResponse toUserResponse(User user) {
+ String departmentName = user.getDepartmentId() == null
+ ? null
+ : departmentRepository.findById(user.getDepartmentId())
+ .map(Department::getDepartmentName)
+ .orElse("Неизвестно");
+ UserResponse response = new UserResponse(
+ user.getId(),
+ user.getUsername(),
+ user.getRole().name(),
+ user.getFullName(),
+ user.getJobTitle(),
+ departmentName
+ );
+ response.setDepartmentId(user.getDepartmentId());
+ response.setStatus(user.getStatus());
+ return response;
+ }
+
private TeacherDepartmentAssignmentDto toAssignmentDto(TeacherDepartmentAssignment assignment) {
return new TeacherDepartmentAssignmentDto(
assignment.getId(),
diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java
index 7d27645..72c83fe 100644
--- a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java
+++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java
@@ -128,7 +128,7 @@ public class WorkloadController {
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
- .filter(Classroom::getIsAvailable)
+ .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.filter(classroom -> !busyClassroomIds.contains(classroom.getId()))
.map(this::toClassroomResponse)
.toList();
diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java
index 4807f35..6dc642e 100755
--- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java
+++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java
@@ -19,6 +19,10 @@ public class GroupResponse {
private String specialtyName;
private Long specialtyProfileId;
private String specialtyProfileName;
+ private String status;
+ private Boolean active;
+ private String studyState;
+ private String studyStateName;
public GroupResponse(Long id,
String name,
@@ -33,7 +37,11 @@ public class GroupResponse {
String specialtyCode,
String specialtyName,
Long specialtyProfileId,
- String specialtyProfileName) {
+ String specialtyProfileName,
+ String status,
+ Boolean active,
+ String studyState,
+ String studyStateName) {
this.id = id;
this.name = name;
this.groupSize = groupSize;
@@ -48,6 +56,10 @@ public class GroupResponse {
this.specialtyName = specialtyName;
this.specialtyProfileId = specialtyProfileId;
this.specialtyProfileName = specialtyProfileName;
+ this.status = status;
+ this.active = active;
+ this.studyState = studyState;
+ this.studyStateName = studyStateName;
}
public Long getId() {
@@ -109,4 +121,20 @@ public class GroupResponse {
public String getSpecialtyProfileName() {
return specialtyProfileName;
}
+
+ public String getStatus() {
+ return status;
+ }
+
+ public Boolean getActive() {
+ return active;
+ }
+
+ public String getStudyState() {
+ return studyState;
+ }
+
+ public String getStudyStateName() {
+ return studyStateName;
+ }
}
diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java
index b449fae..3fee5f1 100644
--- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java
+++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java
@@ -36,6 +36,7 @@ public record RenderedLessonDto(
String activityType,
Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson,
- Integer remainingLessonTypeAcademicHoursAfterLesson
+ Integer remainingLessonTypeAcademicHoursAfterLesson,
+ com.magistr.app.model.ScheduleParity ruleParity
) {
}
diff --git a/backend/src/main/java/com/magistr/app/dto/UserResponse.java b/backend/src/main/java/com/magistr/app/dto/UserResponse.java
index fbcd2f3..76eca04 100755
--- a/backend/src/main/java/com/magistr/app/dto/UserResponse.java
+++ b/backend/src/main/java/com/magistr/app/dto/UserResponse.java
@@ -71,6 +71,10 @@ public class UserResponse {
return departmentId;
}
+ public void setDepartmentId(Long departmentId) {
+ this.departmentId = departmentId;
+ }
+
public String getStatus() {
return status;
}
diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
index 4ae5483..8de4e61 100644
--- a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
+++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java
@@ -83,6 +83,9 @@ public abstract class LifecycleEntity {
if (date == null) {
return isActiveRecord();
}
+ if (isArchivedRecord() && activeTo == null) {
+ return false;
+ }
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
boolean beforeEnd = activeTo == null || !date.isAfter(activeTo);
return afterStart && beforeEnd;
diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java
index 65a9c12..4d5e4fe 100644
--- a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java
+++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java
@@ -21,16 +21,35 @@ public enum ScheduleLessonCategory {
if (lessonType == null || lessonType.getLessonType() == null) {
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
}
- String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU"));
- if (name.contains("лекц")) {
+ String name = lessonType.getLessonType()
+ .trim()
+ .toLowerCase(Locale.forLanguageTag("ru-RU"))
+ .replace('ё', 'е');
+ boolean lecture = name.equals("лекция") || name.equals("лекции") || name.startsWith("лекц");
+ boolean laboratory = name.equals("лабораторная работа")
+ || name.equals("лабораторные работы")
+ || name.startsWith("лаб")
+ || name.contains("лаборатор");
+ boolean practice = name.equals("практика")
+ || name.equals("практики")
+ || name.startsWith("практ")
+ || name.contains("практичес")
+ || name.contains("семинар");
+ int matches = (lecture ? 1 : 0) + (laboratory ? 1 : 0) + (practice ? 1 : 0);
+ if (matches > 1) {
+ throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
+ + "\" неоднозначен. Используйте отдельный тип: лекция, практика или лабораторная работа");
+ }
+ if (lecture) {
return LECTURE;
}
- if (name.contains("лаб")) {
+ if (laboratory) {
return LABORATORY;
}
- if (name.contains("практ")) {
+ if (practice) {
return PRACTICE;
}
- throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
+ throw new IllegalArgumentException("Тип занятия \"" + lessonType.getLessonType()
+ + "\" должен быть лекцией, практикой или лабораторной работой");
}
}
diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java
index c0bbb1c..95d49b8 100644
--- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java
+++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java
@@ -1,5 +1,6 @@
package com.magistr.app.model;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.time.LocalDate;
@@ -89,7 +90,7 @@ public class ScheduleRule {
}
public void setStatus(String status) {
- this.status = status;
+ this.status = status == null || status.isBlank() ? LifecycleEntity.STATUS_ACTIVE : status;
}
public LocalDate getValidFrom() {
@@ -172,6 +173,38 @@ public class ScheduleRule {
};
}
+ @JsonIgnore
+ public boolean isActiveRecord() {
+ return !LifecycleEntity.STATUS_ARCHIVED.equals(status);
+ }
+
+ @JsonIgnore
+ public boolean isArchivedRecord() {
+ return LifecycleEntity.STATUS_ARCHIVED.equals(status);
+ }
+
+ @JsonIgnore
+ public boolean isActiveOn(LocalDate date) {
+ if (date == null) {
+ return isActiveRecord();
+ }
+ if (!isActiveRecord()) {
+ return false;
+ }
+ boolean afterStart = validFrom == null || !date.isBefore(validFrom);
+ boolean beforeEnd = validTo == null || !date.isAfter(validTo);
+ return afterStart && beforeEnd;
+ }
+
+ public void archive() {
+ status = LifecycleEntity.STATUS_ARCHIVED;
+ }
+
+ public void restore() {
+ status = LifecycleEntity.STATUS_ACTIVE;
+ validTo = null;
+ }
+
private int valueOrZero(Integer value) {
return valueOrDefault(value, 0);
}
diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java
new file mode 100644
index 0000000..ba8c02c
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java
@@ -0,0 +1,19 @@
+package com.magistr.app.model;
+
+public enum StudentGroupStudyState {
+ ACTIVE("Активна"),
+ NOT_STARTED("Обучение не началось"),
+ GRADUATED("Завершила обучение"),
+ INACTIVE("Неактивна"),
+ ARCHIVED("Архив");
+
+ private final String label;
+
+ StudentGroupStudyState(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java
index 9449bd6..772cb32 100644
--- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java
+++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java
@@ -16,7 +16,6 @@ import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Service
@@ -34,7 +33,6 @@ public class ScheduleGeneratorService {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
- private final Map consumedHoursCache = new ConcurrentHashMap<>();
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
Map> rulesBySemester = new HashMap<>();
+ Map consumedHoursProgress = new HashMap<>();
List result = new ArrayList<>();
Set warnedAcademicYears = new HashSet<>();
+ Set primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional semesterOpt = academicDateService.findSemester(date);
@@ -81,9 +81,12 @@ public class ScheduleGeneratorService {
semester.getId(),
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
);
+ if (primedSemesterIds.add(semester.getId())) {
+ primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
+ }
for (ScheduleRule rule : sortRules(rules)) {
- renderRuleForDate(rule, date, group, null, result);
+ processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
}
}
@@ -97,7 +100,9 @@ public class ScheduleGeneratorService {
}
Map> rulesBySemester = new HashMap<>();
+ Map consumedHoursProgress = new HashMap<>();
List result = new ArrayList<>();
+ Set primedSemesterIds = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional semesterOpt = academicDateService.findSemester(date);
@@ -110,9 +115,12 @@ public class ScheduleGeneratorService {
semester.getId(),
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
);
+ if (primedSemesterIds.add(semester.getId())) {
+ primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
+ }
for (ScheduleRule rule : sortRules(rules)) {
- renderRuleForDate(rule, date, null, teacherId, result);
+ processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
}
}
@@ -120,7 +128,8 @@ public class ScheduleGeneratorService {
}
public void clearCache() {
- consumedHoursCache.clear();
+ // Расход часов считается в контексте одного построения расписания.
+ // Метод оставлен для совместимости с контроллерами, которые сигнализируют об изменении исходных данных.
}
private void validateRange(LocalDate startDate, LocalDate endDate) {
@@ -152,10 +161,34 @@ public class ScheduleGeneratorService {
.toList();
}
- private void renderRuleForDate(ScheduleRule rule,
+ private void primeConsumedHoursBeforeRange(Semester semester,
+ List rules,
+ LocalDate rangeStart,
+ StudentGroup targetGroup,
+ Long targetTeacherId,
+ Map consumedHoursProgress) {
+ LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
+ if (!semester.getStartDate().isBefore(endExclusive)) {
+ return;
+ }
+
+ List sortedRules = sortRules(rules);
+ for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
+ for (ScheduleRule rule : sortedRules) {
+ processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
+ }
+ }
+ }
+
+ private LocalDate minDate(LocalDate first, LocalDate second) {
+ return first.isBefore(second) ? first : second;
+ }
+
+ private void processRuleForDate(ScheduleRule rule,
LocalDate date,
StudentGroup targetGroup,
Long targetTeacherId,
+ Map consumedHoursProgress,
List result) {
if (rule.getLectureAcademicHours() == null
|| rule.getLaboratoryAcademicHours() == null
@@ -170,6 +203,9 @@ public class ScheduleGeneratorService {
if (!rule.getSubject().isActiveOn(date)) {
return;
}
+ if (!rule.isActiveOn(date)) {
+ return;
+ }
Semester semester = semesterOpt.get();
List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
return;
}
- Map consumedHoursByBucket = new HashMap<>();
-
for (ScheduleRuleSlot slot : slots) {
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
continue;
@@ -217,11 +251,8 @@ public class ScheduleGeneratorService {
List activeScopes = new ArrayList<>();
for (LessonScope scope : lessonScopes) {
- String consumptionBucket = consumptionBucket(category, scope.subgroupId());
- int consumedHours = consumedHoursByBucket.computeIfAbsent(
- consumptionBucket,
- key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
- );
+ String progressKey = consumptionProgressKey(rule, targetGroup, category, scope.subgroupId());
+ int consumedHours = consumedHoursProgress.getOrDefault(progressKey, 0);
if (consumedHours >= totalHours) {
continue;
}
@@ -233,28 +264,30 @@ public class ScheduleGeneratorService {
continue;
}
- Map lessonGroupById = new LinkedHashMap<>();
- Map lessonSubgroupById = new LinkedHashMap<>();
- activeScopes.forEach(scope -> {
- scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
- scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
- });
- List lessonGroups = new ArrayList<>(lessonGroupById.values());
- List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
- int consumedHours = activeScopes.stream()
- .mapToInt(ActiveLessonScope::consumedHours)
- .min()
- .orElse(0);
- int remainingAfterLesson = activeScopes.stream()
- .mapToInt(ActiveLessonScope::remainingAfterLesson)
- .min()
- .orElse(0);
+ if (result != null) {
+ Map lessonGroupById = new LinkedHashMap<>();
+ Map lessonSubgroupById = new LinkedHashMap<>();
+ activeScopes.forEach(scope -> {
+ scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
+ scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
+ });
+ List lessonGroups = new ArrayList<>(lessonGroupById.values());
+ List lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
+ int consumedHours = activeScopes.stream()
+ .mapToInt(ActiveLessonScope::consumedHours)
+ .min()
+ .orElse(0);
+ int remainingAfterLesson = activeScopes.stream()
+ .mapToInt(ActiveLessonScope::remainingAfterLesson)
+ .min()
+ .orElse(0);
- result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
- totalHours, consumedHours, remainingAfterLesson));
+ result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
+ totalHours, consumedHours, remainingAfterLesson));
+ }
for (ActiveLessonScope scope : activeScopes) {
- consumedHoursByBucket.put(
- consumptionBucket(category, scope.scope().subgroupId()),
+ consumedHoursProgress.put(
+ consumptionProgressKey(rule, targetGroup, category, scope.scope().subgroupId()),
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
);
}
@@ -275,62 +308,20 @@ public class ScheduleGeneratorService {
.toList();
}
- private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
- StudentGroup targetGroup,
- LocalDate targetDate,
- ScheduleLessonCategory category,
- Long subgroupId) {
- Long groupId = targetGroup == null ? null : targetGroup.getId();
- String cacheKey = rule.getId()
- + ":" + category.name()
- + ":" + (subgroupId == null ? "whole" : subgroupId)
- + ":" + (groupId == null ? "all" : groupId)
- + ":" + targetDate;
- return consumedHoursCache.computeIfAbsent(cacheKey,
- ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
- }
-
- private int calculateConsumedHours(ScheduleRule rule,
- StudentGroup targetGroup,
- LocalDate targetDate,
- ScheduleLessonCategory category,
- Long subgroupId) {
- int consumed = 0;
- for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
- Semester semester = rule.getSemester();
- if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
- continue;
- }
- int weekNumber = academicDateService.getWeekNumber(semester, date);
- if (weekNumber < rule.startWeekFor(category)) {
- continue;
- }
- List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
- if (eligibleGroups.isEmpty()) {
- continue;
- }
-
- int dayOfWeek = date.getDayOfWeek().getValue();
- ScheduleParity dateParity = academicDateService.getParity(semester, date);
- long activeSlots = rule.getSlots().stream()
- .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
- .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
- .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
- .flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
- .filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
- .count();
- consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
- if (consumed >= rule.academicHoursFor(category)) {
- return consumed;
- }
- }
- return consumed;
- }
-
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
}
+ private String consumptionProgressKey(ScheduleRule rule,
+ StudentGroup targetGroup,
+ ScheduleLessonCategory category,
+ Long subgroupId) {
+ Long groupId = targetGroup == null ? null : targetGroup.getId();
+ return rule.getId()
+ + ":" + consumptionBucket(category, subgroupId)
+ + ":" + (groupId == null ? "all" : groupId);
+ }
+
private List lessonScopesForSlot(ScheduleRuleSlot slot,
ScheduleLessonCategory category,
List eligibleGroups) {
@@ -415,7 +406,8 @@ public class ScheduleGeneratorService {
"Т",
lessonTypeAcademicHours,
consumedBeforeLesson,
- remainingAfterLesson
+ remainingAfterLesson,
+ slot.getParity()
);
}
diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java
index 348044f..89ffb85 100644
--- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java
+++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java
@@ -1,7 +1,6 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
-import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
@@ -9,22 +8,29 @@ import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class ScheduleQueryService {
+ private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
+ private static final long MAX_RANGE_DAYS = 120;
+
private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository;
+ private final StudentGroupLifecycleService groupLifecycleService;
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
- ScheduleOverrideRepository scheduleOverrideRepository) {
+ ScheduleOverrideRepository scheduleOverrideRepository,
+ StudentGroupLifecycleService groupLifecycleService) {
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
+ this.groupLifecycleService = groupLifecycleService;
}
public List search(Long groupId,
@@ -37,10 +43,16 @@ public class ScheduleQueryService {
String parity,
LocalDate startDate,
LocalDate endDate) {
- List groups = resolveGroups(groupId, departmentId);
- List generatedLessons = groups.stream()
- .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
- .toList();
+ validateRange(startDate, endDate);
+ List generatedLessons;
+ if (groupId == null && departmentId == null && teacherId != null) {
+ generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
+ } else {
+ List groups = resolveGroups(groupId, departmentId, startDate, endDate);
+ generatedLessons = groups.stream()
+ .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
+ .toList();
+ }
List lessons = applyOverrides(generatedLessons, startDate, endDate).stream()
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
@@ -124,7 +136,8 @@ public class ScheduleQueryService {
lesson.activityType(),
lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
- lesson.remainingLessonTypeAcademicHoursAfterLesson()
+ lesson.remainingLessonTypeAcademicHoursAfterLesson(),
+ lesson.ruleParity()
);
}
@@ -132,17 +145,39 @@ public class ScheduleQueryService {
return scheduleRuleSlotId + ":" + date;
}
- private List resolveGroups(Long groupId, Long departmentId) {
+ private void validateRange(LocalDate startDate, LocalDate endDate) {
+ if (startDate == null || endDate == null) {
+ throw new IllegalArgumentException("Дата начала и дата окончания обязательны");
+ }
+ if (endDate.isBefore(startDate)) {
+ throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
+ }
+ if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
+ throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
+ }
+ }
+
+ private List resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
if (groupId != null) {
return groupRepository.findById(groupId)
- .filter(StudentGroup::isActiveRecord)
+ .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.map(List::of)
.orElse(List.of());
}
+ List groups;
if (departmentId != null) {
- return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
+ groups = groupRepository.findByDepartmentId(departmentId);
+ } else {
+ groups = groupRepository.findAll();
}
- return groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
+ groups = groups.stream()
+ .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
+ .toList();
+ if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
+ throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
+ + groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
+ }
+ return groups;
}
private List deduplicate(List lessons) {
@@ -239,7 +274,8 @@ public class ScheduleQueryService {
source.activityType(),
source.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(),
- source.remainingLessonTypeAcademicHoursAfterLesson()
+ source.remainingLessonTypeAcademicHoursAfterLesson(),
+ source.ruleParity()
);
}
}
diff --git a/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java
new file mode 100644
index 0000000..5620a9b
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java
@@ -0,0 +1,82 @@
+package com.magistr.app.service;
+
+import com.magistr.app.model.StudentGroup;
+import com.magistr.app.model.StudentGroupCalendarAssignment;
+import com.magistr.app.model.StudentGroupStudyState;
+import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
+import com.magistr.app.utils.CourseAndSemesterCalculator;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.util.Comparator;
+import java.util.Optional;
+
+@Service
+public class StudentGroupLifecycleService {
+
+ private final StudentGroupCalendarAssignmentRepository assignmentRepository;
+
+ public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) {
+ this.assignmentRepository = assignmentRepository;
+ }
+
+ public boolean isAvailableForSelection(StudentGroup group, LocalDate date) {
+ return group != null
+ && group.isActiveRecord()
+ && group.isActiveOn(date)
+ && !isGraduatedOn(group, date);
+ }
+
+ public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) {
+ if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) {
+ return false;
+ }
+ Optional courseLimit = resolveCourseLimit(group);
+ for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
+ if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) {
+ if (group == null || group.isArchivedRecord()) {
+ return StudentGroupStudyState.ARCHIVED;
+ }
+ if (!group.isActiveOn(date)) {
+ return StudentGroupStudyState.INACTIVE;
+ }
+ if (isGraduatedOn(group, date)) {
+ return StudentGroupStudyState.GRADUATED;
+ }
+ if (!hasStarted(group, date)) {
+ return StudentGroupStudyState.NOT_STARTED;
+ }
+ return StudentGroupStudyState.ACTIVE;
+ }
+
+ private boolean hasStarted(StudentGroup group, LocalDate date) {
+ return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0;
+ }
+
+ private boolean isGraduatedOn(StudentGroup group, LocalDate date) {
+ return isGraduatedOn(group, date, resolveCourseLimit(group));
+ }
+
+ private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional courseLimit) {
+ int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
+ return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
+ }
+
+ private Optional resolveCourseLimit(StudentGroup group) {
+ if (group.getId() == null) {
+ return Optional.empty();
+ }
+ return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream()
+ .map(StudentGroupCalendarAssignment::getAcademicCalendar)
+ .filter(calendar -> calendar.getCourseCount() != null)
+ .map(calendar -> calendar.getCourseCount())
+ .max(Comparator.naturalOrder());
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java
index af6b488..3b0f9e9 100644
--- a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java
+++ b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java
@@ -9,22 +9,30 @@ import java.time.LocalDate;
public class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) {
- LocalDate now = LocalDate.now();
- int currentYear = now.getYear();
- int currentMonth = now.getMonthValue();
+ return getActualCourse(yearStartStudy, LocalDate.now());
+ }
- if (currentMonth >= 9) {
- return currentYear - yearStartStudy + 1;
- } else {
- return currentYear - yearStartStudy;
- }
+ public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
+ int currentYear = date.getYear();
+ int currentMonth = date.getMonthValue();
+ int course = currentMonth >= 9
+ ? currentYear - yearStartStudy + 1
+ : currentYear - yearStartStudy;
+ return Math.max(0, course);
}
public static int getActualSemester(Integer yearStartStudy) {
- int course = getActualCourse(yearStartStudy);
- int currentMonth = LocalDate.now().getMonthValue();
+ return getActualSemester(yearStartStudy, LocalDate.now());
+ }
- if ( currentMonth <= 1 || currentMonth >= 9) {
+ public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
+ int course = getActualCourse(yearStartStudy, date);
+ if (course == 0) {
+ return 0;
+ }
+ int currentMonth = date.getMonthValue();
+
+ if (currentMonth <= 1 || currentMonth >= 9) {
return course * 2 - 1;
} else {
return course * 2;
@@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator {
public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
int recordYear = Integer.parseInt(periodYears.substring(0, 4));
- return recordYear - yearStartStudy + 1;
+ return Math.max(0, recordYear - yearStartStudy + 1);
}
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
int course = getFutureCourse(yearStartStudy, periodYears);
+ if (course == 0) {
+ return 0;
+ }
if (semesterType == SemesterType.autumn) {
return course * 2 - 1;
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index a1d4852..ed1e51f 100755
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -3,7 +3,7 @@ server.port=8080
# PostgreSQL (дефолтный — для локальной разработки через Docker Compose)
spring.datasource.url=jdbc:postgresql://db:5432/app_db
spring.datasource.username=${POSTGRES_USER:myuser}
-spring.datasource.password=${POSTGRES_PASSWORD:supersecretpassword}
+spring.datasource.password=${POSTGRES_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver
# JPA
diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java
new file mode 100644
index 0000000..3820475
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java
@@ -0,0 +1,23 @@
+package com.magistr.app.config.tenant;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TenantConfigWatcherTest {
+
+ @Test
+ void configHashUsesSha256Hex() {
+ String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");
+
+ assertThat(hash).hasSize(64);
+ assertThat(hash).matches("[0-9a-f]{64}");
+ assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
+ }
+
+ @Test
+ void configHashChangesWhenContentChanges() {
+ assertThat(TenantConfigWatcher.configHash("tenant-a"))
+ .isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java
new file mode 100644
index 0000000..20e4841
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/controller/AuthControllerTest.java
@@ -0,0 +1,38 @@
+package com.magistr.app.controller;
+
+import com.magistr.app.config.auth.AuthContext;
+import com.magistr.app.config.auth.AuthenticatedUser;
+import com.magistr.app.model.Role;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AuthControllerTest {
+
+ @AfterEach
+ void clearAuthContext() {
+ AuthContext.clear();
+ }
+
+ @Test
+ void meAllowsNullDepartmentId() {
+ AuthController controller = new AuthController(null, null, null, null, null);
+ AuthContext.setCurrentUser(new AuthenticatedUser(10L, "admin", Role.ADMIN, null));
+
+ ResponseEntity> response = controller.me();
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isInstanceOf(Map.class);
+ @SuppressWarnings("unchecked")
+ Map body = (Map) response.getBody();
+ assertThat(body).containsEntry("userId", 10L);
+ assertThat(body).containsEntry("username", "admin");
+ assertThat(body).containsEntry("role", "ADMIN");
+ assertThat(body).containsKey("departmentId");
+ assertThat(body.get("departmentId")).isNull();
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java
new file mode 100644
index 0000000..9f281e9
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java
@@ -0,0 +1,99 @@
+package com.magistr.app.controller;
+
+import com.magistr.app.config.auth.AuthContext;
+import com.magistr.app.config.auth.AuthenticatedUser;
+import com.magistr.app.dto.UserResponse;
+import com.magistr.app.model.Department;
+import com.magistr.app.model.LifecycleEntity;
+import com.magistr.app.model.Role;
+import com.magistr.app.model.Subject;
+import com.magistr.app.model.TeacherDepartmentAssignment;
+import com.magistr.app.model.User;
+import com.magistr.app.repository.SubjectCommentRepository;
+import com.magistr.app.repository.SubjectRepository;
+import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
+import com.magistr.app.repository.UserRepository;
+import com.magistr.app.service.ScheduleQueryService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DepartmentWorkspaceControllerTest {
+
+ @AfterEach
+ void clearAuthContext() {
+ AuthContext.clear();
+ }
+
+ @Test
+ void commentsRejectSubjectWithoutDepartmentForDepartmentUserWithoutNpe() {
+ SubjectRepository subjectRepository = mock(SubjectRepository.class);
+ Subject subject = new Subject();
+ subject.setId(10L);
+ subject.setDepartmentId(null);
+ when(subjectRepository.findById(10L)).thenReturn(Optional.of(subject));
+ DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
+ subjectRepository,
+ mock(SubjectCommentRepository.class),
+ mock(UserRepository.class),
+ mock(TeacherDepartmentAssignmentRepository.class),
+ mock(ScheduleQueryService.class)
+ );
+ AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
+
+ var response = controller.getComments(10L);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(404);
+ }
+
+ @Test
+ void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
+ UserRepository userRepository = mock(UserRepository.class);
+ TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
+ User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
+ User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
+ when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
+ .thenReturn(List.of(assignment(assignedTeacher, 1L)));
+ when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
+ .thenReturn(List.of(directTeacher, assignedTeacher));
+ DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
+ mock(SubjectRepository.class),
+ mock(SubjectCommentRepository.class),
+ userRepository,
+ assignmentRepository,
+ mock(ScheduleQueryService.class)
+ );
+
+ List teachers = controller.getTeachers(1L);
+
+ assertThat(teachers).extracting(UserResponse::getFullName)
+ .containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
+ }
+
+ private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
+ Department department = new Department(departmentId, "Кафедра", departmentId);
+ TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
+ assignment.setTeacher(teacher);
+ assignment.setDepartment(department);
+ assignment.setValidFrom(LocalDate.now());
+ return assignment;
+ }
+
+ private User user(Long id, String username, String fullName, Long departmentId) {
+ User user = new User();
+ user.setId(id);
+ user.setUsername(username);
+ user.setRole(Role.TEACHER);
+ user.setFullName(fullName);
+ user.setJobTitle("Доцент");
+ user.setDepartmentId(departmentId);
+ return user;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java
new file mode 100644
index 0000000..b751e4e
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java
@@ -0,0 +1,60 @@
+package com.magistr.app.controller;
+
+import com.magistr.app.dto.UserResponse;
+import com.magistr.app.model.Department;
+import com.magistr.app.model.LifecycleEntity;
+import com.magistr.app.model.Role;
+import com.magistr.app.model.User;
+import com.magistr.app.repository.DepartmentRepository;
+import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
+import com.magistr.app.repository.UserRepository;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class UserControllerTest {
+
+ @Test
+ void teachersUseUnifiedUserResponseMapping() {
+ UserRepository userRepository = mock(UserRepository.class);
+ DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
+ UserController controller = new UserController(
+ userRepository,
+ null,
+ departmentRepository,
+ mock(TeacherDepartmentAssignmentRepository.class)
+ );
+ User teacher = user(10L, "teacher", Role.TEACHER, 2L);
+ when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
+ .thenReturn(List.of(teacher));
+ when(departmentRepository.findById(2L))
+ .thenReturn(Optional.of(new Department(2L, "Кафедра ВТ", 2L)));
+
+ var response = controller.getTeachersByDepartmentId(2L);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ @SuppressWarnings("unchecked")
+ List body = (List) response.getBody();
+ assertThat(body).hasSize(1);
+ assertThat(body.get(0).getUsername()).isEqualTo("teacher");
+ assertThat(body.get(0).getDepartmentId()).isEqualTo(2L);
+ assertThat(body.get(0).getDepartmentName()).isEqualTo("Кафедра ВТ");
+ assertThat(body.get(0).getStatus()).isEqualTo(LifecycleEntity.STATUS_ACTIVE);
+ }
+
+ private User user(Long id, String username, Role role, Long departmentId) {
+ User user = new User();
+ user.setId(id);
+ user.setUsername(username);
+ user.setRole(role);
+ user.setFullName("Тестовый пользователь");
+ user.setJobTitle("Преподаватель");
+ user.setDepartmentId(departmentId);
+ return user;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java
new file mode 100644
index 0000000..502f7ef
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java
@@ -0,0 +1,50 @@
+package com.magistr.app.controller;
+
+import com.magistr.app.model.Classroom;
+import com.magistr.app.model.LifecycleEntity;
+import com.magistr.app.repository.ClassroomRepository;
+import com.magistr.app.repository.DepartmentRepository;
+import com.magistr.app.repository.UserRepository;
+import com.magistr.app.service.ScheduleQueryService;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class WorkloadControllerTest {
+
+ @Test
+ void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
+ ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
+ ClassroomRepository classroomRepository = mock(ClassroomRepository.class);
+ WorkloadController controller = new WorkloadController(
+ scheduleQueryService,
+ classroomRepository,
+ mock(UserRepository.class),
+ mock(DepartmentRepository.class)
+ );
+ when(scheduleQueryService.search(any(), any(), any(), any(), any(), any(), eq(1L), any(), any(), any()))
+ .thenReturn(List.of());
+ when(classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
+ .thenReturn(List.of(classroom(1L, null), classroom(2L, false), classroom(3L, true)));
+
+ var result = controller.freeClassrooms(LocalDate.of(2026, 5, 27), 1L);
+
+ assertThat(result).extracting("id").containsExactly(3L);
+ }
+
+ private Classroom classroom(Long id, Boolean available) {
+ Classroom classroom = new Classroom();
+ classroom.setId(id);
+ classroom.setName("Аудитория " + id);
+ classroom.setCapacity(30);
+ classroom.setIsAvailable(available);
+ return classroom;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java
new file mode 100644
index 0000000..c5e36c0
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java
@@ -0,0 +1,40 @@
+package com.magistr.app.model;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LifecycleEntityTest {
+
+ @Test
+ void archivedEntityIsNotActiveOnDateWithoutActiveTo() {
+ Subject subject = new Subject();
+ subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
+
+ assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
+ }
+
+ @Test
+ void archivedEntityRemainsActiveForHistoricalDatesBeforeActiveTo() {
+ Subject subject = new Subject();
+ subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
+ subject.setActiveTo(LocalDate.of(2026, 5, 10));
+
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 9))).isTrue();
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 11))).isFalse();
+ }
+
+ @Test
+ void activeEntityUsesDateRange() {
+ Subject subject = new Subject();
+ subject.setActiveFrom(LocalDate.of(2026, 1, 10));
+ subject.setActiveTo(LocalDate.of(2026, 1, 20));
+
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 9))).isFalse();
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 10))).isTrue();
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 20))).isTrue();
+ assertThat(subject.isActiveOn(LocalDate.of(2026, 1, 21))).isFalse();
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java
new file mode 100644
index 0000000..8b47ec0
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/model/ScheduleLessonCategoryTest.java
@@ -0,0 +1,39 @@
+package com.magistr.app.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ScheduleLessonCategoryTest {
+
+ @Test
+ void mapsCanonicalLessonTypes() {
+ assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лекция")))
+ .isEqualTo(ScheduleLessonCategory.LECTURE);
+ assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Практика")))
+ .isEqualTo(ScheduleLessonCategory.PRACTICE);
+ assertThat(ScheduleLessonCategory.fromLessonType(lessonType("Лабораторная работа")))
+ .isEqualTo(ScheduleLessonCategory.LABORATORY);
+ }
+
+ @Test
+ void rejectsAmbiguousLessonType() {
+ assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Лабораторно-практическое занятие")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("неоднозначен");
+ }
+
+ @Test
+ void rejectsUnsupportedLessonType() {
+ assertThatThrownBy(() -> ScheduleLessonCategory.fromLessonType(lessonType("Консультация")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("должен быть лекцией");
+ }
+
+ private LessonType lessonType(String name) {
+ LessonType lessonType = new LessonType();
+ lessonType.setLessonType(name);
+ return lessonType;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java
new file mode 100644
index 0000000..f6f0bfe
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java
@@ -0,0 +1,41 @@
+package com.magistr.app.model;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ScheduleRuleTest {
+
+ @Test
+ void activeOnUsesStatusAndValidityRange() {
+ ScheduleRule rule = new ScheduleRule();
+ rule.setValidFrom(LocalDate.of(2026, 2, 1));
+ rule.setValidTo(LocalDate.of(2026, 2, 10));
+
+ assertThat(rule.isActiveOn(LocalDate.of(2026, 1, 31))).isFalse();
+ assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 1))).isTrue();
+ assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 10))).isTrue();
+ assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 11))).isFalse();
+
+ rule.setStatus(LifecycleEntity.STATUS_ARCHIVED);
+
+ assertThat(rule.isActiveOn(LocalDate.of(2026, 2, 5))).isFalse();
+ }
+
+ @Test
+ void archiveAndRestoreToggleStatus() {
+ ScheduleRule rule = new ScheduleRule();
+
+ rule.archive();
+
+ assertThat(rule.isArchivedRecord()).isTrue();
+ assertThat(rule.isActiveOn(LocalDate.now())).isFalse();
+
+ rule.restore();
+
+ assertThat(rule.isActiveRecord()).isTrue();
+ assertThat(rule.getValidTo()).isNull();
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java
new file mode 100644
index 0000000..7fe126a
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java
@@ -0,0 +1,77 @@
+package com.magistr.app.service;
+
+import com.magistr.app.model.StudentGroup;
+import com.magistr.app.repository.GroupRepository;
+import com.magistr.app.repository.ScheduleOverrideRepository;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ScheduleQueryServiceTest {
+
+ @Test
+ void rejectsTooBroadGroupSearch() {
+ GroupRepository groupRepository = mock(GroupRepository.class);
+ StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
+ when(groupRepository.findAll())
+ .thenReturn(IntStream.rangeClosed(1, 51)
+ .mapToObj(this::group)
+ .toList());
+ when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
+ ScheduleQueryService service = new ScheduleQueryService(
+ mock(ScheduleGeneratorService.class),
+ groupRepository,
+ mock(ScheduleOverrideRepository.class),
+ groupLifecycleService
+ );
+
+ assertThatThrownBy(() -> service.search(null, null, null, null,
+ null, null, null, null,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 7)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Уточните группу или кафедру");
+ }
+
+ @Test
+ void teacherSearchDoesNotLoadAllGroups() {
+ ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
+ GroupRepository groupRepository = mock(GroupRepository.class);
+ ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
+ StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
+ ScheduleQueryService service = new ScheduleQueryService(
+ generatorService,
+ groupRepository,
+ overrideRepository,
+ groupLifecycleService
+ );
+ LocalDate startDate = LocalDate.of(2026, 5, 1);
+ LocalDate endDate = LocalDate.of(2026, 5, 7);
+ when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
+ when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
+
+ service.search(null, 10L, null, null,
+ null, null, null, null,
+ startDate,
+ endDate);
+
+ verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
+ verify(groupRepository, never()).findAll();
+ }
+
+ private StudentGroup group(int id) {
+ StudentGroup group = new StudentGroup();
+ group.setId((long) id);
+ group.setName("Группа " + id);
+ return group;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java
new file mode 100644
index 0000000..54e966d
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java
@@ -0,0 +1,63 @@
+package com.magistr.app.service;
+
+import com.magistr.app.model.AcademicCalendar;
+import com.magistr.app.model.StudentGroup;
+import com.magistr.app.model.StudentGroupCalendarAssignment;
+import com.magistr.app.model.StudentGroupStudyState;
+import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class StudentGroupLifecycleServiceTest {
+
+ @Test
+ void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() {
+ StudentGroup group = group(1L, 2027);
+ StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
+ LocalDate date = LocalDate.of(2026, 5, 1);
+
+ assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED);
+ assertThat(service.isAvailableForSelection(group, date)).isTrue();
+ assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
+ }
+
+ @Test
+ void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() {
+ StudentGroup group = group(1L, 2020);
+ StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
+ LocalDate date = LocalDate.of(2026, 5, 1);
+
+ assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED);
+ assertThat(service.isAvailableForSelection(group, date)).isFalse();
+ assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
+ }
+
+ private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) {
+ StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class);
+ when(repository.findByGroupIdWithDetails(group.getId()))
+ .thenReturn(List.of(assignment(courseCount)));
+ return new StudentGroupLifecycleService(repository);
+ }
+
+ private StudentGroup group(Long id, int yearStartStudy) {
+ StudentGroup group = new StudentGroup();
+ group.setId(id);
+ group.setName("Группа");
+ group.setYearStartStudy(yearStartStudy);
+ return group;
+ }
+
+ private StudentGroupCalendarAssignment assignment(int courseCount) {
+ AcademicCalendar calendar = new AcademicCalendar();
+ calendar.setCourseCount(courseCount);
+ StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
+ assignment.setAcademicCalendar(calendar);
+ return assignment;
+ }
+}
diff --git a/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java
new file mode 100644
index 0000000..f3eb939
--- /dev/null
+++ b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java
@@ -0,0 +1,26 @@
+package com.magistr.app.utils;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CourseAndSemesterCalculatorTest {
+
+ @Test
+ void courseAndSemesterAreZeroBeforeStudyStart() {
+ LocalDate date = LocalDate.of(2026, 5, 1);
+
+ assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero();
+ assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero();
+ }
+
+ @Test
+ void septemberStartsFirstCourseAndFirstSemester() {
+ LocalDate date = LocalDate.of(2026, 9, 1);
+
+ assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1);
+ assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1);
+ }
+}
diff --git a/backend/tenants.json b/backend/tenants.example.json
old mode 100755
new mode 100644
similarity index 74%
rename from backend/tenants.json
rename to backend/tenants.example.json
index 704573b..3f65553
--- a/backend/tenants.json
+++ b/backend/tenants.example.json
@@ -4,6 +4,6 @@
"domain": "default",
"url": "jdbc:postgresql://db:5432/app_db",
"username": "myuser",
- "password": "supersecretpassword"
+ "password": "replace-with-local-password"
}
-]
\ No newline at end of file
+]
diff --git a/compose.yaml b/compose.yaml
index a5bd0a0..05cf62d 100755
--- a/compose.yaml
+++ b/compose.yaml
@@ -32,11 +32,11 @@ services:
ports:
- "5432:5432"
environment:
- POSTGRES_USER: myuser
- POSTGRES_PASSWORD: supersecretpassword
- POSTGRES_DB: app_db
+ POSTGRES_USER: ${POSTGRES_USER:-myuser}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD должен быть задан в .env}
+ POSTGRES_DB: ${POSTGRES_DB:-app_db}
healthcheck:
- test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
+ test: [ "CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\"" ]
interval: 5s
timeout: 5s
retries: 5
diff --git a/docs/API.md b/docs/API.md
index a7088c8..faa2506 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -2,6 +2,20 @@
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
+Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
+
+```json
+{
+ "timestamp": "2026-05-27T19:47:54",
+ "status": 400,
+ "error": "Некорректный запрос",
+ "message": "Некорректные параметры запроса",
+ "path": "/api/schedule"
+}
+```
+
+Контроллеры, у которых исторически есть собственная обработка ошибок, могут возвращать более короткий объект с полем `message`.
+
---
## Аутентификация
@@ -105,6 +119,8 @@ Refresh-токен ротируется при каждом успешном о
}
```
+`departmentId` присутствует в ответе всегда, но может быть `null` для пользователей без привязки к кафедре.
+
---
## Пользователи
@@ -116,18 +132,20 @@ Refresh-токен ротируется при каждом успешном о
**Ответ:**
```json
[
- { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
- { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
+ { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ", "departmentId": 1, "status": "ACTIVE" },
+ { "id": 2, "username": "teacher1", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ", "departmentId": 2, "status": "ACTIVE" }
]
```
+`UserResponse` единый для списков пользователей, списков преподавателей и ответов создания/восстановления. Поля `departmentName`, `departmentId` и `status` не выводятся только если равны `null`.
+
### `GET /api/users/teachers`
Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/{departmentId}`
-Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
+Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
### `POST /api/users`
@@ -446,6 +464,8 @@ CRUD доступен по:
| `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` |
+Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
+
Пример:
```http
@@ -521,6 +541,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
| `GET` | `/api/department/schedule` | Расписание кафедры |
+`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
+
---
## Кафедры и специальности
@@ -579,7 +601,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `GET /api/groups`
-Список всех групп.
+Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
+
+Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
**Ответ:**
```json
@@ -599,14 +623,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
"specialtyName": "Программная инженерия",
"specialtyProfileId": 2,
"specialtyProfileName": "Без профиля",
- "specialityCode": 1
+ "specialityCode": 1,
+ "status": "ACTIVE",
+ "active": true,
+ "studyState": "ACTIVE",
+ "studyStateName": "Активна"
}
]
```
### `GET /api/groups/{departmentId}`
-Список всех групп привязанных к конкретной кафедре.
+Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
### `POST /api/groups`
@@ -624,7 +652,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
-`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`.
+`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
+
+Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
@@ -646,7 +676,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `DELETE /api/groups/{id}`
-Удаление группы.
+Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
+
+### `POST /api/groups/{id}/restore`
+
+Восстановление архивной группы.
### Подгруппы группы
@@ -939,7 +973,7 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| Код | Описание |
|-----|----------|
| `200` | Успех |
-| `400` | Ошибка валидации (с `message` в теле) |
+| `400` | Ошибка валидации или некорректные параметры запроса |
| `401` | Неверные учётные данные |
| `404` | Ресурс / тенант не найден |
| `500` | Внутренняя ошибка сервера |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 13d7ecb..ce6e9b4 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -32,7 +32,7 @@ graph TD
- **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта)
-- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), in-memory UUID-сессии, bearer-токены и backend-проверка ролей
+- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
### PostgreSQL
- **Версия:** `postgres:alpine3.23`
@@ -80,6 +80,7 @@ sequenceDiagram
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
+| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
@@ -99,7 +100,7 @@ sequenceDiagram
### Конфигурация тенантов
Список тенантов хранится в JSON-файле:
-- **Локально:** `backend/tenants.json`
+- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
Формат:
@@ -121,12 +122,16 @@ sequenceDiagram
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
+`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
+
### Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта:
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
+`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
+
---
## Аутентификация
diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md
index 2fa695c..88722a7 100644
--- a/docs/BUSINESS_LOGIC.md
+++ b/docs/BUSINESS_LOGIC.md
@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Учебные группы (Student Groups)
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
-- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
+- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
+- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms)
@@ -90,6 +91,8 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
+Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
+
### Перевод преподавателей между кафедрами
Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
@@ -136,6 +139,12 @@ Bearer-токен проверяется на backend. Frontend-скрытие
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
+Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
+
+В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
+
+Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
+
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
@@ -167,6 +176,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
+- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
### Точечные изменения расписания
diff --git a/docs/BUTTON_STYLING_PLAN.md b/docs/BUTTON_STYLING_PLAN.md
new file mode 100644
index 0000000..68db2c9
--- /dev/null
+++ b/docs/BUTTON_STYLING_PLAN.md
@@ -0,0 +1,420 @@
+# План стилизации кнопок
+
+Дата аудита: 01.06.2026
+Среда аудита: `https://localhost`
+Метод: визуальный обход через Playwright, фиксация скриншотов и computed styles.
+
+## 1. Краткое резюме
+
+Интерфейс уже имеет узнаваемую базу: тёмная админ-панель, фиолетовый акцент, компактные таблицы и отдельные публичные кабинеты преподавателя/студента. Главная проблема не в отдельных кнопках, а в отсутствии единого контракта между смыслом действия и его визуальным видом.
+
+Сейчас одинаковые классы могут выглядеть по-разному в разных SPA, а одинаковые действия могут получать разные цвета, размеры и плотность. Из-за этого пользователь каждый раз заново считывает интерфейс: где главное действие, где безопасное второстепенное, а где рискованное.
+
+Цель новой структуры: закрепить единую кнопку как системный компонент с понятными ролями, размерами и правилами использования.
+
+## 2. Что было проверено
+
+| Раздел | Проверенные сценарии | Скриншот |
+|---|---|---|
+| Авторизация | Вход, показ пароля, переключение темы | [`audit-buttons-01-login.png`](../audit-buttons-01-login.png) |
+| Дашборд админки | Быстрые переходы, recheck, сайдбар | [`audit-buttons-02-dashboard.png`](../audit-buttons-02-dashboard.png) |
+| Пользователи | Создание пользователя, архивирование | [`audit-buttons-03-users-form.png`](../audit-buttons-03-users-form.png) |
+| Меню настроек | Выпадающее меню, выход, переход в настройки | [`audit-buttons-04-settings-menu.png`](../audit-buttons-04-settings-menu.png) |
+| Настройки | Навигация отдельной SPA | [`audit-buttons-05-settings-general.png`](../audit-buttons-05-settings-general.png) |
+| Кабинет преподавателя | Недели, сегодня, выход, тема | [`audit-buttons-06-teacher.png`](../audit-buttons-06-teacher.png) |
+| Кабинет студента | Недели, сегодня, выход, тема | [`audit-buttons-07-student.png`](../audit-buttons-07-student.png) |
+| Модальное окно | Создание ручной сетки, отмена, закрытие | [`audit-buttons-08-modal.png`](../audit-buttons-08-modal.png) |
+
+Дополнительно были сохранены скриншоты отдельных разделов: календарный график, аудитории, группы, дисциплины, расписание, формы обучения, база данных, временные слоты.
+
+## 3. Текущее состояние
+
+### 3.1. Авторизация
+
+
+
+Найдены кнопки:
+
+| Кнопка | Текущая роль | Комментарий |
+|---|---|---|
+| `Войти` | Primary | Корректно считывается как главное действие экрана. |
+| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. |
+| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. |
+
+Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру.
+
+### 3.2. Дашборд админки
+
+
+
+Найдены кнопки и ссылки-кнопки:
+
+| Кнопка | Текущий вид | Проблема |
+|---|---|---|
+| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. |
+| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. |
+| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. |
+| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. |
+| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. |
+
+Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию.
+
+### 3.3. Формы и таблицы админки
+
+
+
+Типичные кнопки:
+
+| Кнопка | Смысл | Текущее впечатление |
+|---|---|---|
+| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. |
+| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. |
+| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. |
+| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. |
+
+Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках.
+
+### 3.4. Меню настроек и выход
+
+
+
+Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль.
+
+Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки.
+
+### 3.5. Отдельная SPA настроек
+
+
+
+В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему.
+
+Пример различий:
+
+| Класс | Основная админка | Настройки |
+|---|---|---|
+| `btn-primary` | Часто прозрачный фон, белый текст, визуальная заливка может приходить из другого слоя | Фиолетовая заливка `rgb(139, 92, 246)`, высота `40px`, жирность `700` |
+| `btn-delete` | Красный tint, высота часто `28px`, font-weight `400` | Красный tint, высота `40px`, font-weight `700` |
+| `btn-secondary` | Фиолетовый tint или светлая surface-кнопка | Нейтральная surface-кнопка |
+
+Вывод: одинаковое имя класса не является дизайн-контрактом. Компонент кнопки нужно нормализовать на уровне токенов и модификаторов.
+
+### 3.6. Кабинеты преподавателя и студента
+
+
+
+
+
+В публичных кабинетах используется отдельная светлая система:
+
+| Элемент | Преподаватель | Студент |
+|---|---|---|
+| `Сегодня` | Primary blue `rgb(29, 78, 216)` | Primary teal `rgb(15, 118, 110)` |
+| `Выйти` | Outline, белый фон, radius `8px` | Outline, белый фон, radius `8px` |
+| `‹` / `›` | Outline icon-like buttons | Outline icon-like buttons |
+| `Переключить тему` | Текстовый символ `◐` | Текстовый символ `◐` |
+
+Проблема: одно действие `Сегодня` меняет цвет в зависимости от кабинета. Это допустимо для брендинга ролей, но плохо для системных кнопок. Ролевые цвета лучше использовать в расписании, бейджах и карточках, а не в базовой кнопочной иерархии.
+
+### 3.7. Модальные окна
+
+
+
+Найдены:
+
+| Кнопка | Роль | Рекомендация |
+|---|---|---|
+| `Создать сетку` | Primary | Оставить primary. |
+| `Отмена` | Secondary/Ghost | Сделать ghost или secondary без акцента. |
+| `Закрыть` (`×`) | Icon close | Привести к icon-only button `40x40` или `36x36`, с `aria-label="Закрыть"`. |
+
+Модальная структура в целом правильная, но её нужно подчинить тем же токенам, что и остальные кнопки.
+
+## 4. Семантическая группировка найденных кнопок
+
+### Primary: главное действие
+
+Примеры: `Войти`, `Создать`, `Добавить`, `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот`, `Создать сетку`.
+
+Правило: одна primary-кнопка на форму, модальное окно или смысловой блок. Если на экране несколько форм, primary может быть в каждой форме, но не должна превращаться в оформление всех кнопок подряд.
+
+### Secondary: обычное второстепенное действие
+
+Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус`, `Преподаватели`, `Загрузить сетку`, `Заполнить диапазон`.
+
+Правило: использовать для действий, которые помогают выполнить задачу, но не являются главным подтверждением.
+
+### Tertiary / Ghost: тихое действие
+
+Примеры: `Очистить`, `Очистить форму`, `Отмена`, `Назад в панель`, `Свернуть`, `Из формы`, `Все`, `Точечные изменения / Просмотр`.
+
+Правило: использовать там, где действие должно быть доступно, но не конкурировать с сохранением или созданием.
+
+### Danger: деструктивное действие
+
+Примеры: `Удалить`.
+
+Правило: только для необратимых или почти необратимых операций. Красный цвет должен быть зарезервирован именно под риск.
+
+### Danger subtle: рискованное, но не удаление
+
+Примеры: `Архив`, `Архивировать`, `Удалить сетку`, `Выйти`.
+
+Правило: использовать для действий с негативным или сессионным эффектом, но без визуальной паники solid-red кнопки.
+
+### Icon button: компактное служебное действие
+
+Примеры: `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›`.
+
+Правило: фиксированный квадратный размер, единое состояние hover/focus, обязательный доступный текст через `aria-label`.
+
+### Navigation item: навигация, а не кнопка действия
+
+Примеры: пункты сайдбара `Дашборд`, `Конструктор правил`, `Календарный график`, `Пользователи`, `Общие настройки`.
+
+Правило: не смешивать навигацию с action button. У неё отдельные active, hover и focus-состояния.
+
+## 5. Предлагаемая дизайн-система кнопок
+
+### 5.1. Токены
+
+| Токен | Светлая тема | Тёмная тема | Назначение |
+|---|---|---|---|
+| `--button-primary-bg` | `#4F46E5` | `#8B5CF6` | Основное действие |
+| `--button-primary-bg-hover` | `#4338CA` | `#7C3AED` | Hover primary |
+| `--button-primary-text` | `#FFFFFF` | `#FFFFFF` | Текст primary |
+| `--button-secondary-bg` | `#FFFFFF` | `rgba(255,255,255,0.04)` | Второстепенная поверхность |
+| `--button-secondary-bg-hover` | `#F1F5F9` | `rgba(255,255,255,0.08)` | Hover secondary |
+| `--button-secondary-border` | `#CBD5E1` | `rgba(255,255,255,0.10)` | Граница secondary |
+| `--button-secondary-text` | `#334155` | `#F8FAFC` | Текст secondary |
+| `--button-ghost-bg-hover` | `#F8FAFC` | `rgba(255,255,255,0.06)` | Hover ghost |
+| `--button-danger-bg` | `#DC2626` | `#DC2626` | Удаление |
+| `--button-danger-bg-hover` | `#B91C1C` | `#B91C1C` | Hover удаления |
+| `--button-danger-subtle-bg` | `#FEF2F2` | `rgba(239,68,68,0.13)` | Риск без solid-заливки |
+| `--button-danger-subtle-text` | `#B91C1C` | `#FECACA` | Текст risk subtle |
+| `--button-focus-ring` | `rgba(79,70,229,0.35)` | `rgba(139,92,246,0.45)` | Focus outline |
+
+Примечание: для всего продукта лучше выбрать один primary-акцент. Для текущей тёмной админки логично оставить фиолетовый `#8B5CF6`, а для светлой темы использовать более читаемый `#4F46E5`. Ролевые цвета преподавателя и студента лучше вынести из кнопок в расписание и бейджи.
+
+### 5.2. Размеры
+
+| Размер | Высота | Padding | Шрифт | Где применять |
+|---|---:|---|---|---|
+| `sm` | `32px` | `0 12px` | `13px / 600` | Таблицы, компактные действия строк |
+| `md` | `40px` | `0 16px` | `14px / 600` | Формы, фильтры, модальные окна |
+| `lg` | `48px` | `0 20px` | `15px / 700` | Вход, главный CTA экрана |
+| `icon-sm` | `32x32` | `0` | icon `18px` | Табличные иконки |
+| `icon-md` | `40x40` | `0` | icon `20px` | Верхние панели, модалки, навигация недель |
+
+Базовый `border-radius`: `8px`.
+
+Почему `8px`: интерфейс административный и рабочий. Радиус `8px` сохраняет мягкость, но выглядит собраннее, чем повсеместные `10px`, особенно в таблицах и плотных формах.
+
+### 5.3. Варианты
+
+#### Primary
+
+Использовать для главного подтверждения.
+
+| Состояние | Стиль |
+|---|---|
+| Default | Фон primary, белый текст, без видимой границы |
+| Hover | Фон темнее на 8-12% |
+| Focus | Видимый ring `2px` |
+| Disabled | Нейтральный фон, muted-текст, `cursor: not-allowed` |
+
+Примеры: `Войти`, `Создать`, `Сохранить правило`, `Создать сетку`.
+
+#### Secondary
+
+Использовать для обычных действий рядом с primary.
+
+| Состояние | Стиль |
+|---|---|
+| Default | Surface-фон, тонкая граница, основной текст |
+| Hover | Чуть более светлая/контрастная поверхность |
+| Focus | Такой же focus ring, как у primary |
+| Disabled | Opacity `0.5` |
+
+Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`.
+
+#### Ghost
+
+Использовать для тихих действий.
+
+| Состояние | Стиль |
+|---|---|
+| Default | Прозрачный фон, muted/regular text |
+| Hover | Лёгкий surface-hover |
+| Focus | Ring без изменения иерархии |
+
+Примеры: `Отмена`, `Очистить`, `Назад в панель`, `Свернуть`.
+
+#### Danger
+
+Использовать только для удаления.
+
+| Состояние | Стиль |
+|---|---|
+| Default | Красная заливка, белый текст |
+| Hover | Более тёмный красный |
+| Focus | Красный или общий focus ring |
+
+Примеры: `Удалить`.
+
+#### Danger subtle
+
+Использовать для рискованных действий без полного удаления.
+
+| Состояние | Стиль |
+|---|---|
+| Default | Красный tint-фон, красный текст, мягкая border |
+| Hover | Более насыщенный tint |
+| Focus | Видимый ring |
+
+Примеры: `Архивировать`, `Архив`, `Выйти`, `Удалить сетку`.
+
+#### Icon button
+
+Использовать для действий, где текст заменён иконкой.
+
+| Параметр | Правило |
+|---|---|
+| Размер | `32x32` в таблицах, `40x40` в тулбарах |
+| Иконка | 18-20px, один набор иконок |
+| Текст | Только через `aria-label` |
+| Форма | Квадрат с radius `8px`, не круг, кроме специальных случаев avatar/fab |
+
+Примеры: `Переключить тему`, `Показать пароль`, `Закрыть`, `Предыдущая неделя`, `Следующая неделя`.
+
+## 6. Маппинг текущих кнопок на новую систему
+
+| Текущие кнопки | Новый тип | Размер |
+|---|---|---|
+| `Войти` | `Primary` | `lg` |
+| `Создать`, `Добавить` | `Primary` | `md` |
+| `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот` | `Primary` | `md` |
+| `Создать сетку` | `Primary` | `md` |
+| `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус` | `Secondary` | `sm` в таблицах, `md` в формах |
+| `Преподаватели`, `Подгруппы`, `Календарь`, `Сетка` | `Secondary` | `sm` |
+| `Очистить`, `Очистить форму`, `Отмена`, `Свернуть`, `Из формы`, `Все` | `Ghost` | `sm` или `md` |
+| `Точечные изменения / Просмотр`, `Назад в панель` | `Ghost navigation` | `md` |
+| `Удалить` | `Danger` | `sm` в таблицах, `md` в модалках |
+| `Архив`, `Архивировать`, `Выйти`, `Удалить сетку` | `Danger subtle` | `sm` или `md` |
+| `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›` | `Icon button` | `icon-sm` или `icon-md` |
+| Пункты сайдбара | `Nav item` | отдельный nav-компонент |
+
+## 7. Правила композиции
+
+### Формы
+
+Primary-кнопка должна стоять в конце формы или action row. Рядом с ней допустимы secondary/ghost-действия, но не ещё одна primary без причины.
+
+Рекомендуемый порядок:
+
+| Сценарий | Порядок |
+|---|---|
+| Форма создания | `Очистить` как ghost, `Создать` как primary |
+| Форма редактирования | `Отмена` как ghost, `Сохранить` как primary |
+| Фильтр | `Сбросить` как ghost, `Показать` как secondary или primary, если это единственное действие блока |
+
+### Таблицы
+
+В строках таблицы использовать только `sm`.
+
+Рекомендуемый порядок:
+
+1. Основное безопасное действие: `Изменить`, `Преподаватели`, `Подгруппы`.
+2. Дополнительное действие: `Календарь`, `Сетка`.
+3. Рискованное действие: `Архивировать` или `Удалить`.
+
+Удаление лучше отделять визуально от остальных кнопок небольшим gap.
+
+### Модальные окна
+
+В модалке primary должен соответствовать заголовку и цели модалки.
+
+Пример:
+
+| Заголовок | Primary | Secondary/Ghost |
+|---|---|---|
+| `Новая ручная сетка` | `Создать сетку` | `Отмена` |
+| `Удалить слот` | `Удалить` как danger | `Отмена` |
+| `Редактировать слот` | `Сохранить слот` | `Отмена` |
+
+Кнопка закрытия всегда icon-only с `aria-label="Закрыть"`.
+
+### Навигация
+
+Сайдбарные пункты не должны наследовать стили action-кнопок. У них отдельная логика:
+
+| Состояние | Стиль |
+|---|---|
+| Default | Прозрачный фон, muted-текст |
+| Hover | Нейтральный surface-hover |
+| Active | Акцентный tint-фон, accent-текст |
+| Focus | Видимый ring/outline |
+
+## 8. Главные исправления по приоритету
+
+| Приоритет | Что сделать | Почему важно |
+|---|---|---|
+| P1 | Нормализовать `btn-primary`, `btn-secondary`, `btn-delete` между основной админкой и настройками | Один класс должен означать один визуальный контракт |
+| P1 | Развести `Danger` и `Danger subtle` | `Удалить` и `Архивировать` не должны выглядеть одинаково опасными |
+| P1 | Ввести размерную шкалу `sm/md/lg/icon` | Таблицы и формы станут визуально ровнее |
+| P2 | Унифицировать `Переключить тему`, `Закрыть`, `‹`, `›`, `Показать пароль` как icon buttons | Повысит предсказуемость и доступность |
+| P2 | Убрать ролевые primary-цвета из student/teacher кнопок | Одинаковые действия будут считываться одинаково |
+| P3 | Заменить текстовые символы `◐`, `×`, `‹`, `›` на иконки единого набора | Интерфейс станет аккуратнее и доступнее |
+
+## 9. Визуальный референс поведения
+
+```text
+Primary
+[ Сохранить правило ]
+Фон: акцентный, текст белый. Только главное действие.
+
+Secondary
+[ Изменить ]
+Фон: поверхность, тонкая граница. Обычное действие.
+
+Ghost
+[ Отмена ]
+Фон: прозрачный. Тихое действие без конкуренции.
+
+Danger
+[ Удалить ]
+Фон: красный. Только необратимое действие.
+
+Danger subtle
+[ Архивировать ]
+Фон: красный tint. Рискованное, но не полноценное удаление.
+
+Icon
+[ icon ]
+Фиксированный квадрат. Доступное название через aria-label.
+```
+
+## 10. Почему это решение улучшит интерфейс
+
+Единая кнопочная система снижает когнитивную нагрузку. Пользователь перестаёт разгадывать каждый экран и начинает быстро узнавать роли: главное действие, второстепенное действие, отмена, удаление, навигация.
+
+Разделение `Danger` и `Danger subtle` особенно важно для админ-панели. Сейчас `Архивировать` визуально конкурирует с `Удалить`, хотя риск у этих действий разный. После унификации красный solid будет означать только настоящее удаление.
+
+Единая размерная шкала сделает таблицы спокойнее. В строках не будет скачков высоты и плотности, а формы и модалки начнут выглядеть как части одной системы.
+
+Общий primary-цвет вернёт продукту целостность. Ролевые цвета можно сохранить там, где они помогают считывать расписание и принадлежность данных, но базовые действия должны оставаться стабильными.
+
+## 11. Приложение: дополнительные скриншоты
+
+| Раздел | Скриншот |
+|---|---|
+| Конструктор правил |  |
+| Календарный график |  |
+| Просмотр расписаний |  |
+| Аудиторный фонд |  |
+| Группы обучения |  |
+| Структура вуза |  |
+| Дисциплины |  |
+| Пользователи |  |
+| Временные слоты |  |
+| Формы обучения |  |
+| База данных |  |
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index 240625c..bc3f180 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -429,6 +429,11 @@ erDiagram
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
+| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
+| `active_from` | DATE | Дата начала действия группы |
+| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
+| `archived_at` | TIMESTAMP | Дата и время архивирования |
+| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы
| Колонка | Тип | Описание |
@@ -644,6 +649,8 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `version_group_id` | BIGINT | Группа версий одного правила |
| `change_reason` | TEXT | Причина изменения |
+`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
+
#### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание |
|---------|-----|----------|
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 8e7be9d..e3a3a74 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -254,11 +254,13 @@ com.magistr.app/
│ ├── TenantContext.java # ThreadLocal текущего тенанта
│ ├── TenantInterceptor.java # Определение тенанта из Host
│ ├── TenantRoutingDataSource.java # Маршрутизация к БД
-│ ├── TenantDataSourceConfig.java # Spring-конфигурация
+│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
+│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
├── controller/ # REST-контроллеры
│ ├── AuthController.java
+│ ├── GlobalExceptionHandler.java
│ ├── ScheduleController.java
│ ├── ClassroomController.java
│ ├── DatabaseController.java
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index 7eff0c3..d8f54d5 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -138,7 +138,7 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
-| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
+| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` |
@@ -146,13 +146,14 @@ frontend/
### Особенности админских вкладок
-- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
-- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
+- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
+- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
+- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
-- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
+- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
-- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
+- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -316,16 +317,27 @@ CSS-переменные позволяют поддерживать светл
:root {
--bg-primary: #ffffff;
--text-primary: #1a1a2e;
- --accent: #6366f1;
+ --accent: #4F46E5;
}
[data-theme="dark"] {
--bg-primary: #0f0f23;
--text-primary: #e2e8f0;
- --accent: #818cf8;
+ --accent: #8B5CF6;
}
```
+### Система кнопок
+
+Кнопки админ-панели, отдельной SPA настроек, страницы входа и личных кабинетов используют единую семантическую модель:
+
+- `btn` — обязательная база для всех кнопок и ссылок-кнопок.
+- Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий.
+- Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке.
+- Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления.
+- Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`.
+- Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.
+
Переключение — через `theme-toggle.js`.
---
diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md
index ad899bd..0a23cea 100644
--- a/docs/INFRASTRUCTURE.md
+++ b/docs/INFRASTRUCTURE.md
@@ -25,18 +25,21 @@ docker network create proxy
```env
POSTGRES_USER=myuser
-POSTGRES_PASSWORD=supersecretpassword
+POSTGRES_PASSWORD=replace-with-local-password
+POSTGRES_DB=app_db
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
```
-`JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене он задаётся через Kubernetes Secret `app-secret`.
+`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
+
+Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
-1. Этап сборки: `maven:3-eclipse-temurin-17-alpine` → `mvn package`
+1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
### Dockerfile (Frontend)
diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css
index 9722ab7..677c985 100644
--- a/frontend/admin/css/auditorium-workload.css
+++ b/frontend/admin/css/auditorium-workload.css
@@ -576,12 +576,31 @@
gap: 1rem;
}
+.schedule-view-control-card {
+ position: relative;
+ z-index: 10;
+}
+.schedule-view-results-card {
+ position: relative;
+ z-index: 5;
+}
+.schedule-view-table-card {
+ position: relative;
+ z-index: 1;
+}
+
.schedule-view-control-card,
.schedule-view-results-card,
.schedule-view-table-card {
animation-duration: 0.28s;
}
+.schedule-view-results-card {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
.schedule-view-control-header,
.schedule-view-results-header,
.schedule-view-table-header {
@@ -589,6 +608,7 @@
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
+ flex-wrap: wrap;
}
.schedule-view-control-header h2,
@@ -634,32 +654,6 @@
gap: 0.45rem;
}
-.schedule-view-shell .btn-secondary {
- min-height: 42px;
- padding: 0.65rem 0.9rem;
- border: 1px solid var(--bg-card-border);
- border-radius: var(--radius-sm);
- background: var(--bg-input);
- color: var(--text-primary);
- font: inherit;
- font-size: 0.9rem;
- font-weight: 600;
- cursor: pointer;
- transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
-}
-
-.schedule-view-shell .btn-secondary:hover:not(:disabled) {
- background: var(--bg-hover);
- border-color: var(--accent);
- color: var(--accent-hover);
- transform: translateY(-1px);
-}
-
-.schedule-view-shell .btn-secondary:disabled {
- cursor: default;
- opacity: 0.45;
-}
-
.schedule-view-extra-filters {
margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border);
@@ -684,10 +678,11 @@
.schedule-view-result-tabs {
display: flex;
+ flex-wrap: wrap;
gap: 0.55rem;
- margin-top: 1rem;
+ margin-top: 0;
padding-bottom: 0.1rem;
- overflow-x: auto;
+ overflow-x: visible;
}
.schedule-view-result-tab {
@@ -923,8 +918,8 @@
width: 100%;
}
- .schedule-view-period-buttons .btn-secondary,
- .schedule-view-result-actions .btn-secondary {
+ .schedule-view-period-buttons .btn,
+ .schedule-view-result-actions .btn {
flex: 1;
}
diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css
index 623cfeb..444e36c 100755
--- a/frontend/admin/css/components.css
+++ b/frontend/admin/css/components.css
@@ -1,3 +1,45 @@
+/* ===== Button Tokens ===== */
+:root {
+ --button-primary-bg: #8B5CF6;
+ --button-primary-bg-hover: #7C3AED;
+ --button-primary-text: #FFFFFF;
+ --button-secondary-bg: rgba(255, 255, 255, 0.04);
+ --button-secondary-bg-hover: rgba(255, 255, 255, 0.08);
+ --button-secondary-border: rgba(255, 255, 255, 0.10);
+ --button-secondary-text: #F8FAFC;
+ --button-ghost-bg-hover: rgba(255, 255, 255, 0.06);
+ --button-danger-bg: #DC2626;
+ --button-danger-bg-hover: #B91C1C;
+ --button-danger-subtle-bg: rgba(239, 68, 68, 0.13);
+ --button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20);
+ --button-danger-subtle-border: rgba(239, 68, 68, 0.25);
+ --button-danger-subtle-text: #FECACA;
+ --button-focus-ring: rgba(139, 92, 246, 0.45);
+ --button-disabled-bg: rgba(148, 163, 184, 0.16);
+ --button-disabled-text: #94A3B8;
+ --button-radius: 8px;
+}
+
+[data-theme="light"] {
+ --button-primary-bg: #4F46E5;
+ --button-primary-bg-hover: #4338CA;
+ --button-primary-text: #FFFFFF;
+ --button-secondary-bg: #FFFFFF;
+ --button-secondary-bg-hover: #F1F5F9;
+ --button-secondary-border: #CBD5E1;
+ --button-secondary-text: #334155;
+ --button-ghost-bg-hover: #F8FAFC;
+ --button-danger-bg: #DC2626;
+ --button-danger-bg-hover: #B91C1C;
+ --button-danger-subtle-bg: #FEF2F2;
+ --button-danger-subtle-bg-hover: #FEE2E2;
+ --button-danger-subtle-border: #FECACA;
+ --button-danger-subtle-text: #B91C1C;
+ --button-focus-ring: rgba(79, 70, 229, 0.35);
+ --button-disabled-bg: #CBD5E1;
+ --button-disabled-text: #64748B;
+}
+
/* ===== Cards ===== */
.card {
background: var(--bg-card);
@@ -48,6 +90,189 @@
letter-spacing: 0.04em;
}
+/* ===== Dashboard ===== */
+.dashboard-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.dashboard-metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(180px, 1fr));
+ gap: 1.25rem;
+}
+
+.dashboard-metric-card {
+ --metric-color: var(--accent);
+ --metric-bg: rgba(99, 102, 241, 0.1);
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ min-height: 112px;
+ padding: 1.25rem;
+ border-left: 4px solid var(--metric-color);
+}
+
+.dashboard-metric-card--students {
+ --metric-color: #10b981;
+ --metric-bg: rgba(16, 185, 129, 0.1);
+}
+
+.dashboard-metric-card--teachers {
+ --metric-color: #f59e0b;
+ --metric-bg: rgba(245, 158, 11, 0.1);
+}
+
+.dashboard-metric-card--classrooms {
+ --metric-color: #3b82f6;
+ --metric-bg: rgba(59, 130, 246, 0.1);
+}
+
+.dashboard-metric-card .metric-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 48px;
+ width: 48px;
+ height: 48px;
+ color: var(--metric-color);
+ background: var(--metric-bg);
+ border-radius: 12px;
+}
+
+.dashboard-middle {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
+ gap: 1.25rem;
+ align-items: stretch;
+}
+
+.dashboard-monitor-card,
+.dashboard-compact-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.dashboard-monitor-card {
+ min-height: 100%;
+}
+
+.dashboard-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.dashboard-card-header h2 {
+ margin: 0;
+}
+
+.dashboard-time-slot {
+ flex: 0 0 auto;
+ padding: 0.35rem 0.75rem;
+ border-radius: 999px;
+ color: var(--accent-hover);
+ background: var(--bg-hover);
+ font-size: 0.85rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.dashboard-monitor-table {
+ flex: 1;
+}
+
+.dashboard-side-stack {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 1.25rem;
+ min-height: 100%;
+}
+
+.dashboard-compact-card {
+ min-height: 0;
+ padding: 1.25rem;
+ gap: 0.75rem;
+}
+
+.dashboard-card-title {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.dashboard-badge-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.dashboard-actions {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.65rem;
+ margin-top: auto;
+}
+
+.dashboard-action-link {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-height: 40px;
+ padding: 0.6rem 0.85rem;
+ text-align: center;
+ text-decoration: none;
+ font-size: 0.9rem;
+}
+
+.dashboard-action-link.btn-secondary {
+ color: var(--button-secondary-text);
+ background: var(--button-secondary-bg);
+ border: 1px solid var(--button-secondary-border);
+ border-radius: var(--button-radius);
+ box-shadow: none;
+}
+
+.dashboard-action-link.btn-secondary:hover {
+ color: var(--button-secondary-text);
+ background: var(--button-secondary-bg-hover);
+ border-color: var(--button-secondary-border);
+}
+
+@media (max-width: 1200px) {
+ .dashboard-metrics-grid {
+ grid-template-columns: repeat(2, minmax(180px, 1fr));
+ }
+
+ .dashboard-middle {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-side-stack {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: auto;
+ }
+}
+
+@media (max-width: 700px) {
+ .dashboard-metrics-grid,
+ .dashboard-side-stack {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-card-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
+
/* ===== Form Structure ===== */
.form-row {
display: flex;
@@ -167,14 +392,34 @@
gap: 1rem;
}
+.department-workspace-grid {
+ align-items: stretch;
+}
+
+.department-subject-import-card,
+.department-teachers-card {
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+ min-height: 100%;
+}
+
+.department-teachers-card .card-header-row {
+ margin-bottom: 0.85rem;
+}
+
+.department-teachers-card .metric-grid {
+ margin-top: 0;
+}
+
.metric-grid {
display: grid;
- gap: 0.75rem;
+ gap: 0.6rem;
}
.metric-card,
.department-comment-item {
- padding: 0.85rem;
+ padding: 0.8rem 0.85rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
@@ -183,7 +428,7 @@
.metric-card strong,
.department-comment-item strong {
display: block;
- margin-bottom: 0.35rem;
+ margin-bottom: 0.28rem;
color: var(--text-primary);
}
@@ -194,10 +439,6 @@
color: var(--text-secondary);
}
-.department-workload-list {
- margin-top: 1rem;
-}
-
.department-comment-list {
display: grid;
gap: 0.75rem;
@@ -262,6 +503,8 @@
.schedule-slots-panel {
margin-top: 1rem;
+ margin-bottom: 1.25rem;
+ min-width: 0;
}
.schedule-slots-list {
@@ -269,6 +512,7 @@
flex-direction: column;
gap: 0.75rem;
margin-top: 0.75rem;
+ overflow: visible;
}
.schedule-slot-row {
@@ -282,10 +526,98 @@
border-radius: var(--radius-sm);
}
+.schedule-slot-row:last-child {
+ margin-bottom: 0;
+}
+
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
+.subject-import-rows-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+ overflow: visible;
+}
+
+.subject-import-row {
+ display: grid;
+ grid-template-columns: 160px minmax(260px, 1fr) auto;
+ column-gap: 0.65rem;
+ row-gap: 0.75rem;
+ align-items: end;
+ padding: 0.75rem;
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+}
+
+.schedule-rule-actions {
+ margin-top: 0.25rem;
+ clear: both;
+}
+
+.schedule-rule-table-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 188px;
+}
+
+.schedule-rule-table-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 86px;
+ min-height: 32px;
+ padding: 0.38rem 0.72rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.82rem;
+ font-weight: 700;
+ line-height: 1.1;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition);
+}
+
+.schedule-rule-table-button:hover,
+.schedule-rule-table-button:focus-visible {
+ outline: none;
+ transform: translateY(-1px);
+}
+
+.schedule-rule-table-button-edit {
+ border-color: color-mix(in srgb, var(--accent) 34%, var(--bg-card-border));
+ background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
+ color: var(--accent-hover);
+}
+
+.schedule-rule-table-button-edit:hover,
+.schedule-rule-table-button-edit:focus-visible {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 22%, var(--bg-input));
+ box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-glow) 60%, transparent);
+}
+
+.schedule-rule-table-button-delete {
+ border-color: color-mix(in srgb, var(--error) 34%, var(--bg-card-border));
+ background: color-mix(in srgb, var(--error) 10%, var(--bg-input));
+ color: var(--error);
+}
+
+.schedule-rule-table-button-delete:hover,
+.schedule-rule-table-button-delete:focus-visible {
+ border-color: var(--error);
+ background: color-mix(in srgb, var(--error) 18%, var(--bg-input));
+ box-shadow: 0 4px 14px rgba(248, 113, 113, 0.14);
+}
+
.slot-subgroup-multi .select-text {
overflow: hidden;
text-overflow: ellipsis;
@@ -296,22 +628,532 @@
min-width: 260px;
}
-.schedule-slot-summary,
-.schedule-semester-line {
+.schedule-slot-summary {
margin-bottom: 0.35rem;
}
-.schedule-semester-line {
- display: flex;
+.academic-year-semesters {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
+ gap: 0.35rem;
+ align-items: stretch;
+}
+
+.academic-years-table tbody td {
+ padding-top: 0.6rem;
+ padding-bottom: 0.6rem;
+ vertical-align: middle;
+}
+
+.academic-years-table thead th:first-child,
+.academic-years-table thead th:nth-child(2),
+.academic-years-table thead th:last-child {
+ text-align: center;
+}
+
+.academic-year-center-cell {
+ text-align: center;
+ font-weight: 700;
+}
+
+.academic-year-semesters-cell {
+ min-width: 520px;
+ vertical-align: middle;
+}
+
+.academic-year-actions-cell {
+ text-align: center;
+ vertical-align: middle;
+}
+
+.academic-year-actions {
+ display: inline-flex;
align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
+ justify-content: center;
+ gap: 0.4rem;
+ min-height: 100%;
+ white-space: nowrap;
+}
+
+.academic-year-semester-item {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) auto;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.5rem 0.65rem;
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+}
+
+.subject-import-code-field,
+.subject-import-name-field {
+ min-width: 0;
+}
+
+.subject-import-row .form-group label {
+ margin-bottom: 0.45rem;
+}
+
+@media (max-width: 900px) {
+ .academic-year-semesters {
+ grid-template-columns: 1fr;
+ }
+
+ .academic-year-semesters-cell {
+ min-width: 360px;
+ }
+}
+
+.academic-year-semester-meta {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.academic-year-semester-type {
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
+.academic-year-semester-period {
+ color: var(--text-secondary);
+ font-size: 0.82rem;
}
.schedule-inline-table {
margin-top: 1rem;
}
+.schedule-visual-handle {
+ position: fixed;
+ top: 50%;
+ right: 0;
+ z-index: 905;
+ width: 40px;
+ height: 40px;
+ border: 1px solid var(--button-secondary-border);
+ border-right: 0;
+ border-radius: var(--button-radius) 0 0 var(--button-radius);
+ background: var(--button-secondary-bg);
+ color: var(--button-secondary-text);
+ box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
+ transform: translateY(-50%);
+ transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
+}
+
+.schedule-visual-handle:hover,
+.schedule-visual-handle.open {
+ border-color: var(--button-secondary-border);
+ background: var(--button-secondary-bg-hover);
+ color: var(--button-secondary-text);
+}
+
+.schedule-visual-handle.open {
+ right: min(92vw, 1180px);
+}
+
+.schedule-visual-handle.open .schedule-visual-handle-icon {
+ transform: rotate(180deg);
+}
+
+.schedule-visual-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 880;
+ background: rgba(0, 0, 0, 0.24);
+ backdrop-filter: blur(2px);
+}
+
+.schedule-visual-drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 900;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ width: min(92vw, 1180px);
+ height: 100vh;
+ padding: 1rem;
+ background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-card));
+ border-left: 1px solid var(--bg-card-border);
+ box-shadow: -22px 0 55px rgba(0, 0, 0, 0.34);
+ transform: translateX(100%);
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.schedule-visual-drawer.open {
+ transform: translateX(0);
+}
+
+.schedule-visual-header {
+ display: grid;
+ gap: 1rem;
+ padding-bottom: 0.85rem;
+ border-bottom: 1px solid var(--bg-card-border);
+}
+
+.schedule-visual-header-main,
+.schedule-visual-filter-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.schedule-visual-header h2 {
+ margin: 0 0 0.25rem;
+ color: var(--text-primary);
+ font-size: 1rem;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.schedule-visual-toolbar {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.schedule-visual-period-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(180px, 1fr));
+ gap: 0.65rem;
+ align-items: end;
+}
+
+.schedule-visual-period-grid .form-group {
+ min-width: 0;
+}
+
+.schedule-visual-filter-head {
+ align-items: center;
+}
+
+.schedule-visual-filter-head>span {
+ color: var(--text-secondary);
+ font-size: 0.74rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.schedule-visual-filter-head div {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.4rem;
+}
+
+.schedule-visual-group-filter {
+ display: flex;
+ gap: 0.4rem;
+ overflow-x: auto;
+ padding-bottom: 0.15rem;
+}
+
+.schedule-visual-group-chip {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 30px;
+ padding: 0.25rem 0.58rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition), color var(--transition);
+}
+
+.schedule-visual-group-chip:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.schedule-visual-group-chip input {
+ width: 0.9rem;
+ height: 0.9rem;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.schedule-visual-group-chip:has(input:checked) {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-input));
+ color: var(--text-primary);
+}
+
+.schedule-visual-stage {
+ min-height: 0;
+ padding-top: 0.85rem;
+ overflow: hidden;
+}
+
+.schedule-visual-state {
+ height: 100%;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+}
+
+.schedule-visual-table-wrap {
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+}
+
+.schedule-visual-table {
+ min-width: 760px;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.schedule-visual-table th,
+.schedule-visual-table td {
+ border: 1px solid var(--bg-card-border);
+}
+
+.schedule-visual-table th {
+ position: sticky;
+ top: 0;
+ z-index: 6;
+ min-width: 168px;
+ padding: 0.52rem;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ text-align: center;
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.schedule-visual-table .schedule-visual-day-head,
+.schedule-visual-table .schedule-visual-time-head {
+ z-index: 8;
+ min-width: 96px;
+ width: 96px;
+}
+
+.schedule-visual-table .schedule-visual-day-head {
+ left: 0;
+}
+
+.schedule-visual-table .schedule-visual-time-head {
+ left: 96px;
+}
+
+.schedule-visual-day-cell,
+.schedule-visual-time-cell {
+ position: sticky;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ box-shadow: 1px 0 0 var(--bg-card-border);
+}
+
+.schedule-visual-day-cell {
+ left: 0;
+ z-index: 4;
+ width: 96px;
+ min-width: 96px;
+ padding: 0.52rem;
+ vertical-align: top;
+ font-weight: 800;
+ font-size: 0.74rem;
+ line-height: 1.15;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ hyphens: auto;
+}
+
+.schedule-visual-time-cell {
+ left: 96px;
+ z-index: 4;
+ width: 96px;
+ min-width: 96px;
+ padding: 0.48rem;
+ vertical-align: middle;
+}
+
+.schedule-visual-time-cell strong,
+.schedule-visual-time-cell span {
+ display: block;
+ line-height: 1.15;
+}
+
+.schedule-visual-time-cell span {
+ margin-top: 0.2rem;
+ color: var(--text-secondary);
+ font-size: 0.66rem;
+}
+
+.schedule-visual-lesson-cell {
+ height: 1px;
+ min-width: 168px;
+ padding: 0 !important;
+ vertical-align: stretch;
+ background: var(--bg-primary);
+}
+
+.schedule-visual-lesson-cell-empty {
+ background: color-mix(in srgb, var(--bg-input) 42%, var(--bg-primary));
+}
+
+.schedule-visual-unified {
+ min-height: 96px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.22rem;
+ padding: 0.34rem;
+}
+
+.schedule-visual-unified {
+ justify-content: flex-start;
+ background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
+}
+
+.schedule-visual-half {
+ position: relative;
+ min-height: 56px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.22rem;
+ padding: 1.28rem 0.34rem 0.34rem;
+ background: color-mix(in srgb, var(--accent) 13%, var(--bg-primary));
+}
+
+.schedule-visual-half+.schedule-visual-half {
+ border-top: 1px solid var(--bg-card-border);
+}
+
+.schedule-visual-half-even {
+ background: color-mix(in srgb, var(--success) 8%, var(--bg-primary));
+}
+
+.schedule-visual-half-label {
+ position: absolute;
+ top: 0.35rem;
+ left: 0.45rem;
+ max-width: calc(100% - 0.9rem);
+ padding: 0.1rem 0.4rem;
+ border-radius: 999px;
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ font-size: 0.58rem;
+ font-weight: 800;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.schedule-visual-half-odd .schedule-visual-half-label {
+ color: var(--accent-hover);
+}
+
+.schedule-visual-half-even .schedule-visual-half-label {
+ color: var(--success);
+}
+
+.schedule-visual-lesson {
+ position: relative;
+ display: grid;
+ gap: 0.12rem;
+ padding: 0.34rem 1.65rem 0.34rem 0.34rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
+}
+
+.schedule-visual-lesson strong {
+ color: var(--text-primary);
+ font-size: 0.7rem;
+ line-height: 1.18;
+}
+
+.schedule-visual-lesson strong span {
+ color: var(--accent-hover);
+ font-weight: 800;
+}
+
+.schedule-visual-lesson small {
+ color: var(--text-secondary);
+ font-size: 0.6rem;
+ line-height: 1.14;
+}
+
+.schedule-visual-edit-rule {
+ position: absolute;
+ top: 0.26rem;
+ right: 0.26rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.1rem;
+ height: 1.1rem;
+ padding: 0;
+ border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
+ color: var(--accent-hover);
+ font: inherit;
+ font-size: 0.82rem;
+ font-weight: 800;
+ line-height: 1;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
+}
+
+.schedule-visual-edit-rule:hover,
+.schedule-visual-edit-rule:focus-visible {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
+ color: var(--text-primary);
+ outline: none;
+ transform: translateY(-1px);
+}
+
+.schedule-visual-free {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100%;
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+}
+
+@media (max-width: 640px) {
+ .schedule-visual-drawer {
+ width: 100vw;
+ padding: 0.75rem;
+ }
+
+ .schedule-visual-handle.open {
+ right: calc(100vw - 42px);
+ }
+
+ .schedule-visual-header {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .schedule-visual-header-main,
+ .schedule-visual-filter-head {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .schedule-visual-period-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
.calendar-fill-panel {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
@@ -391,20 +1233,23 @@
}
.calendar-semesters-grid {
- --calendar-semester-block-height: 194px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-auto-rows: 1fr;
+ grid-auto-rows: auto;
align-items: stretch;
gap: 0.65rem;
padding: 0.65rem;
+ overflow-x: hidden;
+ overflow-y: hidden;
}
.calendar-semester-block {
display: flex;
flex-direction: column;
- height: var(--calendar-semester-block-height);
- min-height: 100%;
+ width: 100%;
+ max-width: 100%;
+ height: auto;
+ min-height: 0;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
@@ -433,11 +1278,11 @@
}
.calendar-semester-table-wrap {
- flex: 1;
+ flex: 1 1 auto;
display: flex;
- min-height: 0;
+ min-height: 151px;
overflow-x: auto;
- overflow-y: hidden;
+ overflow-y: visible;
background: var(--bg-primary);
scrollbar-width: none;
}
@@ -448,9 +1293,14 @@
}
.calendar-semester-table {
- width: max-content;
- min-width: 100%;
- height: 100%;
+ --calendar-day-head-width: 32px;
+ --calendar-day-cell-size: 22px;
+ --calendar-min-table-width: 538px;
+ width: 100%;
+ min-width: var(--calendar-min-table-width);
+ max-width: none;
+ min-height: 100%;
+ flex: 1 1 auto;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.62rem;
@@ -459,32 +1309,43 @@
.calendar-semester-table th,
.calendar-semester-table td {
+ box-sizing: border-box;
border: 1px solid var(--bg-card-border);
}
.calendar-semester-table tbody tr {
- height: 19px;
+ height: var(--calendar-day-cell-size);
+}
+
+.calendar-day-head-col {
+ width: var(--calendar-day-head-width);
+}
+
+.calendar-week-col {
+ width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1));
}
.calendar-semester-table thead th {
position: sticky;
top: 0;
z-index: 1;
- height: 18px;
- min-width: 18px;
- padding: 0 0.12rem;
+ height: var(--calendar-day-cell-size);
+ padding: 0;
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 700;
text-align: center;
+ line-height: 1;
+ white-space: nowrap;
}
.calendar-semester-table .calendar-day-head {
position: sticky;
left: 0;
z-index: 2;
- width: 30px;
- min-width: 30px;
+ width: var(--calendar-day-head-width);
+ min-width: var(--calendar-day-head-width);
+ max-width: var(--calendar-day-head-width);
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 700;
@@ -493,10 +1354,8 @@
.calendar-day {
position: relative;
- width: 18px;
- min-width: 18px;
- height: 19px;
- min-height: 19px;
+ height: var(--calendar-day-cell-size);
+ min-height: var(--calendar-day-cell-size);
padding: 0;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card));
border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border));
@@ -607,8 +1466,7 @@
width: 34px;
min-width: 34px;
height: 34px;
- display: inline-grid;
- place-items: center;
+ box-sizing: border-box;
border-radius: 8px;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
@@ -616,10 +1474,27 @@
font-size: 0.86rem;
font-weight: 800;
line-height: 1;
- padding-top: 2px;
+ padding: 0;
text-align: center;
}
+.calendar-day-tooltip-head .calendar-day-tooltip-code {
+ display: inline-grid;
+ place-items: center;
+}
+
+.calendar-day-tooltip-code span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ color: inherit;
+ font: inherit;
+ line-height: 1;
+ transform: none;
+}
+
.calendar-day-tooltip-body {
display: grid;
gap: 0.45rem;
@@ -627,12 +1502,14 @@
}
.calendar-empty-day {
- width: 18px;
- min-width: 18px;
- height: 19px;
+ height: var(--calendar-day-cell-size);
background: color-mix(in srgb, var(--bg-input) 65%, transparent);
}
+.calendar-week-placeholder {
+ color: transparent;
+}
+
.calendar-empty-semester {
flex: 1;
display: flex;
@@ -784,6 +1661,7 @@
}
@media (max-width: 1180px) {
+
.schedule-view-filter-grid,
.schedule-view-metrics,
.department-workspace-grid {
@@ -806,6 +1684,8 @@
.calendar-semesters-grid {
grid-template-columns: 1fr;
grid-auto-rows: auto;
+ overflow-x: auto;
+ scrollbar-width: thin;
}
.calendar-semester-block {
@@ -830,6 +1710,10 @@
grid-template-columns: 1fr;
}
+ .subject-import-row {
+ grid-template-columns: 1fr;
+ }
+
.calendar-fill-panel {
grid-template-columns: 1fr;
}
@@ -865,8 +1749,12 @@ input[type="number"] {
}
.select-layer-active {
- position: relative;
- z-index: 300;
+ position: relative !important;
+ z-index: 10001 !important;
+}
+
+.custom-select-wrapper.open {
+ z-index: 10002 !important;
}
.custom-select-trigger {
@@ -886,9 +1774,11 @@ input[type="number"] {
}
.filter-row .custom-select-trigger {
+ height: 38px;
+ box-sizing: border-box;
padding: 0.45rem 1rem;
font-size: 0.85rem;
- border-color: transparent;
+ border-color: var(--bg-card-border);
}
.custom-select-trigger:hover {
@@ -964,18 +1854,45 @@ input[type="number"] {
transform: translateY(0) scale(1);
}
+.custom-select-search-container {
+ padding: 0.25rem 0.5rem 0.5rem 0.5rem;
+ border-bottom: 1px solid var(--bg-card-border);
+ margin-bottom: 0.35rem;
+}
+
+.custom-select-search-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 0.85rem;
+ outline: none;
+ transition: all var(--transition);
+}
+
+.custom-select-search-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
/* 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);
}
@@ -1017,7 +1934,8 @@ input[type="number"] {
}
.custom-select-item.placeholder-item {
- display: none; /* Hide placeholder options in the actual dropdown list naturally */
+ display: none;
+ /* Hide placeholder options in the actual dropdown list naturally */
}
/* Light theme selects */
@@ -1035,6 +1953,7 @@ input[type="number"] {
[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);
}
@@ -1060,7 +1979,8 @@ input[type="number"] {
.filter-row {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 0.75rem;
+ flex-wrap: wrap;
}
.filter-row label {
@@ -1073,12 +1993,15 @@ input[type="number"] {
}
.filter-row input {
- padding: 0.45rem 2rem 0.45rem 0.7rem;
+ height: 38px;
+ box-sizing: border-box;
+ padding: 0.45rem 0.75rem;
background: var(--bg-input);
- border: 1px solid transparent;
+ border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.85rem;
+ outline: none;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
@@ -1177,14 +2100,17 @@ input[type="number"] {
.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);
}
@@ -1192,6 +2118,7 @@ input[type="number"] {
[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);
}
@@ -1237,15 +2164,15 @@ input[type="number"] {
justify-content: center;
}
-.checkbox-item:hover input ~ .checkmark {
+.checkbox-item:hover input~.checkmark {
border-color: var(--accent);
}
-.checkbox-item input:focus ~ .checkmark {
+.checkbox-item input:focus~.checkmark {
box-shadow: 0 0 0 3px var(--accent-glow);
}
-.checkbox-item input:checked ~ .checkmark {
+.checkbox-item input:checked~.checkmark {
background-color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
@@ -1262,113 +2189,227 @@ input[type="number"] {
margin-bottom: 2px;
}
-.checkbox-item input:checked ~ .checkmark::after {
+.checkbox-item input:checked~.checkmark::after {
display: block;
}
/* ===== Buttons ===== */
-.btn-primary {
- position: relative;
- overflow: hidden;
- padding: 0.75rem 1.75rem;
- background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
- border: none;
- border-radius: var(--radius-sm);
- color: #fff;
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
+ min-width: 0;
+ border: 1px solid transparent;
+ border-radius: var(--button-radius);
+ background: transparent;
+ color: inherit;
font-family: inherit;
- font-size: 0.95rem;
+ font-size: 0.875rem;
font-weight: 600;
- letter-spacing: 0.02em;
- cursor: pointer;
+ line-height: 1;
+ text-align: center;
+ text-decoration: none;
white-space: nowrap;
- transition: all var(--transition);
- box-shadow: 0 4px 15px var(--accent-glow);
+ cursor: pointer;
+ user-select: none;
+ transition:
+ background var(--transition),
+ border-color var(--transition),
+ color var(--transition),
+ box-shadow var(--transition),
+ transform var(--transition);
}
-.btn-primary::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: linear-gradient(rgba(255, 255, 255, 0.2), transparent);
- border-radius: inherit;
- opacity: 0;
- transition: opacity var(--transition);
+.btn:hover:not(:disabled) {
+ transform: translateY(-1px);
}
-.btn-primary:hover {
- transform: translateY(-2px);
- box-shadow: 0 8px 25px var(--accent-glow);
+.btn:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--button-focus-ring);
}
-.btn-primary:hover::before {
- opacity: 1;
+.btn:active:not(:disabled) {
+ transform: translateY(0);
}
-.btn-primary:active {
- transform: translateY(1px);
- box-shadow: 0 2px 10px var(--accent-glow);
+.btn:disabled,
+.btn[aria-disabled="true"] {
+ background: var(--button-disabled-bg);
+ border-color: transparent;
+ color: var(--button-disabled-text);
+ cursor: not-allowed;
+ opacity: 0.75;
+ transform: none;
}
-.btn-delete {
- padding: 0.35rem 0.7rem;
- background: rgba(248, 113, 113, 0.1);
- border: 1px solid rgba(248, 113, 113, 0.2);
- border-radius: var(--radius-sm);
- color: var(--error);
+.btn-sm {
+ min-height: 32px;
+ padding: 0 12px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.btn-md {
+ min-height: 40px;
+ padding: 0 16px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.btn-lg {
+ min-height: 48px;
+ padding: 0 20px;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.btn-icon-sm,
+.btn-icon-md {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ padding: 0;
+ border-radius: var(--button-radius);
+ line-height: 1;
+}
+
+.btn-icon-sm {
+ width: 32px;
+ height: 32px;
+}
+
+.btn-icon-md {
+ width: 40px;
+ height: 40px;
+}
+
+.btn-icon-sm svg {
+ width: 18px;
+ height: 18px;
+}
+
+.btn-icon-md svg {
+ width: 20px;
+ height: 20px;
+}
+
+.btn-primary {
+ background: var(--button-primary-bg);
+ border-color: transparent;
+ color: var(--button-primary-text);
+ box-shadow: none;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--button-primary-bg-hover);
+ color: var(--button-primary-text);
+}
+
+.btn-secondary {
+ background: var(--button-secondary-bg);
+ border-color: var(--button-secondary-border);
+ color: var(--button-secondary-text);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--button-secondary-bg-hover);
+ border-color: var(--button-secondary-border);
+ color: var(--button-secondary-text);
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+ color: var(--text-secondary);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: var(--button-ghost-bg-hover);
+ color: var(--text-primary);
+}
+
+.btn-danger {
+ background: var(--button-danger-bg);
+ border-color: transparent;
+ color: #FFFFFF;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: var(--button-danger-bg-hover);
+ color: #FFFFFF;
+}
+
+.btn-danger-subtle {
+ background: var(--button-danger-subtle-bg);
+ border-color: var(--button-danger-subtle-border);
+ color: var(--button-danger-subtle-text);
+}
+
+.btn-danger-subtle:hover:not(:disabled) {
+ background: var(--button-danger-subtle-bg-hover);
+ border-color: var(--button-danger-subtle-border);
+ color: var(--button-danger-subtle-text);
+}
+
+.group-actions-cell {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.group-actions {
+ display: grid;
+ grid-template-columns: 88px 104px 104px 116px;
+ gap: 0.35rem;
+ justify-content: end;
+ align-items: center;
+}
+
+.group-action-btn {
+ width: 100%;
+}
+
+.btn-restore-group {
+ min-height: 32px;
+ padding: 0 12px;
+ border-radius: var(--button-radius);
font-family: inherit;
- font-size: 0.8rem;
+ font-size: 13px;
+ font-weight: 600;
cursor: pointer;
transition: background var(--transition), transform var(--transition);
position: relative;
overflow: hidden;
}
-.btn-delete:hover {
- background: rgba(248, 113, 113, 0.2);
+.btn-restore-group {
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.24);
+ color: var(--warning);
+}
+
+.btn-restore-group:hover {
+ background: rgba(245, 158, 11, 0.2);
transform: scale(1.05);
}
.btn-icon-toggle {
- background: transparent;
- border: 1px solid var(--bg-card-border);
- color: var(--text-secondary);
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- padding: 0;
+ width: 32px;
+ height: 32px;
}
.btn-icon-toggle:hover {
- background: var(--bg-card);
- border-color: var(--accent);
- color: var(--accent);
- transform: rotate(45deg);
- box-shadow: 0 0 10px var(--accent-glow);
-}
-
-.btn-edit-classroom {
- padding: 0.35rem 0.7rem;
- background: rgba(99, 102, 241, 0.1);
- border: 1px solid rgba(99, 102, 241, 0.2);
- border-radius: var(--radius-sm);
- color: var(--accent-hover);
- cursor: pointer;
- transition: all var(--transition);
-}
-
-.btn-edit-classroom:hover {
- background: rgba(99, 102, 241, 0.2);
transform: translateY(-1px);
}
+.group-actions .group-action-btn {
+ min-height: 32px;
+ padding: 0 12px;
+ font-size: 13px;
+}
+
/* ===== Alerts ===== */
.form-alert {
display: none;
@@ -1740,20 +2781,19 @@ tbody tr:hover {
/* ===== 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;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--button-radius);
+ background: var(--button-secondary-bg);
+ border: 1px solid var(--button-secondary-border);
+ color: var(--button-secondary-text);
+ display: inline-flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
- transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
+ padding: 0;
+ transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
z-index: 100;
flex-shrink: 0;
}
@@ -1765,12 +2805,18 @@ tbody tr:hover {
}
.theme-toggle:hover {
- transform: scale(1.1);
- box-shadow: 0 4px 16px var(--accent-glow);
+ background: var(--button-secondary-bg-hover);
+ border-color: var(--button-secondary-border);
+ transform: translateY(-1px);
}
.theme-toggle:active {
- transform: scale(0.95);
+ transform: translateY(0);
+}
+
+.theme-toggle:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--button-focus-ring);
}
.theme-toggle--fixed {
@@ -1778,3 +2824,15 @@ tbody tr:hover {
top: 1.25rem;
right: 1.25rem;
}
+
+.lesson-weeks-badge {
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: 4px;
+ padding: 0.05rem 0.35rem;
+ white-space: nowrap;
+ line-height: 1.2;
+}
diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css
index 3265a1a..57bbc88 100755
--- a/frontend/admin/css/layout.css
+++ b/frontend/admin/css/layout.css
@@ -25,15 +25,7 @@
}
.sidebar-close-btn {
- background: transparent;
- border: none;
color: var(--text-secondary);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.25rem;
- border-radius: var(--radius-sm);
transition: all var(--transition);
}
@@ -355,12 +347,7 @@
/* ===== Mobile Menu Toggle ===== */
.menu-toggle {
display: none;
- padding: 0.4rem;
- background: none;
- border: none;
color: var(--text-primary);
- cursor: pointer;
- border-radius: var(--radius-sm);
transition: background var(--transition);
}
diff --git a/frontend/admin/css/main.css b/frontend/admin/css/main.css
index a03d06e..cbfb555 100755
--- a/frontend/admin/css/main.css
+++ b/frontend/admin/css/main.css
@@ -51,9 +51,9 @@
--text-primary: #0f172a;
--text-secondary: #475569;
--text-placeholder: #94a3b8;
- --accent: #6366f1;
- --accent-hover: #4f46e5;
- --accent-glow: rgba(99, 102, 241, 0.3);
+ --accent: #4F46E5;
+ --accent-hover: #4338CA;
+ --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef;
--error: #ef4444;
diff --git a/frontend/admin/index.html b/frontend/admin/index.html
index 99d71b2..6327494 100755
--- a/frontend/admin/index.html
+++ b/frontend/admin/index.html
@@ -16,6 +16,19 @@
+
@@ -36,79 +49,24 @@
Magistr
-