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. Авторизация + +![Экран авторизации](../audit-buttons-01-login.png) + +Найдены кнопки: + +| Кнопка | Текущая роль | Комментарий | +|---|---|---| +| `Войти` | Primary | Корректно считывается как главное действие экрана. | +| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. | +| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. | + +Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру. + +### 3.2. Дашборд админки + +![Дашборд админки](../audit-buttons-02-dashboard.png) + +Найдены кнопки и ссылки-кнопки: + +| Кнопка | Текущий вид | Проблема | +|---|---|---| +| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. | +| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. | +| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. | +| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. | +| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. | + +Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию. + +### 3.3. Формы и таблицы админки + +![Форма пользователей](../audit-buttons-03-users-form.png) + +Типичные кнопки: + +| Кнопка | Смысл | Текущее впечатление | +|---|---|---| +| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. | +| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. | +| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. | +| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. | + +Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках. + +### 3.4. Меню настроек и выход + +![Меню настроек](../audit-buttons-04-settings-menu.png) + +Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль. + +Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки. + +### 3.5. Отдельная SPA настроек + +![Общие настройки](../audit-buttons-05-settings-general.png) + +В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему. + +Пример различий: + +| Класс | Основная админка | Настройки | +|---|---|---| +| `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. Кабинеты преподавателя и студента + +![Кабинет преподавателя](../audit-buttons-06-teacher.png) + +![Кабинет студента](../audit-buttons-07-student.png) + +В публичных кабинетах используется отдельная светлая система: + +| Элемент | Преподаватель | Студент | +|---|---|---| +| `Сегодня` | 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. Модальные окна + +![Модальное окно создания ручной сетки](../audit-buttons-08-modal.png) + +Найдены: + +| Кнопка | Роль | Рекомендация | +|---|---|---| +| `Создать сетку` | 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. Приложение: дополнительные скриншоты + +| Раздел | Скриншот | +|---|---| +| Конструктор правил | ![Конструктор правил](../audit-buttons-rules.png) | +| Календарный график | ![Календарный график](../audit-buttons-calendar.png) | +| Просмотр расписаний | ![Просмотр расписаний](../audit-buttons-schedule-view.png) | +| Аудиторный фонд | ![Аудиторный фонд](../audit-buttons-classrooms.png) | +| Группы обучения | ![Группы обучения](../audit-buttons-groups.png) | +| Структура вуза | ![Структура вуза](../audit-buttons-structure.png) | +| Дисциплины | ![Дисциплины](../audit-buttons-subjects.png) | +| Пользователи | ![Пользователи](../audit-buttons-users.png) | +| Временные слоты | ![Временные слоты](../audit-buttons-settings-time-slots.png) | +| Формы обучения | ![Формы обучения](../audit-buttons-settings-study-forms.png) | +| База данных | ![База данных](../audit-buttons-settings-database.png) | 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 - `).join(''); } @@ -667,6 +1189,23 @@ export async function initSchedule() { return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; } + function orderedLessonTypes() { + return [...lessonTypes].sort(compareLessonTypes); + } + + function compareLessonTypes(first, second) { + return lessonTypeRank(first) - lessonTypeRank(second) + || naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label); + } + + function lessonTypeRank(lessonType) { + const key = lessonTypeKey(lessonType); + if (key === 'lecture') return 0; + if (key === 'laboratory') return 1; + if (key === 'practice') return 2; + return 3; + } + function lessonTypeKeyById(lessonTypeId) { const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); return lessonType ? lessonTypeKey(lessonType) : null; @@ -693,6 +1232,11 @@ export async function initSchedule() { return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; } + function shortSemesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`; + } + function formatSemester(rule) { const type = normalizeSemesterType(rule.semesterType); return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; @@ -712,4 +1256,149 @@ export async function initSchedule() { select.dispatchEvent(new Event('change', { bubbles: true })); }); } + + function visualCellKey(groupId, dayOfWeek, slotKey) { + return `${groupId}:${dayOfWeek}:${slotKey}`; + } + + function visualSlotKey(slot) { + return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown'); + } + + function visualRuleSlotKey(slot, index) { + return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`); + } + + function activeWeekKey(ruleId, groupId, slotKey) { + return `${ruleId}:${groupId}:${slotKey}`; + } + + function slotAppliesToWeek(slot, week) { + if (!slot.parity || slot.parity === 'BOTH') return true; + const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD'; + return slot.parity === weekParity; + } + + function maxWeeksForRule(rule) { + const semester = semesters.find(item => String(item.id) === String(rule.semesterId)); + if (semester?.startDate && semester?.endDate) { + const start = parseIsoDate(semester.startDate); + const end = parseIsoDate(semester.endDate); + const days = Math.max(1, Math.round((end - start) / 86400000) + 1); + return Math.max(1, Math.ceil(days / 7)); + } + return 18; + } + + function parseIsoDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, month - 1, day); + } + + function compactWeekRanges(weeks, maxWeeks, parity) { + const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b); + if (!uniqueWeeks.length) return ''; + const start = uniqueWeeks[0]; + const end = uniqueWeeks[uniqueWeeks.length - 1]; + const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity); + const semesterStartWeek = firstApplicableSemesterWeek(parity); + if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return ''; + if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`; + return weekRangeText(start, end); + } + + function weekRangeText(start, end) { + return start === end + ? `(${start} нед.)` + : `(с ${start} по ${end} нед.)`; + } + + function firstApplicableSemesterWeek(parity) { + return parity === 'EVEN' ? 2 : 1; + } + + function lastApplicableSemesterWeek(maxWeeks, parity) { + const totalWeeks = Number(maxWeeks || 0); + if (!totalWeeks) return 0; + if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1; + if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1; + return totalWeeks; + } + + function compareSlotItems(first, second) { + return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0) + || compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot)) + || Number(first.index || 0) - Number(second.index || 0); + } + + function slotDescriptor(slot) { + return { + key: visualSlotKey(slot), + orderNumber: slot.timeSlotOrder, + timeLabel: slot.timeSlotLabel + }; + } + + function compareVisualSlots(first, second) { + const firstOrder = Number(first.orderNumber || first.key); + const secondOrder = Number(second.orderNumber || second.key); + if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) { + return firstOrder - secondOrder; + } + return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key); + } + + function compareVisualLessons(first, second) { + return naturalCompare(first.subjectName, second.subjectName) + || naturalCompare(first.weekText, second.weekText) + || naturalCompare(first.lessonTypeName, second.lessonTypeName) + || naturalCompare(first.teacherName, second.teacherName); + } + + function visualLessonSignature(lessons) { + return lessons + .map(lesson => [ + lesson.id, + lesson.subjectName, + lesson.weekText, + lesson.lessonTypeName, + lesson.teacherName, + lesson.classroomName, + lesson.subgroupNames.join(',') + ].join(':')) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function groupNameById(id) { + return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; + } + + function naturalCompare(a, b) { + return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' }); + } + + function ruleCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return 'правил'; + if (count % 10 === 1) return 'правило'; + if ([2, 3, 4].includes(count % 10)) return 'правила'; + return 'правил'; + } + + function groupCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return 'групп'; + if (count % 10 === 1) return 'группа'; + if ([2, 3, 4].includes(count % 10)) return 'группы'; + return 'групп'; + } + + function rowCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return 'строк'; + if (count % 10 === 1) return 'строка'; + if ([2, 3, 4].includes(count % 10)) return 'строки'; + return 'строк'; + } } diff --git a/frontend/admin/js/views/subjects.js b/frontend/admin/js/views/subjects.js index c31abbe..aee5f5a 100755 --- a/frontend/admin/js/views/subjects.js +++ b/frontend/admin/js/views/subjects.js @@ -4,160 +4,260 @@ import { escapeHtml, showAlert, hideAlert } from '../utils.js'; export async function initSubjects() { const subjectsTbody = document.getElementById('subjects-tbody'); const createSubjectForm = document.getElementById('create-subject-form'); + const newSubjectDeptSelect = document.getElementById('new-subject-department'); - const assignTeacherForm = document.getElementById('assign-teacher-form'); - const assignTeacherSelect = document.getElementById('assign-teacher-select'); - const assignSubjectSelect = document.getElementById('assign-subject-select'); - const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody'); + // Модалка управления преподавателями + const modalManageTeachers = document.getElementById('modal-manage-teachers'); + const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close'); + const subjectTeachersContext = document.getElementById('subject-teachers-context'); + const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form'); + const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id'); + const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select'); + const manageTsTbody = document.getElementById('manage-ts-tbody'); let allSubjects = []; let allTeachers = []; + let allDepartments = []; + let teacherSubjects = []; + + bindEvents(); + await loadInitialData(); + + function bindEvents() { + createSubjectForm.addEventListener('submit', createSubject); + subjectsTbody.addEventListener('click', handleSubjectsTableClick); + + manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment); + manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick); + + modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open')); + modalManageTeachers.addEventListener('click', (e) => { + if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open'); + }); + } async function loadInitialData() { - await Promise.all([loadSubjects(), loadTeachers()]); - await loadTeacherSubjects(); + try { + [allTeachers, allDepartments] = await Promise.all([ + api.get('/api/users/teachers'), + api.get('/api/departments') + ]); + populateDepartmentsSelect(); + await loadSubjects(); + } catch (e) { + subjectsTbody.innerHTML = `Ошибка загрузки справочников: ${escapeHtml(e.message)}`; + } + } + + function populateDepartmentsSelect() { + newSubjectDeptSelect.innerHTML = '' + + allDepartments.map(d => { + const name = d.departmentName || d.name || 'Без названия'; + const code = d.departmentCode || d.code || d.id; + return ``; + }).join(''); } async function loadSubjects() { + subjectsTbody.innerHTML = 'Загрузка...'; try { - allSubjects = await api.get('/api/subjects'); - renderSubjects(allSubjects); - populateSubjectSelect(allSubjects); + [allSubjects, teacherSubjects] = await Promise.all([ + api.get('/api/subjects'), + api.get('/api/teacher-subjects') + ]); + renderSubjects(); } catch (e) { - if (subjectsTbody) subjectsTbody.innerHTML = 'Ошибка загрузки'; + subjectsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; } } - function renderSubjects(subjects) { - if (!subjects || !subjects.length) { - subjectsTbody.innerHTML = 'Нет дисциплин'; + function renderSubjects() { + if (!allSubjects || !allSubjects.length) { + subjectsTbody.innerHTML = 'Нет дисциплин'; return; } - subjectsTbody.innerHTML = subjects.map(s => ` - - ${s.id} - ${escapeHtml(s.name)} - ${escapeHtml(s.code || '-')} - ${s.departmentId || '-'} - - `).join(''); + + // Группируем преподавателей по дисциплинам + const teachersBySubject = new Map(); + teacherSubjects.forEach(ts => { + if (!teachersBySubject.has(ts.subjectId)) { + teachersBySubject.set(ts.subjectId, []); + } + teachersBySubject.get(ts.subjectId).push(ts); + }); + + subjectsTbody.innerHTML = allSubjects.map(s => { + const teachers = teachersBySubject.get(s.id) || []; + const teachersHtml = teachers.length + ? teachers.map(t => `${escapeHtml(t.fullName || t.username)}`).join('') + : 'Нет преподавателей'; + + return ` + + ${s.id} + ${escapeHtml(s.name)} + ${escapeHtml(s.code || '-')} + ${escapeHtml(departmentLabel(s.departmentId))} +
${teachersHtml}
+ + + + + + `; + }).join(''); } - function populateSubjectSelect(subjects) { - if (!assignSubjectSelect) return; - const currentVal = assignSubjectSelect.value; - assignSubjectSelect.innerHTML = '' + - subjects.map(s => ``).join(''); - if (currentVal && subjects.find(s => s.id == currentVal)) { - assignSubjectSelect.value = currentVal; - } - } - - async function loadTeachers() { - try { - allTeachers = await api.get('/api/users/teachers'); - populateTeacherSelect(allTeachers); - } catch (e) { - if (assignTeacherSelect) assignTeacherSelect.innerHTML = ''; - } - } - - function populateTeacherSelect(teachers) { - if (!assignTeacherSelect) return; - const currentVal = assignTeacherSelect.value; - if (!teachers || !teachers.length) { - assignTeacherSelect.innerHTML = ''; - return; - } - assignTeacherSelect.innerHTML = '' + - teachers.map(t => ``).join(''); - if (currentVal && teachers.find(t => t.id == currentVal)) { - assignTeacherSelect.value = currentVal; - } - } - - async function loadTeacherSubjects() { - try { - const tsData = await api.get('/api/teacher-subjects'); - renderTeacherSubjects(tsData); - } catch (e) { - if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = 'Ошибка загрузки'; - } - } - - function renderTeacherSubjects(tsArray) { - if (!tsArray || !tsArray.length) { - teacherSubjectsTbody.innerHTML = 'Нет привязок'; - return; - } - teacherSubjectsTbody.innerHTML = tsArray.map(ts => ` - - ${escapeHtml(ts.username)} - ${escapeHtml(ts.subjectName)} - - `).join(''); - } - - createSubjectForm.addEventListener('submit', async (e) => { + async function createSubject(e) { e.preventDefault(); hideAlert('create-subject-alert'); const name = document.getElementById('new-subject-name').value.trim(); const code = document.getElementById('new-subject-code').value.trim(); - const departmentId = document.getElementById('new-subject-department').value; - if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; } - if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; } - if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; } + const departmentId = newSubjectDeptSelect.value; + + if (!name || !code || !departmentId) { + showAlert('create-subject-alert', 'Заполните все поля', 'error'); + return; + } try { - const data = await api.post('/api/subjects', { - name, - code, - departmentId: Number(departmentId) + const data = await api.post('/api/subjects', { + name, + code, + departmentId: Number(departmentId) }); - showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success'); + showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success'); createSubjectForm.reset(); - loadSubjects(); - } catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); } - }); + await loadSubjects(); + } catch (error) { + showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error'); + } + } - subjectsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); - if (!btn) return; - if (!confirm('Удалить дисциплину?')) return; - try { - await api.delete('/api/subjects/' + btn.dataset.id); - loadSubjects(); - loadTeacherSubjects(); // May have deleted a subject that was assigned - } catch (e) { alert(e.message || 'Ошибка удаления'); } - }); + async function handleSubjectsTableClick(e) { + const btnDelete = e.target.closest('.btn-delete-subject'); + const btnTeachers = e.target.closest('.btn-manage-teachers'); - assignTeacherForm.addEventListener('submit', async (e) => { + if (btnDelete) { + if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return; + try { + await api.delete('/api/subjects/' + btnDelete.dataset.id); + await loadSubjects(); + } catch (error) { + alert(error.message || 'Ошибка удаления'); + } + } + + if (btnTeachers) { + const subjectId = btnTeachers.dataset.id; + const subject = allSubjects.find(s => s.id == subjectId); + if (subject) openTeachersModal(subject); + } + } + + // --- Управление привязками преподавателей к дисциплине --- + function openTeachersModal(subject) { + subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`; + manageTsSubjectIdInput.value = subject.id; + hideAlert('manage-ts-alert'); + modalManageTeachers.classList.add('open'); + renderSubjectTeachers(subject.id); + populateTeachersSelect(subject.id); + } + + function renderSubjectTeachers(subjectId) { + const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId)); + if (!list.length) { + manageTsTbody.innerHTML = 'Преподаватели не привязаны'; + return; + } + manageTsTbody.innerHTML = list.map(ts => ` + + ${escapeHtml(ts.fullName || ts.username)} (${escapeHtml(ts.username)}) + + + + + `).join(''); + } + + function populateTeachersSelect(subjectId) { + // Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине + const assignedUserIds = new Set( + teacherSubjects + .filter(ts => String(ts.subjectId) === String(subjectId)) + .map(ts => String(ts.userId)) + ); + + const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id))); + + if (!availableTeachers.length) { + manageTsTeacherSelect.innerHTML = ''; + return; + } + + manageTsTeacherSelect.innerHTML = '' + + availableTeachers.map(t => ``).join(''); + } + + async function saveTeacherAssignment(e) { e.preventDefault(); - hideAlert('assign-teacher-alert'); - const userId = assignTeacherSelect.value; - const subjectId = assignSubjectSelect.value; - if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; } - if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; } + hideAlert('manage-ts-alert'); + const subjectId = manageTsSubjectIdInput.value; + const userId = manageTsTeacherSelect.value; + + if (!userId) { + showAlert('manage-ts-alert', 'Выберите преподавателя', 'error'); + return; + } try { - await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) }); - showAlert('assign-teacher-alert', 'Привязка создана', 'success'); - loadTeacherSubjects(); - } catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); } - }); + await api.post('/api/teacher-subjects', { + userId: Number(userId), + subjectId: Number(subjectId) + }); + showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success'); + + // Перезагрузим привязки и обновим интерфейс + teacherSubjects = await api.get('/api/teacher-subjects'); + renderSubjectTeachers(subjectId); + populateTeachersSelect(subjectId); + + // Также обновим главную таблицу + renderSubjects(); + } catch (error) { + showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error'); + } + } + + async function handleTeacherAssignmentTableClick(e) { + const btnDelete = e.target.closest('.btn-delete-ts'); + if (!btnDelete) return; + if (!confirm('Удалить привязку преподавателя?')) return; + const subjectId = manageTsSubjectIdInput.value; - teacherSubjectsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); - if (!btn) return; - if (!confirm('Удалить привязку?')) return; try { await api.delete('/api/teacher-subjects', { - userId: Number(btn.dataset.userId), - subjectId: Number(btn.dataset.subjectId) + userId: Number(btnDelete.dataset.userId), + subjectId: Number(btnDelete.dataset.subjectId) }); - loadTeacherSubjects(); - } catch (e) { alert(e.message || 'Ошибка удаления'); } - }); + + // Перезагрузим привязки + teacherSubjects = await api.get('/api/teacher-subjects'); + renderSubjectTeachers(subjectId); + populateTeachersSelect(subjectId); + + // Обновим главную таблицу + renderSubjects(); + } catch (error) { + alert(error.message || 'Ошибка удаления привязки'); + } + } - loadInitialData(); + // --- Вспомогательные функции --- + function departmentLabel(departmentId) { + const department = allDepartments.find(item => item.id == departmentId); + if (!department) return departmentId || '-'; + return department.departmentName || department.name || departmentId; + } } diff --git a/frontend/admin/js/views/university-structure.js b/frontend/admin/js/views/university-structure.js new file mode 100644 index 0000000..18e81b0 --- /dev/null +++ b/frontend/admin/js/views/university-structure.js @@ -0,0 +1,549 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +export async function initUniversityStructure() { + const deptTbody = document.getElementById('departments-tbody'); + const specTbody = document.getElementById('specialties-tbody'); + + const createDeptForm = document.getElementById('create-department-form'); + const createSpecForm = document.getElementById('create-specialty-form'); + const editDeptForm = document.getElementById('edit-department-form'); + const editSpecForm = document.getElementById('edit-specialty-form'); + const editDeptModal = document.getElementById('modal-edit-department'); + const editSpecModal = document.getElementById('modal-edit-specialty'); + const editDeptClose = document.getElementById('modal-edit-department-close'); + const editSpecClose = document.getElementById('modal-edit-specialty-close'); + + // Элементы вкладок + const tabDepartments = document.getElementById('btn-tab-departments'); + const tabSpecialties = document.getElementById('btn-tab-specialties'); + const tabProfiles = document.getElementById('btn-tab-profiles'); + const contentDepartments = document.getElementById('tab-content-departments'); + const contentSpecialties = document.getElementById('tab-content-specialties'); + const contentProfiles = document.getElementById('tab-content-profiles'); + + // Элементы модалки профилей + const profilesModal = document.getElementById('modal-manage-profiles'); + const profilesModalClose = document.getElementById('modal-manage-profiles-close'); + const profilesSpecContext = document.getElementById('profiles-spec-context'); + const profileFormTitle = document.getElementById('profile-form-title'); + const manageProfileForm = document.getElementById('manage-profile-form'); + const profileIdInput = document.getElementById('manage-profile-id'); + const profileSpecIdInput = document.getElementById('manage-profile-spec-id'); + const profileNameInput = document.getElementById('manage-profile-name'); + const profileDescInput = document.getElementById('manage-profile-description'); + const btnSaveProfile = document.getElementById('btn-save-profile'); + const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit'); + const profilesTbody = document.getElementById('manage-profiles-tbody'); + + // Элементы вкладки Профилей + const tabProfileTbody = document.getElementById('profiles-tbody'); + const createProfileTabForm = document.getElementById('create-profile-tab-form'); + const tabProfileIdInput = document.getElementById('tab-profile-id'); + const tabProfileSpecSelect = document.getElementById('tab-profile-spec'); + const tabProfileNameInput = document.getElementById('tab-profile-name'); + const tabProfileDescInput = document.getElementById('tab-profile-description'); + const mainProfileFormTitle = document.getElementById('main-profile-form-title'); + const btnTabSaveProfile = document.getElementById('btn-tab-save-profile'); + const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit'); + + let departments = []; + let specialties = []; + let currentProfiles = []; + let allProfiles = []; + + // --- Логика переключения табов --- + function deactivateTabs() { + [tabDepartments, tabSpecialties, tabProfiles].forEach(tab => { + if (tab) { + tab.classList.remove('active'); + tab.style.borderBottomColor = 'transparent'; + tab.style.color = 'var(--text-secondary)'; + } + }); + [contentDepartments, contentSpecialties, contentProfiles].forEach(content => { + if (content) content.style.display = 'none'; + }); + } + + tabDepartments.addEventListener('click', () => { + deactivateTabs(); + tabDepartments.classList.add('active'); + tabDepartments.style.borderBottomColor = 'var(--primary)'; + tabDepartments.style.color = 'var(--text-main)'; + contentDepartments.style.display = 'block'; + }); + + tabSpecialties.addEventListener('click', () => { + deactivateTabs(); + tabSpecialties.classList.add('active'); + tabSpecialties.style.borderBottomColor = 'var(--primary)'; + tabSpecialties.style.color = 'var(--text-main)'; + contentSpecialties.style.display = 'block'; + }); + + tabProfiles.addEventListener('click', () => { + deactivateTabs(); + tabProfiles.classList.add('active'); + tabProfiles.style.borderBottomColor = 'var(--primary)'; + tabProfiles.style.color = 'var(--text-main)'; + contentProfiles.style.display = 'block'; + }); + + // --- Загрузка данных --- + async function loadData() { + // Загрузка кафедр + try { + departments = await api.get('/api/departments'); + renderDepartments(); + } catch (e) { + deptTbody.innerHTML = 'Ошибка загрузки'; + } + + // Загрузка специальностей + try { + specialties = await api.get('/api/specialties'); + renderSpecialties(); + populateSpecialtiesDropdown(); + } catch (e) { + specTbody.innerHTML = 'Ошибка загрузки'; + } + + // Загрузка всех профилей + try { + allProfiles = await api.get('/api/specialties/profiles'); + renderAllProfiles(); + } catch (e) { + tabProfileTbody.innerHTML = 'Ошибка загрузки'; + } + } + + function renderDepartments() { + if (!departments || !departments.length) { + deptTbody.innerHTML = 'Кафедры не созданы'; + return; + } + deptTbody.innerHTML = departments.map(d => ` + + ${d.id} + ${escapeHtml(d.departmentName || d.name)} + ${escapeHtml(String(d.departmentCode || d.code))} + + + + + + `).join(''); + } + + function renderSpecialties() { + if (!specialties || !specialties.length) { + specTbody.innerHTML = 'Специальности не созданы'; + return; + } + specTbody.innerHTML = specialties.map(s => ` + + ${s.id} + ${escapeHtml(s.specialityName || s.name)} + ${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)} + + + + + + + `).join(''); + } + + // --- Кафедры: CRUD --- + createDeptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-dept-alert'); + const name = document.getElementById('dept-name').value.trim(); + const code = document.getElementById('dept-code').value.trim(); + + if (!name || !code) { + showAlert('create-dept-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) }); + showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success'); + createDeptForm.reset(); + loadData(); + } catch (error) { + showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error'); + } + }); + + deptTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-department'); + const deleteBtn = e.target.closest('.btn-delete-department'); + + if (editBtn) { + const department = departments.find(item => item.id == editBtn.dataset.id); + if (!department) return; + + document.getElementById('edit-dept-id').value = department.id; + document.getElementById('edit-dept-name').value = department.departmentName || department.name || ''; + document.getElementById('edit-dept-code').value = department.departmentCode || department.code || ''; + hideAlert('edit-dept-alert'); + editDeptModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить кафедру?')) return; + try { + await api.delete('/api/departments/' + deleteBtn.dataset.id); + showAlert('create-dept-alert', 'Кафедра удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error'); + } + } + }); + + editDeptForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-dept-alert'); + const id = document.getElementById('edit-dept-id').value; + const name = document.getElementById('edit-dept-name').value.trim(); + const code = document.getElementById('edit-dept-code').value.trim(); + + if (!name || !code) { + showAlert('edit-dept-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) }); + editDeptModal.classList.remove('open'); + showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error'); + } + }); + + // --- Специальности: CRUD --- + createSpecForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-spec-alert'); + const name = document.getElementById('spec-name').value.trim(); + const code = document.getElementById('spec-code').value.trim(); + + if (!name || !code) { + showAlert('create-spec-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.post('/api/specialties', { specialityName: name, specialityCode: code }); + showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success'); + createSpecForm.reset(); + loadData(); + } catch (error) { + showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error'); + } + }); + + specTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-specialty'); + const deleteBtn = e.target.closest('.btn-delete-specialty'); + const profilesBtn = e.target.closest('.btn-manage-profiles'); + + if (editBtn) { + const speciality = specialties.find(item => item.id == editBtn.dataset.id); + if (!speciality) return; + + document.getElementById('edit-spec-id').value = speciality.id; + document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || ''; + document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || ''; + hideAlert('edit-spec-alert'); + editSpecModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить специальность?')) return; + try { + await api.delete('/api/specialties/' + deleteBtn.dataset.id); + showAlert('create-spec-alert', 'Специальность удалена', 'success'); + await loadData(); + } catch (error) { + showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error'); + } + } + + if (profilesBtn) { + const specId = profilesBtn.dataset.id; + const speciality = specialties.find(item => item.id == specId); + if (!speciality) return; + + openProfilesModal(speciality); + } + }); + + editSpecForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-spec-alert'); + const id = document.getElementById('edit-spec-id').value; + const name = document.getElementById('edit-spec-name').value.trim(); + const code = document.getElementById('edit-spec-code').value.trim(); + + if (!name || !code) { + showAlert('edit-spec-alert', 'Заполните все поля', 'error'); + return; + } + + try { + await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code }); + editSpecModal.classList.remove('open'); + showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success'); + await loadData(); + } catch (error) { + showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error'); + } + }); + + // --- Профили обучения: Логика --- + async function openProfilesModal(speciality) { + profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`; + profileSpecIdInput.value = speciality.id; + resetProfileForm(); + profilesModal.classList.add('open'); + await loadProfiles(speciality.id); + } + + async function loadProfiles(specId) { + profilesTbody.innerHTML = 'Загрузка...'; + try { + currentProfiles = await api.get(`/api/specialties/${specId}/profiles`); + renderProfilesTable(); + } catch (e) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(e.message)}`; + } + } + + function renderProfilesTable() { + if (!currentProfiles || !currentProfiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + profilesTbody.innerHTML = currentProfiles.map(p => ` + + ${p.id} + ${escapeHtml(p.name)} + ${escapeHtml(p.description || '-')} + + + + + + `).join(''); + } + + function resetProfileForm() { + manageProfileForm.reset(); + profileIdInput.value = ''; + profileFormTitle.textContent = 'Создание профиля'; + btnSaveProfile.textContent = 'Создать'; + btnCancelProfileEdit.style.display = 'none'; + hideAlert('manage-profile-alert'); + } + + manageProfileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('manage-profile-alert'); + + const specId = profileSpecIdInput.value; + const profileId = profileIdInput.value; + const name = profileNameInput.value.trim(); + const description = profileDescInput.value.trim(); + + if (!name) { + showAlert('manage-profile-alert', 'Введите название профиля', 'error'); + return; + } + + try { + if (profileId) { + // Обновление + await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description }); + showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success'); + } else { + // Создание + await api.post(`/api/specialties/${specId}/profiles`, { name, description }); + showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success'); + } + resetProfileForm(); + await loadProfiles(specId); + } catch (error) { + showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-profile'); + const deleteBtn = e.target.closest('.btn-delete-profile'); + const specId = profileSpecIdInput.value; + + if (editBtn) { + const profile = currentProfiles.find(item => item.id == editBtn.dataset.id); + if (!profile) return; + + profileIdInput.value = profile.id; + profileNameInput.value = profile.name || ''; + profileDescInput.value = profile.description || ''; + profileFormTitle.textContent = 'Редактирование профиля'; + btnSaveProfile.textContent = 'Сохранить'; + btnCancelProfileEdit.style.display = 'inline-block'; + hideAlert('manage-profile-alert'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`); + showAlert('manage-profile-alert', 'Профиль удален', 'success'); + await loadProfiles(specId); + } catch (error) { + showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + + btnCancelProfileEdit.addEventListener('click', resetProfileForm); + + // Закрытие модалок + editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); + editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); + profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open')); + + editDeptModal.addEventListener('click', (e) => { + if (e.target === editDeptModal) editDeptModal.classList.remove('open'); + }); + editSpecModal.addEventListener('click', (e) => { + if (e.target === editSpecModal) editSpecModal.classList.remove('open'); + }); + profilesModal.addEventListener('click', (e) => { + if (e.target === profilesModal) profilesModal.classList.remove('open'); + }); + + // --- Дополнительные функции для вкладки Профилей --- + function populateSpecialtiesDropdown() { + const currentValue = tabProfileSpecSelect.value; + tabProfileSpecSelect.innerHTML = '' + + specialties.map(s => ``).join(''); + tabProfileSpecSelect.value = currentValue; + } + + function renderAllProfiles() { + if (!allProfiles || !allProfiles.length) { + tabProfileTbody.innerHTML = 'Профили не созданы'; + return; + } + tabProfileTbody.innerHTML = allProfiles.map(p => ` + + ${p.id} + ${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)} + ${escapeHtml(p.name)} + ${escapeHtml(p.description || '-')} + + + + + + `).join(''); + } + + function resetTabProfileForm() { + createProfileTabForm.reset(); + tabProfileIdInput.value = ''; + tabProfileSpecSelect.disabled = false; + mainProfileFormTitle.textContent = 'Создание профиля'; + btnTabSaveProfile.textContent = 'Создать'; + btnTabCancelProfileEdit.style.display = 'none'; + hideAlert('tab-profile-alert'); + tabProfileSpecSelect.dispatchEvent(new Event('change')); + } + + createProfileTabForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('tab-profile-alert'); + + const specId = tabProfileSpecSelect.value; + const profileId = tabProfileIdInput.value; + const name = tabProfileNameInput.value.trim(); + const description = tabProfileDescInput.value.trim(); + + if (!specId) { + showAlert('tab-profile-alert', 'Выберите специальность', 'error'); + return; + } + if (!name) { + showAlert('tab-profile-alert', 'Введите название профиля', 'error'); + return; + } + + try { + if (profileId) { + await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description }); + showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success'); + } else { + await api.post(`/api/specialties/${specId}/profiles`, { name, description }); + showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success'); + } + resetTabProfileForm(); + await loadData(); + } catch (error) { + showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error'); + } + }); + + tabProfileTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-tab-profile'); + const deleteBtn = e.target.closest('.btn-delete-tab-profile'); + + if (editBtn) { + const profile = allProfiles.find(item => item.id == editBtn.dataset.id); + if (!profile) return; + + tabProfileIdInput.value = profile.id; + tabProfileSpecSelect.value = profile.specialityId; + tabProfileSpecSelect.disabled = true; + tabProfileSpecSelect.dispatchEvent(new Event('change')); + + tabProfileNameInput.value = profile.name || ''; + tabProfileDescInput.value = profile.description || ''; + + mainProfileFormTitle.textContent = 'Редактирование профиля'; + btnTabSaveProfile.textContent = 'Сохранить'; + btnTabCancelProfileEdit.style.display = 'inline-block'; + hideAlert('tab-profile-alert'); + + createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + return; + } + + if (deleteBtn) { + const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id); + if (!profile) return; + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`); + showAlert('tab-profile-alert', 'Профиль удален', 'success'); + await loadData(); + } catch (error) { + showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + + btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm); + + await loadData(); +} diff --git a/frontend/admin/js/views/users.js b/frontend/admin/js/views/users.js index 5fc555d..4130bcf 100644 --- a/frontend/admin/js/views/users.js +++ b/frontend/admin/js/views/users.js @@ -50,7 +50,7 @@ export async function initUsers() { ${u.status === 'ARCHIVED' ? `` - : ``} + : ``} `).join(''); @@ -90,7 +90,7 @@ export async function initUsers() { }); usersTbody.addEventListener('click', async (e) => { - const deleteBtn = e.target.closest('.btn-delete'); + const deleteBtn = e.target.closest('.btn-archive-user'); if (!deleteBtn) return; if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return; diff --git a/frontend/admin/settings/css/layout.css b/frontend/admin/settings/css/layout.css index 7261429..0fbb34c 100644 --- a/frontend/admin/settings/css/layout.css +++ b/frontend/admin/settings/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); } @@ -265,12 +257,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/settings/css/main.css b/frontend/admin/settings/css/main.css index 8406f80..97607dc 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -46,9 +46,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; --success: #10b981; @@ -77,20 +77,19 @@ body { /* ===== Переключатель темы ===== */ .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; } @@ -102,12 +101,18 @@ body { } .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 { @@ -265,63 +270,6 @@ body { cursor: pointer; } -.btn-primary, -.btn-secondary, -.btn-delete { - min-height: 40px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - padding: 0 0.9rem; - color: var(--text-primary); - font: inherit; - font-weight: 700; - cursor: pointer; - transition: transform var(--transition), background var(--transition), border-color var(--transition); - white-space: nowrap; -} - -.btn-primary { - background: var(--accent); -} - -.btn-primary:hover { - background: var(--accent-hover); - transform: translateY(-1px); -} - -.btn-secondary { - background: var(--bg-input); - border-color: var(--bg-card-border); -} - -.btn-secondary:hover { - background: var(--bg-hover); - transform: translateY(-1px); -} - -.btn-delete { - background: rgba(239, 68, 68, 0.13); - border-color: rgba(239, 68, 68, 0.25); - color: #fecaca; -} - -[data-theme="light"] .btn-delete { - color: #991b1b; -} - -.btn-delete:hover { - background: rgba(239, 68, 68, 0.2); - transform: translateY(-1px); -} - -.btn-primary:disabled, -.btn-secondary:disabled, -.btn-delete:disabled { - cursor: not-allowed; - opacity: 0.45; - transform: none; -} - .form-alert { grid-column: 1 / -1; min-height: 0; @@ -444,8 +392,12 @@ body { } .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 { @@ -610,7 +562,7 @@ body { grid-template-columns: 1fr; } - .settings-form .btn-primary { + .settings-form .btn { width: fit-content; } @@ -626,9 +578,16 @@ body { flex-direction: column; } - .settings-header-row .btn-primary, - .card-header-row .btn-secondary, - .settings-form .btn-primary { + .settings-header-row .btn, + .card-header-row .btn, + .settings-form .btn { width: 100%; } } +.settings-modal-close.btn-icon-sm { + width: 32px !important; + height: 32px !important; + min-height: 32px !important; + padding: 0 !important; + border-radius: var(--button-radius) !important; +} diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index 3c10116..7766864 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -9,6 +9,7 @@ + @@ -31,7 +32,7 @@ Настройки - + `; }).join(''); } @@ -136,7 +136,7 @@ export async function initDatabase() { // === Удаление тенанта === tenantsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); + const btn = e.target.closest('.btn-delete-tenant'); if (!btn) return; const domain = btn.dataset.domain; diff --git a/frontend/admin/js/views/edu-forms.js b/frontend/admin/settings/js/views/edu-forms.js old mode 100755 new mode 100644 similarity index 88% rename from frontend/admin/js/views/edu-forms.js rename to frontend/admin/settings/js/views/edu-forms.js index c2c1f4d..752fe39 --- a/frontend/admin/js/views/edu-forms.js +++ b/frontend/admin/settings/js/views/edu-forms.js @@ -1,5 +1,5 @@ -import { api } from '../api.js'; -import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +import { api } from '../../../js/api.js'; +import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js'; export let allEducationForms = []; @@ -35,7 +35,7 @@ export async function initEduForms() { ${ef.id} ${escapeHtml(ef.name)} - + `).join(''); } @@ -56,7 +56,7 @@ export async function initEduForms() { }); efTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); + const btn = e.target.closest('.btn-delete-edu-form'); if (!btn) return; if (!confirm('Удалить форму обучения?')) return; try { diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js index 84932e3..3df9480 100644 --- a/frontend/admin/settings/js/views/time-slots.js +++ b/frontend/admin/settings/js/views/time-slots.js @@ -143,8 +143,8 @@ export async function initTimeSlotsSettings() { ${escapeHtml(trimTime(slot.endTime))} ${escapeHtml(String(slot.durationMinutes ?? '-'))} - - + + `).join(''); diff --git a/frontend/admin/views/database.html b/frontend/admin/settings/views/database.html old mode 100755 new mode 100644 similarity index 92% rename from frontend/admin/views/database.html rename to frontend/admin/settings/views/database.html index 1702885..e7cca00 --- a/frontend/admin/views/database.html +++ b/frontend/admin/settings/views/database.html @@ -61,10 +61,10 @@ - - diff --git a/frontend/admin/views/edu-forms.html b/frontend/admin/settings/views/edu-forms.html old mode 100755 new mode 100644 similarity index 91% rename from frontend/admin/views/edu-forms.html rename to frontend/admin/settings/views/edu-forms.html index a2f51ca..9efdcfc --- a/frontend/admin/views/edu-forms.html +++ b/frontend/admin/settings/views/edu-forms.html @@ -7,7 +7,7 @@ - + @@ -31,4 +31,4 @@ - \ No newline at end of file + diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html index a6800a1..e2735d3 100644 --- a/frontend/admin/settings/views/time-slots.html +++ b/frontend/admin/settings/views/time-slots.html @@ -4,7 +4,7 @@

Временные слоты

Настройка базовой, субботней и ручных сеток времени.

- +
@@ -12,8 +12,8 @@

Сетка времени

- - + +
@@ -47,7 +47,7 @@

Новый слот

- +
@@ -73,7 +73,7 @@ - +
@@ -106,7 +106,12 @@

Новая ручная сетка

Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.

- +
@@ -116,8 +121,8 @@
- - + +
diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index daf0a10..8e47802 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -15,8 +15,8 @@ - - + +
@@ -25,7 +25,7 @@

Учебные годы

- +
@@ -72,8 +72,8 @@ - - + + @@ -82,7 +82,7 @@

Календарный учебный график

- +
@@ -119,7 +119,7 @@
- + @@ -128,7 +128,7 @@

Календарные графики

- +
@@ -161,8 +161,8 @@ - - + +
@@ -185,7 +185,7 @@
- +
@@ -229,8 +229,8 @@
- - + +
diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html index 71755ad..496ff55 100644 --- a/frontend/admin/views/auditorium-workload.html +++ b/frontend/admin/views/auditorium-workload.html @@ -1,7 +1,7 @@

Загруженность

- +
@@ -16,7 +16,7 @@
-
@@ -37,18 +37,8 @@
- -
-
- Любая вместимость - - - -
- -
+ +
diff --git a/frontend/admin/views/classrooms.html b/frontend/admin/views/classrooms.html index b066193..c93ed2c 100755 --- a/frontend/admin/views/classrooms.html +++ b/frontend/admin/views/classrooms.html @@ -1,6 +1,9 @@
-

Новая аудитория

+
+

Новая аудитория

+ +
@@ -33,7 +36,7 @@
- +
@@ -100,10 +103,53 @@
- +
- \ No newline at end of file + + + +
+ + + + + + + + + + + + +
IDНазваниеДействия
Загрузка...
+
+
+ diff --git a/frontend/admin/views/dashboard.html b/frontend/admin/views/dashboard.html new file mode 100644 index 0000000..b3e7dd6 --- /dev/null +++ b/frontend/admin/views/dashboard.html @@ -0,0 +1,145 @@ + +
diff --git a/frontend/admin/views/department-workspace.html b/frontend/admin/views/department-workspace.html index 781f56e..c09ff6c 100644 --- a/frontend/admin/views/department-workspace.html +++ b/frontend/admin/views/department-workspace.html @@ -1,7 +1,7 @@

Рабочая область кафедры

- +
@@ -30,11 +30,14 @@

Загрузка дисциплин

-
- - +
+
+ + +
+
- +
@@ -44,7 +47,6 @@

Преподаватели и нагрузка

Загрузка...
-
Загрузка...
@@ -73,7 +75,7 @@ - +
Выберите дисциплину
diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html deleted file mode 100644 index dd31814..0000000 --- a/frontend/admin/views/departments-data.html +++ /dev/null @@ -1,126 +0,0 @@ - -
-

Создание кафедры

-
-
-
- - -
-
- - -
- -
- -
-
- -
-
-

Кафедры

-
-
- - - - - - - - - - - - - - -
IDНазваниеКодДействия
Загрузка...
-
-
- -
-

Создание специальности

-
-
-
- - -
-
- - -
- -
- -
-
- -
-
-

Специальности

-
-
- - - - - - - - - - - - - - -
IDНазваниеКод специальностиДействия
Загрузка...
-
-
- - - - diff --git a/frontend/admin/views/equipments.html b/frontend/admin/views/equipments.html deleted file mode 100755 index 3754d76..0000000 --- a/frontend/admin/views/equipments.html +++ /dev/null @@ -1,34 +0,0 @@ - -
-

Новое оборудование

-
-
-
- - -
- -
- -
-
- -
-

Справочник оборудования

-
- - - - - - - - - - - - - -
IDНазваниеДействия
Загрузка...
-
-
\ No newline at end of file diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 879269a..18c677d 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -39,7 +39,7 @@ - + @@ -67,99 +67,13 @@ Курс Специальность Профиль + Статус Действия - Загрузка... - - - - - - -
-

Подгруппы для лабораторных

-
- -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- - - - - - - - - - - - - - -
ГруппаПодгруппаЧисленностьДействия
Загрузка...
-
-
- -
-

Назначение календарного графика

-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- - - - - - - - - - - +
Учебный годГрафикДействия
Выберите группуЗагрузка...
@@ -213,10 +127,120 @@
- +
+ + + + + + diff --git a/frontend/admin/views/profiles.html b/frontend/admin/views/profiles.html deleted file mode 100644 index b2a40ac..0000000 --- a/frontend/admin/views/profiles.html +++ /dev/null @@ -1,72 +0,0 @@ -
-
-

Создание профиля обучения

-
-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

Профили выбранной специальности

- -
-
-
- - - - - - - - - - - - - - -
IDПрофильОписаниеДействия
Загрузка...
-
-
- - diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index a409229..46b9744 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -5,13 +5,13 @@

Расписания

Период не выбран - +
- @@ -21,45 +21,33 @@
-
-
- - -
- -
- Переход -
- - - -
-
+
@@ -67,19 +55,19 @@
-
-
- @@ -99,9 +87,17 @@ 0 занятий
- + Нет выбранного расписания - +
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index fe8637d..87b62b1 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,7 +1,7 @@

Новое правило расписания

- +
@@ -79,13 +79,13 @@

Слоты правила

- +
-
- +
+
@@ -94,7 +94,7 @@

Правила динамического расписания

- +
@@ -117,3 +117,57 @@
+ + + + + + diff --git a/frontend/admin/views/subjects.html b/frontend/admin/views/subjects.html index 167edaa..113162a 100755 --- a/frontend/admin/views/subjects.html +++ b/frontend/admin/views/subjects.html @@ -12,37 +12,17 @@
- - + +
- +
-
-

Привязка преподавателя к дисциплине

-
-
-
- - -
-
- - -
- -
- -
-
-

Все дисциплины

@@ -52,35 +32,64 @@ ID Название Код предмета - Кафедра (ID) - Действия + Кафедра + Преподаватели + Действия - Загрузка... + Загрузка...
-
-

Привязки преподавателей

-
- - - - - - - - - - - - - -
ПреподавательДисциплинаДействия
Загрузка...
+ + \ No newline at end of file +
diff --git a/frontend/admin/views/university-structure.html b/frontend/admin/views/university-structure.html new file mode 100644 index 0000000..f88235f --- /dev/null +++ b/frontend/admin/views/university-structure.html @@ -0,0 +1,252 @@ + +
+
+ + + +
+ + +
+
+

Создание кафедры

+
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Кафедры

+
+
+ + + + + + + + + + + + + + +
IDНазваниеКодДействия
Загрузка...
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/admin/views/users.html b/frontend/admin/views/users.html index b3e093c..b084dd3 100755 --- a/frontend/admin/views/users.html +++ b/frontend/admin/views/users.html @@ -34,7 +34,7 @@
- +
diff --git a/frontend/index.html b/frontend/index.html index 68e1607..3799de7 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -57,7 +57,7 @@ - - Выйти + + Выйти @@ -279,9 +325,17 @@
- - - + + +
diff --git a/frontend/style.css b/frontend/style.css index 2ea3645..4e7736f 100755 --- a/frontend/style.css +++ b/frontend/style.css @@ -25,6 +25,24 @@ --accent-hover: #a78bfa; --accent-glow: rgba(139, 92, 246, 0.4); --accent-secondary: #ec4899; + --button-primary-bg: #8B5CF6; + --button-primary-bg-hover: #7C3AED; + --button-primary-text: #FFFFFF; + --button-secondary-bg: rgba(255, 255, 255, 0.04); + --button-secondary-bg-hover: rgba(255, 255, 255, 0.08); + --button-secondary-border: rgba(255, 255, 255, 0.10); + --button-secondary-text: #F8FAFC; + --button-ghost-bg-hover: rgba(255, 255, 255, 0.06); + --button-danger-bg: #DC2626; + --button-danger-bg-hover: #B91C1C; + --button-danger-subtle-bg: rgba(239, 68, 68, 0.13); + --button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20); + --button-danger-subtle-border: rgba(239, 68, 68, 0.25); + --button-danger-subtle-text: #FECACA; + --button-focus-ring: rgba(139, 92, 246, 0.45); + --button-disabled-bg: rgba(148, 163, 184, 0.16); + --button-disabled-text: #94A3B8; + --button-radius: 8px; /* Status Colors */ --error: #ef4444; @@ -48,10 +66,27 @@ --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; + --button-primary-bg: #4F46E5; + --button-primary-bg-hover: #4338CA; + --button-primary-text: #FFFFFF; + --button-secondary-bg: #FFFFFF; + --button-secondary-bg-hover: #F1F5F9; + --button-secondary-border: #CBD5E1; + --button-secondary-text: #334155; + --button-ghost-bg-hover: #F8FAFC; + --button-danger-bg: #DC2626; + --button-danger-bg-hover: #B91C1C; + --button-danger-subtle-bg: #FEF2F2; + --button-danger-subtle-bg-hover: #FEE2E2; + --button-danger-subtle-border: #FECACA; + --button-danger-subtle-text: #B91C1C; + --button-focus-ring: rgba(79, 70, 229, 0.35); + --button-disabled-bg: #CBD5E1; + --button-disabled-text: #64748B; --error: #ef4444; --success: #10b981; --warning: #f59e0b; @@ -389,59 +424,19 @@ body { /* ===== Submit Button ===== */ .btn-submit { - position: relative; - overflow: hidden; width: 100%; - padding: 0.95rem; - border: none; - border-radius: var(--radius-sm); - background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); - color: #fff; - font-family: inherit; - font-size: 1rem; - font-weight: 600; - letter-spacing: 0.02em; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; gap: 0.5rem; - transition: - transform var(--transition), - box-shadow var(--transition), - opacity var(--transition); - box-shadow: 0 4px 15px var(--accent-glow); -} - -.btn-submit::before { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background: linear-gradient(rgba(255,255,255,0.2), transparent); - border-radius: inherit; - opacity: 0; - transition: opacity var(--transition); -} - -.btn-submit:hover { - transform: translateY(-2px); - box-shadow: 0 8px 25px var(--accent-glow); -} - -.btn-submit:hover::before { - opacity: 1; } .btn-submit:active { - transform: translateY(1px); - box-shadow: 0 2px 10px var(--accent-glow); + transform: translateY(0); } .btn-submit:disabled { - opacity: 0.6; + background: var(--button-disabled-bg); + color: var(--button-disabled-text); cursor: not-allowed; transform: none; - box-shadow: none; } /* ===== Ripple Effect ===== */ @@ -480,13 +475,12 @@ body { /* ===== 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); + 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); cursor: pointer; display: flex; align-items: center; @@ -505,12 +499,13 @@ body { } .theme-toggle:hover { - transform: scale(1.1); - box-shadow: 0 4px 16px var(--accent-glow); + background: var(--button-secondary-bg-hover); + transform: translateY(-1px); + box-shadow: none; } .theme-toggle:active { - transform: scale(0.95); + transform: translateY(0); } .theme-toggle--fixed { @@ -540,4 +535,95 @@ body { width: 18px; height: 18px; } -} \ No newline at end of file +} +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + min-width: 0; + border: 1px solid transparent; + border-radius: var(--button-radius); + background: transparent; + color: inherit; + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-align: center; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition); +} + +.btn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--button-focus-ring); +} + +.btn-lg { + min-height: 48px; + padding: 0 20px; + font-size: 15px; + font-weight: 700; +} + +.btn-icon-md { + width: 40px; + height: 40px; + padding: 0; +} + +.btn-icon-md svg { + width: 20px; + height: 20px; +} + +.btn-primary { + background: var(--button-primary-bg); + border-color: transparent; + color: var(--button-primary-text); +} + +.btn-primary:hover:not(:disabled) { + background: var(--button-primary-bg-hover); + color: var(--button-primary-text); +} + +.btn-secondary { + background: var(--button-secondary-bg); + border-color: var(--button-secondary-border); + color: var(--button-secondary-text); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--button-secondary-bg-hover); + color: var(--button-secondary-text); +} +#login-form .btn.btn-primary { + min-height: 48px !important; + padding: 0 20px !important; + border-radius: var(--button-radius) !important; + background: var(--button-primary-bg) !important; + color: var(--button-primary-text) !important; + font-size: 15px !important; + font-weight: 700 !important; + box-shadow: none !important; +} + +#login-form .btn.btn-primary:hover { + background: var(--button-primary-bg-hover) !important; +} +#toggle-password.btn-icon-md { + width: 40px !important; + height: 40px !important; + min-height: 40px !important; + padding: 0 !important; + border-radius: var(--button-radius) !important; + background: transparent !important; +} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 65037b0..846f4c1 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -15,8 +15,8 @@ --panel-border: #d8dee8; --text: #172033; --muted: #67758d; - --accent: #1d4ed8; - --accent-strong: #1e40af; + --accent: #4F46E5; + --accent-strong: #4338CA; --green: #047857; --orange: #b45309; --shadow: 0 12px 30px rgba(23, 32, 51, 0.08); @@ -28,8 +28,8 @@ --panel-border: #2a374b; --text: #eef4fb; --muted: #a2b0c5; - --accent: #60a5fa; - --accent-strong: #93c5fd; + --accent: #8B5CF6; + --accent-strong: #7C3AED; --green: #34d399; --orange: #f59e0b; --shadow: 0 18px 42px rgba(0, 0, 0, 0.28); @@ -107,6 +107,7 @@ padding: 0 14px; cursor: pointer; text-decoration: none; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; } .btn:hover { @@ -125,6 +126,47 @@ color: #fff; } + .btn-secondary { + background: var(--panel); + border-color: var(--panel-border); + color: var(--text); + } + + .btn-secondary:hover { + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); + } + + .btn-danger-subtle { + background: #FEF2F2; + border-color: #FECACA; + color: #B91C1C; + } + + [data-theme="dark"] .btn-danger-subtle { + background: rgba(239, 68, 68, 0.13); + border-color: rgba(239, 68, 68, 0.25); + color: #FECACA; + } + + .btn-icon-md { + width: 40px; + height: 40px; + padding: 0; + border-radius: 8px; + } + + .btn-icon-md svg { + width: 20px; + height: 20px; + } + + .btn-md { + min-height: 40px; + padding: 0 16px; + font-size: 14px; + font-weight: 600; + } + .date-input { padding: 0 12px; } @@ -275,16 +317,28 @@ Загрузка периода...
- - Выйти + + Выйти
- - - + + +
diff --git a/frontend/theme-toggle.js b/frontend/theme-toggle.js index 2931635..4a389d4 100755 --- a/frontend/theme-toggle.js +++ b/frontend/theme-toggle.js @@ -23,7 +23,7 @@ const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light'; const btn = document.createElement('button'); - btn.className = 'theme-toggle'; + btn.className = 'btn btn-icon-md btn-secondary theme-toggle'; btn.setAttribute('aria-label', 'Переключить тему'); btn.innerHTML = isDark() ? moonSVG : sunSVG;