Merge pull request 'Redesign' (#12) from Redesign into main
Reviewed-on: #12
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
db/data/
|
||||
.env
|
||||
backend/tenants.json
|
||||
|
||||
# Игнорируем временные файлы сборки (на будущее)
|
||||
backend/target/
|
||||
|
||||
288
analysis_results.md
Normal file
@@ -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. Структура вуза<br/>Кафедры, Специальности, Профили]
|
||||
GRP[7. Группы и подгруппы]
|
||||
SUBJ[8. Дисциплины]
|
||||
end
|
||||
|
||||
subgraph Модуль: Ресурсы
|
||||
CLASS[9. Аудиторный фонд<br/>Аудитории + Оборудование]
|
||||
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. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
|
||||
BIN
audit-buttons-01-login.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
audit-buttons-02-dashboard.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
audit-buttons-03-users-form.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
audit-buttons-04-settings-menu.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
audit-buttons-05-settings-general.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
audit-buttons-06-teacher.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
audit-buttons-07-student.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
audit-buttons-08-modal.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
audit-buttons-calendar.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
audit-buttons-classrooms.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
audit-buttons-groups.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
audit-buttons-rules.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
audit-buttons-schedule-view.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
audit-buttons-settings-database.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
audit-buttons-settings-study-forms.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
audit-buttons-settings-time-slots.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
audit-buttons-structure.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
audit-buttons-subjects.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
audit-buttons-users.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
audit-buttons-workload.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
@@ -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<TenantConfig> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenantConfig> 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();
|
||||
}
|
||||
|
||||
@@ -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<TenantConfig> 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<>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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) {
|
||||
|
||||
@@ -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<UserResponse> 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<Long, User> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> handleBadRequest(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.warn("Некорректный запрос {} {}: {}", request.getMethod(), request.getRequestURI(), exception.getMessage());
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
log.error("Необработанная ошибка {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера", request);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> 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<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<StudentGroup> 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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +41,7 @@ public class ScheduleController {
|
||||
.body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId"));
|
||||
}
|
||||
|
||||
try {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(
|
||||
return ResponseEntity.ok(scheduleQueryService.search(
|
||||
groupId,
|
||||
teacherId,
|
||||
null,
|
||||
@@ -54,15 +52,6 @@ public class ScheduleController {
|
||||
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", "Произошла ошибка при генерации расписания"));
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +39,6 @@ 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,
|
||||
@@ -53,11 +51,5 @@ public class ScheduleSearchController {
|
||||
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", "Произошла ошибка при поиске расписания"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,16 @@ public class SpecialityController {
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/profiles")
|
||||
public List<SpecialtyProfileDto> 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)) {
|
||||
|
||||
@@ -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,75 +51,31 @@ public class UserController {
|
||||
@GetMapping
|
||||
public List<UserResponse> getAllUsers(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение всех пользователей");
|
||||
try {
|
||||
List<User> users = includeArchived
|
||||
? userRepository.findAll()
|
||||
: userRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
List<UserResponse> 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;
|
||||
})
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} пользователей", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
|
||||
public List<UserResponse> getTeachers() {
|
||||
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||
|
||||
try {
|
||||
List<User> users = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED);
|
||||
|
||||
List<UserResponse> 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;
|
||||
})
|
||||
List<UserResponse> response = userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
logger.info("Получено {} преподавателей", response.size());
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@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<User> users = userRepository.findByRoleAndDepartmentIdAndStatusNot(
|
||||
Role.TEACHER,
|
||||
departmentId,
|
||||
@@ -134,32 +89,15 @@ public class UserController {
|
||||
}
|
||||
|
||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||
|
||||
List<UserResponse> userResponses = users.stream()
|
||||
.map( user -> {
|
||||
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
user.getDepartmentId()
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return ResponseEntity.ok(userResponses);
|
||||
} catch (Exception e) {
|
||||
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Произошла ошибка при получении списка преподавателей");
|
||||
}
|
||||
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);
|
||||
@@ -193,7 +131,7 @@ public class UserController {
|
||||
Role role;
|
||||
try {
|
||||
role = Role.valueOf(request.getRole());
|
||||
} catch (Exception e) {
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||
}
|
||||
@@ -221,13 +159,7 @@ public class UserController {
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public record RenderedLessonDto(
|
||||
String activityType,
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ public class UserResponse {
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
public void setDepartmentId(Long departmentId) {
|
||||
this.departmentId = departmentId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
+ "\" должен быть лекцией, практикой или лабораторной работой");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
||||
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
@@ -58,8 +56,10 @@ public class ScheduleGeneratorService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> 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<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> 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<ScheduleRule> rules,
|
||||
LocalDate rangeStart,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress) {
|
||||
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ScheduleRule> 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<String, Integer> consumedHoursProgress,
|
||||
List<RenderedLessonDto> 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<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
@@ -193,8 +229,6 @@ public class ScheduleGeneratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Integer> 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<ActiveLessonScope> 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,6 +264,7 @@ public class ScheduleGeneratorService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
||||
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
||||
activeScopes.forEach(scope -> {
|
||||
@@ -252,9 +284,10 @@ public class ScheduleGeneratorService {
|
||||
|
||||
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,60 +308,18 @@ public class ScheduleGeneratorService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
|
||||
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
||||
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
||||
}
|
||||
|
||||
private String consumptionProgressKey(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<StudentGroup> 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);
|
||||
return rule.getId()
|
||||
+ ":" + consumptionBucket(category, subgroupId)
|
||||
+ ":" + (groupId == null ? "all" : groupId);
|
||||
}
|
||||
|
||||
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||
@@ -415,7 +406,8 @@ public class ScheduleGeneratorService {
|
||||
"Т",
|
||||
lessonTypeAcademicHours,
|
||||
consumedBeforeLesson,
|
||||
remainingAfterLesson
|
||||
remainingAfterLesson,
|
||||
slot.getParity()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RenderedLessonDto> search(Long groupId,
|
||||
@@ -37,10 +43,16 @@ public class ScheduleQueryService {
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
||||
List<RenderedLessonDto> generatedLessons = groups.stream()
|
||||
validateRange(startDate, endDate);
|
||||
List<RenderedLessonDto> generatedLessons;
|
||||
if (groupId == null && departmentId == null && teacherId != null) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
|
||||
generatedLessons = groups.stream()
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
||||
.toList();
|
||||
}
|
||||
List<RenderedLessonDto> 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<StudentGroup> 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<StudentGroup> 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<StudentGroup> 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<RenderedLessonDto> deduplicate(List<RenderedLessonDto> lessons) {
|
||||
@@ -239,7 +274,8 @@ public class ScheduleQueryService {
|
||||
source.activityType(),
|
||||
source.lessonTypeAcademicHours(),
|
||||
source.consumedLessonTypeAcademicHoursBeforeLesson(),
|
||||
source.remainingLessonTypeAcademicHoursAfterLesson()
|
||||
source.remainingLessonTypeAcademicHoursAfterLesson(),
|
||||
source.ruleParity()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Integer> 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<Integer> courseLimit) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
|
||||
return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
|
||||
}
|
||||
|
||||
private Optional<Integer> 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());
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,28 @@ 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();
|
||||
|
||||
if (currentMonth >= 9) {
|
||||
return currentYear - yearStartStudy + 1;
|
||||
} else {
|
||||
return currentYear - yearStartStudy;
|
||||
return getActualCourse(yearStartStudy, LocalDate.now());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> body = (Map<String, Object>) 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();
|
||||
}
|
||||
}
|
||||
@@ -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<UserResponse> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<UserResponse> body = (List<UserResponse>) 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
2
backend/tenants.json → backend/tenants.example.json
Executable file → Normal file
@@ -4,6 +4,6 @@
|
||||
"domain": "default",
|
||||
"url": "jdbc:postgresql://db:5432/app_db",
|
||||
"username": "myuser",
|
||||
"password": "supersecretpassword"
|
||||
"password": "replace-with-local-password"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
52
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` | Внутренняя ошибка сервера |
|
||||
|
||||
@@ -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 на асинхронных участках.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
@@ -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` запрещает новые назначения, но не удаляет историю.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
420
docs/BUTTON_STYLING_PLAN.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# План стилизации кнопок
|
||||
|
||||
Дата аудита: 01.06.2026
|
||||
Среда аудита: `https://localhost`
|
||||
Метод: визуальный обход через Playwright, фиксация скриншотов и computed styles.
|
||||
|
||||
## 1. Краткое резюме
|
||||
|
||||
Интерфейс уже имеет узнаваемую базу: тёмная админ-панель, фиолетовый акцент, компактные таблицы и отдельные публичные кабинеты преподавателя/студента. Главная проблема не в отдельных кнопках, а в отсутствии единого контракта между смыслом действия и его визуальным видом.
|
||||
|
||||
Сейчас одинаковые классы могут выглядеть по-разному в разных SPA, а одинаковые действия могут получать разные цвета, размеры и плотность. Из-за этого пользователь каждый раз заново считывает интерфейс: где главное действие, где безопасное второстепенное, а где рискованное.
|
||||
|
||||
Цель новой структуры: закрепить единую кнопку как системный компонент с понятными ролями, размерами и правилами использования.
|
||||
|
||||
## 2. Что было проверено
|
||||
|
||||
| Раздел | Проверенные сценарии | Скриншот |
|
||||
|---|---|---|
|
||||
| Авторизация | Вход, показ пароля, переключение темы | [`audit-buttons-01-login.png`](../audit-buttons-01-login.png) |
|
||||
| Дашборд админки | Быстрые переходы, recheck, сайдбар | [`audit-buttons-02-dashboard.png`](../audit-buttons-02-dashboard.png) |
|
||||
| Пользователи | Создание пользователя, архивирование | [`audit-buttons-03-users-form.png`](../audit-buttons-03-users-form.png) |
|
||||
| Меню настроек | Выпадающее меню, выход, переход в настройки | [`audit-buttons-04-settings-menu.png`](../audit-buttons-04-settings-menu.png) |
|
||||
| Настройки | Навигация отдельной SPA | [`audit-buttons-05-settings-general.png`](../audit-buttons-05-settings-general.png) |
|
||||
| Кабинет преподавателя | Недели, сегодня, выход, тема | [`audit-buttons-06-teacher.png`](../audit-buttons-06-teacher.png) |
|
||||
| Кабинет студента | Недели, сегодня, выход, тема | [`audit-buttons-07-student.png`](../audit-buttons-07-student.png) |
|
||||
| Модальное окно | Создание ручной сетки, отмена, закрытие | [`audit-buttons-08-modal.png`](../audit-buttons-08-modal.png) |
|
||||
|
||||
Дополнительно были сохранены скриншоты отдельных разделов: календарный график, аудитории, группы, дисциплины, расписание, формы обучения, база данных, временные слоты.
|
||||
|
||||
## 3. Текущее состояние
|
||||
|
||||
### 3.1. Авторизация
|
||||
|
||||

|
||||
|
||||
Найдены кнопки:
|
||||
|
||||
| Кнопка | Текущая роль | Комментарий |
|
||||
|---|---|---|
|
||||
| `Войти` | Primary | Корректно считывается как главное действие экрана. |
|
||||
| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. |
|
||||
| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. |
|
||||
|
||||
Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру.
|
||||
|
||||
### 3.2. Дашборд админки
|
||||
|
||||

|
||||
|
||||
Найдены кнопки и ссылки-кнопки:
|
||||
|
||||
| Кнопка | Текущий вид | Проблема |
|
||||
|---|---|---|
|
||||
| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. |
|
||||
| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. |
|
||||
| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. |
|
||||
| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. |
|
||||
| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. |
|
||||
|
||||
Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию.
|
||||
|
||||
### 3.3. Формы и таблицы админки
|
||||
|
||||

|
||||
|
||||
Типичные кнопки:
|
||||
|
||||
| Кнопка | Смысл | Текущее впечатление |
|
||||
|---|---|---|
|
||||
| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. |
|
||||
| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. |
|
||||
| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. |
|
||||
| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. |
|
||||
|
||||
Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках.
|
||||
|
||||
### 3.4. Меню настроек и выход
|
||||
|
||||

|
||||
|
||||
Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль.
|
||||
|
||||
Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки.
|
||||
|
||||
### 3.5. Отдельная SPA настроек
|
||||
|
||||

|
||||
|
||||
В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему.
|
||||
|
||||
Пример различий:
|
||||
|
||||
| Класс | Основная админка | Настройки |
|
||||
|---|---|---|
|
||||
| `btn-primary` | Часто прозрачный фон, белый текст, визуальная заливка может приходить из другого слоя | Фиолетовая заливка `rgb(139, 92, 246)`, высота `40px`, жирность `700` |
|
||||
| `btn-delete` | Красный tint, высота часто `28px`, font-weight `400` | Красный tint, высота `40px`, font-weight `700` |
|
||||
| `btn-secondary` | Фиолетовый tint или светлая surface-кнопка | Нейтральная surface-кнопка |
|
||||
|
||||
Вывод: одинаковое имя класса не является дизайн-контрактом. Компонент кнопки нужно нормализовать на уровне токенов и модификаторов.
|
||||
|
||||
### 3.6. Кабинеты преподавателя и студента
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
В публичных кабинетах используется отдельная светлая система:
|
||||
|
||||
| Элемент | Преподаватель | Студент |
|
||||
|---|---|---|
|
||||
| `Сегодня` | Primary blue `rgb(29, 78, 216)` | Primary teal `rgb(15, 118, 110)` |
|
||||
| `Выйти` | Outline, белый фон, radius `8px` | Outline, белый фон, radius `8px` |
|
||||
| `‹` / `›` | Outline icon-like buttons | Outline icon-like buttons |
|
||||
| `Переключить тему` | Текстовый символ `◐` | Текстовый символ `◐` |
|
||||
|
||||
Проблема: одно действие `Сегодня` меняет цвет в зависимости от кабинета. Это допустимо для брендинга ролей, но плохо для системных кнопок. Ролевые цвета лучше использовать в расписании, бейджах и карточках, а не в базовой кнопочной иерархии.
|
||||
|
||||
### 3.7. Модальные окна
|
||||
|
||||

|
||||
|
||||
Найдены:
|
||||
|
||||
| Кнопка | Роль | Рекомендация |
|
||||
|---|---|---|
|
||||
| `Создать сетку` | Primary | Оставить primary. |
|
||||
| `Отмена` | Secondary/Ghost | Сделать ghost или secondary без акцента. |
|
||||
| `Закрыть` (`×`) | Icon close | Привести к icon-only button `40x40` или `36x36`, с `aria-label="Закрыть"`. |
|
||||
|
||||
Модальная структура в целом правильная, но её нужно подчинить тем же токенам, что и остальные кнопки.
|
||||
|
||||
## 4. Семантическая группировка найденных кнопок
|
||||
|
||||
### Primary: главное действие
|
||||
|
||||
Примеры: `Войти`, `Создать`, `Добавить`, `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот`, `Создать сетку`.
|
||||
|
||||
Правило: одна primary-кнопка на форму, модальное окно или смысловой блок. Если на экране несколько форм, primary может быть в каждой форме, но не должна превращаться в оформление всех кнопок подряд.
|
||||
|
||||
### Secondary: обычное второстепенное действие
|
||||
|
||||
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус`, `Преподаватели`, `Загрузить сетку`, `Заполнить диапазон`.
|
||||
|
||||
Правило: использовать для действий, которые помогают выполнить задачу, но не являются главным подтверждением.
|
||||
|
||||
### Tertiary / Ghost: тихое действие
|
||||
|
||||
Примеры: `Очистить`, `Очистить форму`, `Отмена`, `Назад в панель`, `Свернуть`, `Из формы`, `Все`, `Точечные изменения / Просмотр`.
|
||||
|
||||
Правило: использовать там, где действие должно быть доступно, но не конкурировать с сохранением или созданием.
|
||||
|
||||
### Danger: деструктивное действие
|
||||
|
||||
Примеры: `Удалить`.
|
||||
|
||||
Правило: только для необратимых или почти необратимых операций. Красный цвет должен быть зарезервирован именно под риск.
|
||||
|
||||
### Danger subtle: рискованное, но не удаление
|
||||
|
||||
Примеры: `Архив`, `Архивировать`, `Удалить сетку`, `Выйти`.
|
||||
|
||||
Правило: использовать для действий с негативным или сессионным эффектом, но без визуальной паники solid-red кнопки.
|
||||
|
||||
### Icon button: компактное служебное действие
|
||||
|
||||
Примеры: `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›`.
|
||||
|
||||
Правило: фиксированный квадратный размер, единое состояние hover/focus, обязательный доступный текст через `aria-label`.
|
||||
|
||||
### Navigation item: навигация, а не кнопка действия
|
||||
|
||||
Примеры: пункты сайдбара `Дашборд`, `Конструктор правил`, `Календарный график`, `Пользователи`, `Общие настройки`.
|
||||
|
||||
Правило: не смешивать навигацию с action button. У неё отдельные active, hover и focus-состояния.
|
||||
|
||||
## 5. Предлагаемая дизайн-система кнопок
|
||||
|
||||
### 5.1. Токены
|
||||
|
||||
| Токен | Светлая тема | Тёмная тема | Назначение |
|
||||
|---|---|---|---|
|
||||
| `--button-primary-bg` | `#4F46E5` | `#8B5CF6` | Основное действие |
|
||||
| `--button-primary-bg-hover` | `#4338CA` | `#7C3AED` | Hover primary |
|
||||
| `--button-primary-text` | `#FFFFFF` | `#FFFFFF` | Текст primary |
|
||||
| `--button-secondary-bg` | `#FFFFFF` | `rgba(255,255,255,0.04)` | Второстепенная поверхность |
|
||||
| `--button-secondary-bg-hover` | `#F1F5F9` | `rgba(255,255,255,0.08)` | Hover secondary |
|
||||
| `--button-secondary-border` | `#CBD5E1` | `rgba(255,255,255,0.10)` | Граница secondary |
|
||||
| `--button-secondary-text` | `#334155` | `#F8FAFC` | Текст secondary |
|
||||
| `--button-ghost-bg-hover` | `#F8FAFC` | `rgba(255,255,255,0.06)` | Hover ghost |
|
||||
| `--button-danger-bg` | `#DC2626` | `#DC2626` | Удаление |
|
||||
| `--button-danger-bg-hover` | `#B91C1C` | `#B91C1C` | Hover удаления |
|
||||
| `--button-danger-subtle-bg` | `#FEF2F2` | `rgba(239,68,68,0.13)` | Риск без solid-заливки |
|
||||
| `--button-danger-subtle-text` | `#B91C1C` | `#FECACA` | Текст risk subtle |
|
||||
| `--button-focus-ring` | `rgba(79,70,229,0.35)` | `rgba(139,92,246,0.45)` | Focus outline |
|
||||
|
||||
Примечание: для всего продукта лучше выбрать один primary-акцент. Для текущей тёмной админки логично оставить фиолетовый `#8B5CF6`, а для светлой темы использовать более читаемый `#4F46E5`. Ролевые цвета преподавателя и студента лучше вынести из кнопок в расписание и бейджи.
|
||||
|
||||
### 5.2. Размеры
|
||||
|
||||
| Размер | Высота | Padding | Шрифт | Где применять |
|
||||
|---|---:|---|---|---|
|
||||
| `sm` | `32px` | `0 12px` | `13px / 600` | Таблицы, компактные действия строк |
|
||||
| `md` | `40px` | `0 16px` | `14px / 600` | Формы, фильтры, модальные окна |
|
||||
| `lg` | `48px` | `0 20px` | `15px / 700` | Вход, главный CTA экрана |
|
||||
| `icon-sm` | `32x32` | `0` | icon `18px` | Табличные иконки |
|
||||
| `icon-md` | `40x40` | `0` | icon `20px` | Верхние панели, модалки, навигация недель |
|
||||
|
||||
Базовый `border-radius`: `8px`.
|
||||
|
||||
Почему `8px`: интерфейс административный и рабочий. Радиус `8px` сохраняет мягкость, но выглядит собраннее, чем повсеместные `10px`, особенно в таблицах и плотных формах.
|
||||
|
||||
### 5.3. Варианты
|
||||
|
||||
#### Primary
|
||||
|
||||
Использовать для главного подтверждения.
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Фон primary, белый текст, без видимой границы |
|
||||
| Hover | Фон темнее на 8-12% |
|
||||
| Focus | Видимый ring `2px` |
|
||||
| Disabled | Нейтральный фон, muted-текст, `cursor: not-allowed` |
|
||||
|
||||
Примеры: `Войти`, `Создать`, `Сохранить правило`, `Создать сетку`.
|
||||
|
||||
#### Secondary
|
||||
|
||||
Использовать для обычных действий рядом с primary.
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Surface-фон, тонкая граница, основной текст |
|
||||
| Hover | Чуть более светлая/контрастная поверхность |
|
||||
| Focus | Такой же focus ring, как у primary |
|
||||
| Disabled | Opacity `0.5` |
|
||||
|
||||
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`.
|
||||
|
||||
#### Ghost
|
||||
|
||||
Использовать для тихих действий.
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Прозрачный фон, muted/regular text |
|
||||
| Hover | Лёгкий surface-hover |
|
||||
| Focus | Ring без изменения иерархии |
|
||||
|
||||
Примеры: `Отмена`, `Очистить`, `Назад в панель`, `Свернуть`.
|
||||
|
||||
#### Danger
|
||||
|
||||
Использовать только для удаления.
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Красная заливка, белый текст |
|
||||
| Hover | Более тёмный красный |
|
||||
| Focus | Красный или общий focus ring |
|
||||
|
||||
Примеры: `Удалить`.
|
||||
|
||||
#### Danger subtle
|
||||
|
||||
Использовать для рискованных действий без полного удаления.
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Красный tint-фон, красный текст, мягкая border |
|
||||
| Hover | Более насыщенный tint |
|
||||
| Focus | Видимый ring |
|
||||
|
||||
Примеры: `Архивировать`, `Архив`, `Выйти`, `Удалить сетку`.
|
||||
|
||||
#### Icon button
|
||||
|
||||
Использовать для действий, где текст заменён иконкой.
|
||||
|
||||
| Параметр | Правило |
|
||||
|---|---|
|
||||
| Размер | `32x32` в таблицах, `40x40` в тулбарах |
|
||||
| Иконка | 18-20px, один набор иконок |
|
||||
| Текст | Только через `aria-label` |
|
||||
| Форма | Квадрат с radius `8px`, не круг, кроме специальных случаев avatar/fab |
|
||||
|
||||
Примеры: `Переключить тему`, `Показать пароль`, `Закрыть`, `Предыдущая неделя`, `Следующая неделя`.
|
||||
|
||||
## 6. Маппинг текущих кнопок на новую систему
|
||||
|
||||
| Текущие кнопки | Новый тип | Размер |
|
||||
|---|---|---|
|
||||
| `Войти` | `Primary` | `lg` |
|
||||
| `Создать`, `Добавить` | `Primary` | `md` |
|
||||
| `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот` | `Primary` | `md` |
|
||||
| `Создать сетку` | `Primary` | `md` |
|
||||
| `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус` | `Secondary` | `sm` в таблицах, `md` в формах |
|
||||
| `Преподаватели`, `Подгруппы`, `Календарь`, `Сетка` | `Secondary` | `sm` |
|
||||
| `Очистить`, `Очистить форму`, `Отмена`, `Свернуть`, `Из формы`, `Все` | `Ghost` | `sm` или `md` |
|
||||
| `Точечные изменения / Просмотр`, `Назад в панель` | `Ghost navigation` | `md` |
|
||||
| `Удалить` | `Danger` | `sm` в таблицах, `md` в модалках |
|
||||
| `Архив`, `Архивировать`, `Выйти`, `Удалить сетку` | `Danger subtle` | `sm` или `md` |
|
||||
| `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›` | `Icon button` | `icon-sm` или `icon-md` |
|
||||
| Пункты сайдбара | `Nav item` | отдельный nav-компонент |
|
||||
|
||||
## 7. Правила композиции
|
||||
|
||||
### Формы
|
||||
|
||||
Primary-кнопка должна стоять в конце формы или action row. Рядом с ней допустимы secondary/ghost-действия, но не ещё одна primary без причины.
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
| Сценарий | Порядок |
|
||||
|---|---|
|
||||
| Форма создания | `Очистить` как ghost, `Создать` как primary |
|
||||
| Форма редактирования | `Отмена` как ghost, `Сохранить` как primary |
|
||||
| Фильтр | `Сбросить` как ghost, `Показать` как secondary или primary, если это единственное действие блока |
|
||||
|
||||
### Таблицы
|
||||
|
||||
В строках таблицы использовать только `sm`.
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
1. Основное безопасное действие: `Изменить`, `Преподаватели`, `Подгруппы`.
|
||||
2. Дополнительное действие: `Календарь`, `Сетка`.
|
||||
3. Рискованное действие: `Архивировать` или `Удалить`.
|
||||
|
||||
Удаление лучше отделять визуально от остальных кнопок небольшим gap.
|
||||
|
||||
### Модальные окна
|
||||
|
||||
В модалке primary должен соответствовать заголовку и цели модалки.
|
||||
|
||||
Пример:
|
||||
|
||||
| Заголовок | Primary | Secondary/Ghost |
|
||||
|---|---|---|
|
||||
| `Новая ручная сетка` | `Создать сетку` | `Отмена` |
|
||||
| `Удалить слот` | `Удалить` как danger | `Отмена` |
|
||||
| `Редактировать слот` | `Сохранить слот` | `Отмена` |
|
||||
|
||||
Кнопка закрытия всегда icon-only с `aria-label="Закрыть"`.
|
||||
|
||||
### Навигация
|
||||
|
||||
Сайдбарные пункты не должны наследовать стили action-кнопок. У них отдельная логика:
|
||||
|
||||
| Состояние | Стиль |
|
||||
|---|---|
|
||||
| Default | Прозрачный фон, muted-текст |
|
||||
| Hover | Нейтральный surface-hover |
|
||||
| Active | Акцентный tint-фон, accent-текст |
|
||||
| Focus | Видимый ring/outline |
|
||||
|
||||
## 8. Главные исправления по приоритету
|
||||
|
||||
| Приоритет | Что сделать | Почему важно |
|
||||
|---|---|---|
|
||||
| P1 | Нормализовать `btn-primary`, `btn-secondary`, `btn-delete` между основной админкой и настройками | Один класс должен означать один визуальный контракт |
|
||||
| P1 | Развести `Danger` и `Danger subtle` | `Удалить` и `Архивировать` не должны выглядеть одинаково опасными |
|
||||
| P1 | Ввести размерную шкалу `sm/md/lg/icon` | Таблицы и формы станут визуально ровнее |
|
||||
| P2 | Унифицировать `Переключить тему`, `Закрыть`, `‹`, `›`, `Показать пароль` как icon buttons | Повысит предсказуемость и доступность |
|
||||
| P2 | Убрать ролевые primary-цвета из student/teacher кнопок | Одинаковые действия будут считываться одинаково |
|
||||
| P3 | Заменить текстовые символы `◐`, `×`, `‹`, `›` на иконки единого набора | Интерфейс станет аккуратнее и доступнее |
|
||||
|
||||
## 9. Визуальный референс поведения
|
||||
|
||||
```text
|
||||
Primary
|
||||
[ Сохранить правило ]
|
||||
Фон: акцентный, текст белый. Только главное действие.
|
||||
|
||||
Secondary
|
||||
[ Изменить ]
|
||||
Фон: поверхность, тонкая граница. Обычное действие.
|
||||
|
||||
Ghost
|
||||
[ Отмена ]
|
||||
Фон: прозрачный. Тихое действие без конкуренции.
|
||||
|
||||
Danger
|
||||
[ Удалить ]
|
||||
Фон: красный. Только необратимое действие.
|
||||
|
||||
Danger subtle
|
||||
[ Архивировать ]
|
||||
Фон: красный tint. Рискованное, но не полноценное удаление.
|
||||
|
||||
Icon
|
||||
[ icon ]
|
||||
Фиксированный квадрат. Доступное название через aria-label.
|
||||
```
|
||||
|
||||
## 10. Почему это решение улучшит интерфейс
|
||||
|
||||
Единая кнопочная система снижает когнитивную нагрузку. Пользователь перестаёт разгадывать каждый экран и начинает быстро узнавать роли: главное действие, второстепенное действие, отмена, удаление, навигация.
|
||||
|
||||
Разделение `Danger` и `Danger subtle` особенно важно для админ-панели. Сейчас `Архивировать` визуально конкурирует с `Удалить`, хотя риск у этих действий разный. После унификации красный solid будет означать только настоящее удаление.
|
||||
|
||||
Единая размерная шкала сделает таблицы спокойнее. В строках не будет скачков высоты и плотности, а формы и модалки начнут выглядеть как части одной системы.
|
||||
|
||||
Общий primary-цвет вернёт продукту целостность. Ролевые цвета можно сохранить там, где они помогают считывать расписание и принадлежность данных, но базовые действия должны оставаться стабильными.
|
||||
|
||||
## 11. Приложение: дополнительные скриншоты
|
||||
|
||||
| Раздел | Скриншот |
|
||||
|---|---|
|
||||
| Конструктор правил |  |
|
||||
| Календарный график |  |
|
||||
| Просмотр расписаний |  |
|
||||
| Аудиторный фонд |  |
|
||||
| Группы обучения |  |
|
||||
| Структура вуза |  |
|
||||
| Дисциплины |  |
|
||||
| Пользователи |  |
|
||||
| Временные слоты |  |
|
||||
| Формы обучения |  |
|
||||
| База данных |  |
|
||||
@@ -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` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
<link rel="stylesheet" href="css/modals.css">
|
||||
<link rel="stylesheet" href="css/departments-data.css">
|
||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||
<style>
|
||||
.sidebar.collapsed .sidebar-section-title {
|
||||
display: none !important;
|
||||
}
|
||||
.sidebar-section-divider {
|
||||
height: 1px;
|
||||
background: rgba(229, 231, 235, 0.1);
|
||||
margin: 0.5rem 1.5rem;
|
||||
}
|
||||
.sidebar.collapsed .sidebar-section-divider {
|
||||
margin: 0.5rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -36,79 +49,24 @@
|
||||
</svg>
|
||||
<span>Magistr</span>
|
||||
</div>
|
||||
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item" data-tab="users">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="departments-data">
|
||||
<!-- Главная -->
|
||||
<a href="#" class="nav-item" data-tab="dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
<rect x="3" y="3" width="7" height="9" />
|
||||
<rect x="14" y="3" width="7" height="5" />
|
||||
<rect x="14" y="12" width="7" height="9" />
|
||||
<rect x="3" y="16" width="7" height="5" />
|
||||
</svg>
|
||||
<span>Создание кафедры/специальности</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="profiles">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
<path d="M16 11.5 18 14l3-5"></path>
|
||||
</svg>
|
||||
<span>Профили обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="groups">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
<span>Группы</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
<line x1="9" y1="7" x2="17" y2="7" />
|
||||
<line x1="9" y1="11" x2="15" y2="11" />
|
||||
</svg>
|
||||
<span>Формы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="equipments">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||
</svg>
|
||||
<span>Оборудование</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="classrooms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
<span>Аудитории</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="subjects">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
<span>Дисциплины</span>
|
||||
<span>Дашборд</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="department-workspace">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -120,14 +78,11 @@
|
||||
</svg>
|
||||
<span>Кабинет кафедры</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="schedule-view">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>Просмотр расписаний</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Расписание -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="schedule">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
@@ -135,7 +90,7 @@
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
<span>Расписание занятий</span>
|
||||
<span>Конструктор правил</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="academic-calendar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -155,13 +110,63 @@
|
||||
</svg>
|
||||
<span>Загруженность</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="database">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
<a href="#" class="nav-item" data-tab="schedule-view">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>База данных</span>
|
||||
<span>Просмотр расписаний</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Субъекты и ресурсы -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="classrooms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
<span>Аудиторный фонд</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="groups">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
<span>Группы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="university-structure">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
<span>Структура вуза</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="subjects">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
<span>Дисциплины</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Система -->
|
||||
<div class="sidebar-section-divider"></div>
|
||||
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
|
||||
|
||||
<a href="#" class="nav-item" data-tab="users">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
@@ -188,7 +193,7 @@
|
||||
Общие настройки
|
||||
</a>
|
||||
<div class="settings-menu-divider"></div>
|
||||
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
@@ -208,7 +213,7 @@
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
|
||||
@@ -92,6 +92,41 @@ export async function refreshAccessToken() {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
const CACHABLE_PREFIXES = [
|
||||
'/api/departments',
|
||||
'/api/specialties',
|
||||
'/api/education-forms',
|
||||
'/api/users/teachers',
|
||||
'/api/equipments',
|
||||
'/api/classrooms'
|
||||
];
|
||||
|
||||
function invalidateCache(url) {
|
||||
const cacheKeysToClear = [
|
||||
'/api/departments',
|
||||
'/api/specialties',
|
||||
'/api/education-forms',
|
||||
'/api/users',
|
||||
'/api/equipments',
|
||||
'/api/classrooms'
|
||||
];
|
||||
for (const prefix of cacheKeysToClear) {
|
||||
if (url.includes(prefix)) {
|
||||
for (const cacheKey of cache.keys()) {
|
||||
if (cacheKey.includes(prefix)) {
|
||||
cache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
@@ -102,6 +137,7 @@ export async function logout() {
|
||||
console.warn('Не удалось завершить серверную сессию:', e.message);
|
||||
} finally {
|
||||
clearAuthState();
|
||||
clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +157,26 @@ export function clearAuthState() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (url) => apiFetch(url, 'GET'),
|
||||
post: (url, body) => apiFetch(url, 'POST', body),
|
||||
put: (url, body) => apiFetch(url, 'PUT', body),
|
||||
delete: (url, body = null) => apiFetch(url, 'DELETE', body)
|
||||
get: async (url) => {
|
||||
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||
if (isCachable) {
|
||||
if (!cache.has(url)) {
|
||||
cache.set(url, await apiFetch(url, 'GET'));
|
||||
}
|
||||
return cache.get(url);
|
||||
}
|
||||
return apiFetch(url, 'GET');
|
||||
},
|
||||
post: async (url, body) => {
|
||||
invalidateCache(url);
|
||||
return apiFetch(url, 'POST', body);
|
||||
},
|
||||
put: async (url, body) => {
|
||||
invalidateCache(url);
|
||||
return apiFetch(url, 'PUT', body);
|
||||
},
|
||||
delete: async (url, body = null) => {
|
||||
invalidateCache(url);
|
||||
return apiFetch(url, 'DELETE', body);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,6 +99,35 @@ export class CustomSelect {
|
||||
this.menu.innerHTML = '';
|
||||
const options = Array.from(this.originalSelect.options);
|
||||
|
||||
// Show search input if more than 6 options exist
|
||||
if (options.length > 6) {
|
||||
const searchLi = document.createElement('li');
|
||||
searchLi.className = 'custom-select-search-container';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.placeholder = 'Поиск...';
|
||||
searchInput.className = 'custom-select-search-input';
|
||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
const items = this.menu.querySelectorAll('.custom-select-item');
|
||||
items.forEach(item => {
|
||||
if (item.classList.contains('placeholder-item')) return;
|
||||
const text = item.textContent.toLowerCase();
|
||||
if (text.includes(query)) {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
searchLi.appendChild(searchInput);
|
||||
this.menu.appendChild(searchLi);
|
||||
this.searchInput = searchInput;
|
||||
} else {
|
||||
this.searchInput = null;
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'custom-select-item disabled';
|
||||
@@ -171,7 +200,22 @@ export class CustomSelect {
|
||||
|
||||
if (!isOpen) {
|
||||
this.layerParent?.classList.add('select-layer-active');
|
||||
this.originalSelect.closest('.form-group, .form-field')?.classList.add('select-layer-active');
|
||||
this.wrapper.classList.add('open');
|
||||
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = '';
|
||||
const items = this.menu.querySelectorAll('.custom-select-item');
|
||||
items.forEach(item => {
|
||||
if (!item.classList.contains('placeholder-item')) {
|
||||
item.style.display = '';
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.searchInput.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Scroll selected item into view
|
||||
const selectedItem = this.menu.querySelector('.selected');
|
||||
if (selectedItem) {
|
||||
@@ -194,6 +238,7 @@ export class CustomSelect {
|
||||
close() {
|
||||
this.wrapper.classList.remove('open');
|
||||
this.layerParent?.classList.remove('select-layer-active');
|
||||
this.originalSelect.closest('.form-group, .form-field')?.classList.remove('select-layer-active');
|
||||
}
|
||||
|
||||
handleItemClick(e, index) {
|
||||
|
||||
@@ -20,47 +20,106 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
startDropdownAutoObserver();
|
||||
});
|
||||
|
||||
import { initUsers } from './views/users.js';
|
||||
import { initGroups } from './views/groups.js';
|
||||
import { initEduForms } from './views/edu-forms.js';
|
||||
import { initEquipments } from './views/equipments.js';
|
||||
import { initClassrooms } from './views/classrooms.js';
|
||||
import { initSubjects } from './views/subjects.js';
|
||||
import {initSchedule} from "./views/schedule.js";
|
||||
import {initDatabase} from "./views/database.js";
|
||||
import {initDepartmentsData} from "./views/departments-data.js";
|
||||
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
|
||||
import {initProfiles} from "./views/profiles.js";
|
||||
import {initAcademicCalendar} from "./views/academic-calendar.js";
|
||||
import {initScheduleView} from "./views/schedule-view.js";
|
||||
import {initDepartmentWorkspace} from "./views/department-workspace.js";
|
||||
|
||||
// Configuration
|
||||
const ROUTES = {
|
||||
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers },
|
||||
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups },
|
||||
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms },
|
||||
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments },
|
||||
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms },
|
||||
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects },
|
||||
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace },
|
||||
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView },
|
||||
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule },
|
||||
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar },
|
||||
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload },
|
||||
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase },
|
||||
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData },
|
||||
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles },
|
||||
dashboard: {
|
||||
title: 'Дашборд',
|
||||
file: '/admin/views/dashboard.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/dashboard.js');
|
||||
await module.initDashboard();
|
||||
}
|
||||
},
|
||||
users: {
|
||||
title: 'Управление пользователями',
|
||||
file: '/admin/views/users.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/users.js');
|
||||
await module.initUsers();
|
||||
}
|
||||
},
|
||||
groups: {
|
||||
title: 'Управление группами',
|
||||
file: '/admin/views/groups.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/groups.js');
|
||||
await module.initGroups();
|
||||
}
|
||||
},
|
||||
classrooms: {
|
||||
title: 'Аудиторный фонд',
|
||||
file: '/admin/views/classrooms.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/classrooms.js');
|
||||
await module.initClassrooms();
|
||||
}
|
||||
},
|
||||
subjects: {
|
||||
title: 'Дисциплины',
|
||||
file: '/admin/views/subjects.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/subjects.js');
|
||||
await module.initSubjects();
|
||||
}
|
||||
},
|
||||
'university-structure': {
|
||||
title: 'Структура вуза',
|
||||
file: '/admin/views/university-structure.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/university-structure.js');
|
||||
await module.initUniversityStructure();
|
||||
}
|
||||
},
|
||||
'department-workspace': {
|
||||
title: 'Кабинет кафедры',
|
||||
file: '/admin/views/department-workspace.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/department-workspace.js');
|
||||
await module.initDepartmentWorkspace();
|
||||
}
|
||||
},
|
||||
'schedule-view': {
|
||||
title: 'Просмотр расписаний',
|
||||
file: '/admin/views/schedule-view.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule-view.js');
|
||||
await module.initScheduleView();
|
||||
}
|
||||
},
|
||||
schedule: {
|
||||
title: 'Расписание занятий',
|
||||
file: '/admin/views/schedule.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/schedule.js');
|
||||
await module.initSchedule();
|
||||
}
|
||||
},
|
||||
'academic-calendar': {
|
||||
title: 'Календарный учебный график',
|
||||
file: '/admin/views/academic-calendar.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/academic-calendar.js');
|
||||
await module.initAcademicCalendar();
|
||||
}
|
||||
},
|
||||
'auditorium-workload': {
|
||||
title: 'Загруженность',
|
||||
file: '/admin/views/auditorium-workload.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/auditorium-workload.js');
|
||||
await module.initAuditoriumWorkload();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_NAVIGATION = {
|
||||
ADMIN: {
|
||||
defaultTab: 'users',
|
||||
tabs: Object.keys(ROUTES)
|
||||
defaultTab: 'dashboard',
|
||||
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
|
||||
},
|
||||
EDUCATION_OFFICE: {
|
||||
defaultTab: 'schedule-view',
|
||||
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments']
|
||||
defaultTab: 'dashboard',
|
||||
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
|
||||
},
|
||||
DEPARTMENT: {
|
||||
defaultTab: 'department-workspace',
|
||||
@@ -176,10 +235,13 @@ async function switchTab(tab) {
|
||||
|
||||
// Initialize logic for the tab
|
||||
if (ROUTES[tab].init) {
|
||||
ROUTES[tab].init();
|
||||
await ROUTES[tab].init();
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
if (window.location.hash !== `#${tab}`) {
|
||||
window.location.hash = tab;
|
||||
}
|
||||
} catch (e) {
|
||||
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
|
||||
console.error(e);
|
||||
@@ -211,3 +273,10 @@ function configureRoleInterface() {
|
||||
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
|
||||
switchTab(hashTab);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export function hideAlert(elementId) {
|
||||
|
||||
export function applyRippleEffect() {
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-primary, .btn-delete, .btn-logout');
|
||||
const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
|
||||
if (!btn) return;
|
||||
|
||||
const rect = btn.getBoundingClientRect();
|
||||
|
||||
@@ -217,12 +217,14 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
academicYearsTbody.innerHTML = academicYears.map(year => `
|
||||
<tr>
|
||||
<td>${escapeHtml(year.title)}</td>
|
||||
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
|
||||
<td>${renderSemesterList(year.semesters || [])}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
|
||||
<td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
|
||||
<td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
|
||||
<td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
|
||||
<td class="academic-year-actions-cell">
|
||||
<div class="academic-year-actions">
|
||||
<button class="btn btn-sm btn-secondary btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-year" data-id="${year.id}">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -230,12 +232,19 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function renderSemesterList(items) {
|
||||
if (!items.length) return '<span class="muted">Нет семестров</span>';
|
||||
return items.map(semester => `
|
||||
<div class="schedule-semester-line">
|
||||
${escapeHtml(semesterLabel(semester))}
|
||||
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
|
||||
return `<div class="academic-year-semesters">${items.map(semester => {
|
||||
const title = semesterTypeTitle(semester);
|
||||
const period = formatDisplayDateRange(semester.startDate, semester.endDate);
|
||||
return `
|
||||
<div class="academic-year-semester-item">
|
||||
<div class="academic-year-semester-meta">
|
||||
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
|
||||
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
<button class="btn btn-sm btn-secondary btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function saveAcademicYear(event) {
|
||||
@@ -282,7 +291,7 @@ export async function initAcademicCalendar() {
|
||||
if (editSemesterButton) {
|
||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||
if (!semester) return;
|
||||
semesterFormTitle.textContent = 'Редактирование семестра';
|
||||
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||||
semesterIdInput.value = semester.id;
|
||||
semesterYearSelect.value = semester.academicYearId || '';
|
||||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||||
@@ -391,9 +400,9 @@ export async function initAcademicCalendar() {
|
||||
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
|
||||
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -580,39 +589,56 @@ export async function initAcademicCalendar() {
|
||||
function renderCourseSemesters(course, courseRows) {
|
||||
const calendar = selectedEditorCalendar();
|
||||
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
|
||||
const maxWeekColumns = Math.max(
|
||||
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
|
||||
1
|
||||
);
|
||||
return semestersForCourse.map(block => `
|
||||
<section class="calendar-semester-block">
|
||||
<div class="calendar-semester-header">
|
||||
<h4>${block.semesterNumber} семестр</h4>
|
||||
<span>${escapeHtml(block.caption)}</span>
|
||||
</div>
|
||||
${renderSemesterTable(block.rows)}
|
||||
${renderSemesterTable(block.rows, maxWeekColumns)}
|
||||
</section>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSemesterTable(rows) {
|
||||
function renderSemesterTable(rows, targetWeekColumns = 0) {
|
||||
if (!rows.length) {
|
||||
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
|
||||
}
|
||||
|
||||
const rowsByWeek = groupBy(rows, row => row.weekNumber);
|
||||
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
|
||||
const weekSlots = [...weeks];
|
||||
while (weekSlots.length < targetWeekColumns) {
|
||||
weekSlots.push(null);
|
||||
}
|
||||
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
|
||||
return `
|
||||
<div class="calendar-semester-table-wrap">
|
||||
<table class="calendar-semester-table">
|
||||
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
|
||||
<colgroup>
|
||||
<col class="calendar-day-head-col">
|
||||
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="calendar-day-head">День</th>
|
||||
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
|
||||
${weekSlots.map(week => week === null
|
||||
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
|
||||
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${DAY_OPTIONS.map(day => `
|
||||
<tr>
|
||||
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
|
||||
${weeks.map(week => {
|
||||
${weekSlots.map(week => {
|
||||
if (week === null) {
|
||||
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
|
||||
}
|
||||
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
||||
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
||||
}).join('')}
|
||||
@@ -624,6 +650,10 @@ export async function initAcademicCalendar() {
|
||||
`;
|
||||
}
|
||||
|
||||
function uniqueWeekNumbers(rows) {
|
||||
return Array.from(new Set(rows.map(row => row.weekNumber)));
|
||||
}
|
||||
|
||||
function renderCalendarDay(row) {
|
||||
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
||||
const dayName = shortDayLabel(row.dayOfWeek);
|
||||
@@ -870,7 +900,7 @@ export async function initAcademicCalendar() {
|
||||
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
|
||||
return `
|
||||
<div class="calendar-day-tooltip-head">
|
||||
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}">${escapeHtml(code)}</span>
|
||||
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
|
||||
<div>
|
||||
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
|
||||
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
|
||||
@@ -1143,7 +1173,13 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function semesterLabel(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
|
||||
}
|
||||
|
||||
function semesterTypeTitle(semester) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function normalizeSemesterType(type) {
|
||||
@@ -1174,6 +1210,16 @@ export async function initAcademicCalendar() {
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
if (!year || !month || !day) return String(value || '');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
function formatDisplayDateRange(startDate, endDate) {
|
||||
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||||
}
|
||||
|
||||
function dateRangeCaption(rows) {
|
||||
if (!rows.length) return '';
|
||||
const dates = rows.map(row => row.date).sort();
|
||||
|
||||
@@ -68,13 +68,6 @@ const CELL_PARITY_LAYOUT = [
|
||||
{ parity: 'EVEN', label: 'Чётная' }
|
||||
];
|
||||
|
||||
const CAPACITY_BUCKETS = [
|
||||
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
|
||||
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
|
||||
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
|
||||
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
|
||||
];
|
||||
|
||||
export async function initAuditoriumWorkload() {
|
||||
const dateInput = document.getElementById('workload-date');
|
||||
const refreshButton = document.getElementById('workload-refresh');
|
||||
@@ -89,6 +82,7 @@ export async function initAuditoriumWorkload() {
|
||||
const roomTables = document.getElementById('room-workload-tables');
|
||||
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
|
||||
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
|
||||
const minCapacityInput = document.getElementById('workload-min-capacity');
|
||||
|
||||
let classrooms = [];
|
||||
let teachers = [];
|
||||
@@ -117,7 +111,6 @@ export async function initAuditoriumWorkload() {
|
||||
|
||||
function initFilters() {
|
||||
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
||||
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
|
||||
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
||||
}
|
||||
|
||||
@@ -142,7 +135,6 @@ export async function initAuditoriumWorkload() {
|
||||
|
||||
[
|
||||
['building-checkboxes', 'building-text', 'Все корпуса'],
|
||||
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
|
||||
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
|
||||
].forEach(([containerId, textId, emptyText]) => {
|
||||
const container = document.getElementById(containerId);
|
||||
@@ -153,6 +145,12 @@ export async function initAuditoriumWorkload() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
minCapacityInput?.addEventListener('input', () => {
|
||||
if (currentView === DISPLAY_ALL) {
|
||||
renderGrid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
@@ -345,12 +343,6 @@ export async function initAuditoriumWorkload() {
|
||||
item => item.value,
|
||||
item => item.label
|
||||
);
|
||||
renderCheckboxes(
|
||||
'capacity-checkboxes',
|
||||
CAPACITY_BUCKETS,
|
||||
item => item.id,
|
||||
item => item.name
|
||||
);
|
||||
renderCheckboxes(
|
||||
'equipment-checkboxes',
|
||||
equipmentOptions(),
|
||||
@@ -359,7 +351,6 @@ export async function initAuditoriumWorkload() {
|
||||
);
|
||||
|
||||
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
|
||||
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
|
||||
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
|
||||
}
|
||||
|
||||
@@ -934,14 +925,12 @@ export async function initAuditoriumWorkload() {
|
||||
|
||||
function filteredClassrooms() {
|
||||
const selectedBuildings = selectedValues('building-checkboxes');
|
||||
const selectedCapacities = selectedValues('capacity-checkboxes');
|
||||
const minCapacity = parseInt(minCapacityInput?.value || '0', 10) || 0;
|
||||
const selectedEquipment = selectedValues('equipment-checkboxes');
|
||||
|
||||
return classrooms.filter(room => {
|
||||
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
|
||||
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS
|
||||
.filter(bucket => selectedCapacities.includes(bucket.id))
|
||||
.some(bucket => bucket.match(room.capacity));
|
||||
const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
|
||||
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
|
||||
(room.equipments || []).some(item => String(item.id) === String(id))
|
||||
);
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
||||
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
|
||||
|
||||
async function fetchEquipments() {
|
||||
try {
|
||||
return await api.get('/api/equipments');
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch equipments", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!equipments || !equipments.length) {
|
||||
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
|
||||
updateSelectText(containerId, selectTextId);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = equipments.map(eq => `
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
|
||||
<span>${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
updateSelectText(containerId, selectTextId);
|
||||
}
|
||||
|
||||
export async function initClassrooms() {
|
||||
const classroomsTbody = document.getElementById('classrooms-tbody');
|
||||
@@ -59,16 +87,16 @@ export async function initClassrooms() {
|
||||
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
|
||||
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
|
||||
</span>
|
||||
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
|
||||
<button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
|
||||
${c.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -97,9 +125,9 @@ export async function initClassrooms() {
|
||||
});
|
||||
|
||||
classroomsTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete');
|
||||
const btnDelete = e.target.closest('.btn-archive-classroom');
|
||||
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
|
||||
const btnEdit = e.target.closest('.btn-edit-classroom');
|
||||
const btnEdit = e.target.closest('.classroom-edit-action');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
|
||||
@@ -191,5 +219,82 @@ export async function initClassrooms() {
|
||||
}
|
||||
});
|
||||
|
||||
// Элементы модалки оборудования
|
||||
const btnManageEquipments = document.getElementById('btn-manage-equipments');
|
||||
const modalManageEquipments = document.getElementById('modal-manage-equipments');
|
||||
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
|
||||
const manageEquipmentForm = document.getElementById('manage-equipment-form');
|
||||
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
|
||||
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
|
||||
|
||||
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
|
||||
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
|
||||
modalManageEquipments.addEventListener('click', (e) => {
|
||||
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
|
||||
});
|
||||
|
||||
async function openManageEquipmentsModal() {
|
||||
hideAlert('manage-equipment-alert');
|
||||
manageEquipmentForm.reset();
|
||||
modalManageEquipments.classList.add('open');
|
||||
await loadManageEquipments();
|
||||
}
|
||||
|
||||
async function loadManageEquipments() {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
allEquipments = await fetchEquipments();
|
||||
renderManageEquipmentsTable();
|
||||
// Обновим чекбоксы на основной странице
|
||||
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
|
||||
} catch (e) {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderManageEquipmentsTable() {
|
||||
if (!allEquipments || !allEquipments.length) {
|
||||
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
|
||||
<tr>
|
||||
<td>${eq.id}</td>
|
||||
<td>${escapeHtml(eq.name)}</td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
manageEquipmentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('manage-equipment-alert');
|
||||
const name = manageEquipmentNameInput.value.trim();
|
||||
if (!name) {
|
||||
showAlert('manage-equipment-alert', 'Введите название', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.post('/api/equipments', { name });
|
||||
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
|
||||
manageEquipmentForm.reset();
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
manageEquipmentTbody.addEventListener('click', async (e) => {
|
||||
const btnDelete = e.target.closest('.btn-delete-equipment');
|
||||
if (!btnDelete) return;
|
||||
if (!confirm('Удалить оборудование из каталога?')) return;
|
||||
try {
|
||||
await api.delete('/api/equipments/' + btnDelete.dataset.id);
|
||||
await loadManageEquipments();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Ошибка удаления');
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialData();
|
||||
}
|
||||
|
||||
322
frontend/admin/js/views/dashboard.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
export async function initDashboard() {
|
||||
// DOM Элементы
|
||||
const metricGroups = document.getElementById('metric-groups');
|
||||
const metricStudents = document.getElementById('metric-students');
|
||||
const metricTeachers = document.getElementById('metric-teachers');
|
||||
const metricClassrooms = document.getElementById('metric-classrooms');
|
||||
|
||||
const currentTimeSlot = document.getElementById('current-time-slot');
|
||||
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
|
||||
const freeClassroomsList = document.getElementById('free-classrooms-list');
|
||||
const conflictsContainer = document.getElementById('conflicts-container');
|
||||
const btnRecheck = document.getElementById('btn-recheck-conflicts');
|
||||
|
||||
let departments = [];
|
||||
let classrooms = [];
|
||||
let groups = [];
|
||||
let teachers = [];
|
||||
let todayLessons = [];
|
||||
let timeSlots = [];
|
||||
|
||||
btnRecheck.addEventListener('click', async () => {
|
||||
btnRecheck.disabled = true;
|
||||
btnRecheck.textContent = '...';
|
||||
await checkConflicts();
|
||||
btnRecheck.textContent = 'Перепроверить';
|
||||
btnRecheck.disabled = false;
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
// Загружаем базовые справочники
|
||||
[departments, classrooms, groups, teachers] = await Promise.all([
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/classrooms?includeArchived=false'),
|
||||
api.get('/api/groups?includeArchived=false'),
|
||||
api.get('/api/users/teachers')
|
||||
]);
|
||||
|
||||
// Выводим метрики
|
||||
metricGroups.textContent = groups.length;
|
||||
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
|
||||
metricStudents.textContent = totalStudents;
|
||||
metricTeachers.textContent = teachers.length;
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Загружаем временные слоты и сегодняшнее расписание
|
||||
const lessonsPromises = departments.map(d =>
|
||||
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
|
||||
);
|
||||
const lessonsArrays = await Promise.all(lessonsPromises);
|
||||
todayLessons = lessonsArrays.flat();
|
||||
|
||||
try {
|
||||
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
|
||||
} catch (e) {
|
||||
// Если эндпоинт effective не поддерживается, используем базовые слоты
|
||||
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
|
||||
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
|
||||
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
|
||||
}
|
||||
|
||||
// Сортируем слоты по времени
|
||||
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||
|
||||
// Посчитаем занятость аудиторий сегодня
|
||||
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
|
||||
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||
const classPercent = activeClassrooms.length
|
||||
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
|
||||
: 0;
|
||||
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
||||
|
||||
updateRealtimeMonitoring();
|
||||
await checkConflicts();
|
||||
} catch (e) {
|
||||
console.error("Dashboard data load error", e);
|
||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRealtimeMonitoring() {
|
||||
const now = new Date();
|
||||
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
|
||||
|
||||
// Находим текущий временной слот
|
||||
let currentSlot = null;
|
||||
for (const slot of timeSlots) {
|
||||
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
|
||||
currentSlot = slot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSlot) {
|
||||
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`;
|
||||
renderCurrentLessons(currentSlot.orderNumber);
|
||||
renderFreeClassrooms(currentSlot.orderNumber);
|
||||
} else {
|
||||
currentTimeSlot.textContent = 'Вне сетки пар';
|
||||
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
|
||||
|
||||
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
|
||||
renderFreeClassrooms(null);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentLessons(slotOrder) {
|
||||
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
|
||||
if (!activeLessons.length) {
|
||||
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentLessonsTbody.innerHTML = activeLessons.map(l => `
|
||||
<tr>
|
||||
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
|
||||
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
|
||||
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
|
||||
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderFreeClassrooms(slotOrder) {
|
||||
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||
let occupiedIds = new Set();
|
||||
if (slotOrder !== null) {
|
||||
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
|
||||
}
|
||||
|
||||
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
|
||||
if (!free.length) {
|
||||
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
freeClassroomsList.innerHTML = free.map(c => `
|
||||
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
|
||||
${escapeHtml(c.name)} (${c.capacity} м.)
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// --- Анализ конфликтов (Red Zone) ---
|
||||
async function checkConflicts() {
|
||||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||||
|
||||
// Вычисляем даты начала и конца текущей недели
|
||||
const curr = new Date();
|
||||
const day = curr.getDay();
|
||||
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
|
||||
const monday = new Date(curr.setDate(diff));
|
||||
const sunday = new Date(curr.setDate(diff + 6));
|
||||
|
||||
const mondayStr = monday.toISOString().split('T')[0];
|
||||
const sundayStr = sunday.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
// Загружаем расписание на всю неделю по кафедрам
|
||||
const weekLessonsPromises = departments.map(d =>
|
||||
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
|
||||
);
|
||||
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
|
||||
const weekLessons = weekLessonsArrays.flat();
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
// Мапы для быстрого поиска характеристик групп и аудиторий
|
||||
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
|
||||
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
|
||||
|
||||
// 1. Проверка накладок преподавателей
|
||||
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
|
||||
const teacherSlots = new Map();
|
||||
weekLessons.forEach(l => {
|
||||
if (!l.teacherId) return;
|
||||
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
|
||||
if (!teacherSlots.has(key)) {
|
||||
teacherSlots.set(key, []);
|
||||
}
|
||||
teacherSlots.get(key).push(l);
|
||||
});
|
||||
|
||||
teacherSlots.forEach((lessons, key) => {
|
||||
if (lessons.length > 1) {
|
||||
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
|
||||
const classroomIds = new Set(lessons.map(l => l.classroomId));
|
||||
if (classroomIds.size > 1) {
|
||||
const first = lessons[0];
|
||||
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
|
||||
conflicts.push({
|
||||
type: 'TEACHER_COLLISION',
|
||||
title: 'Накладка в расписании преподавателя',
|
||||
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Проверка накладок аудиторий
|
||||
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
|
||||
const classroomSlots = new Map();
|
||||
weekLessons.forEach(l => {
|
||||
if (!l.classroomId) return;
|
||||
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
|
||||
if (!classroomSlots.has(key)) {
|
||||
classroomSlots.set(key, []);
|
||||
}
|
||||
classroomSlots.get(key).push(l);
|
||||
});
|
||||
|
||||
classroomSlots.forEach((lessons, key) => {
|
||||
if (lessons.length > 1) {
|
||||
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
|
||||
const teacherIds = new Set(lessons.map(l => l.teacherId));
|
||||
const subjectIds = new Set(lessons.map(l => l.subjectId));
|
||||
|
||||
if (teacherIds.size > 1 || subjectIds.size > 1) {
|
||||
const first = lessons[0];
|
||||
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
|
||||
conflicts.push({
|
||||
type: 'CLASSROOM_COLLISION',
|
||||
title: 'Накладка в аудитории',
|
||||
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Проверка превышения вместимости аудитории
|
||||
// Считаем сумму студентов для каждого уникального занятия в аудитории
|
||||
classroomSlots.forEach((lessons, key) => {
|
||||
if (!lessons.length) return;
|
||||
const first = lessons[0];
|
||||
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
|
||||
|
||||
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
|
||||
const groupIds = new Set();
|
||||
lessons.forEach(l => {
|
||||
if (l.groupIds) {
|
||||
l.groupIds.forEach(id => groupIds.add(id));
|
||||
}
|
||||
});
|
||||
|
||||
let totalStudents = 0;
|
||||
groupIds.forEach(id => {
|
||||
totalStudents += groupSizeMap.get(id) || 0;
|
||||
});
|
||||
|
||||
if (totalStudents > capacity) {
|
||||
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
|
||||
conflicts.push({
|
||||
type: 'CAPACITY_OVERFLOW',
|
||||
title: 'Превышение вместимости аудитории',
|
||||
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||
severity: 'warning'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
renderConflicts(conflicts);
|
||||
} catch (e) {
|
||||
console.error("Conflicts check error", e);
|
||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflicts(conflicts) {
|
||||
if (!conflicts.length) {
|
||||
conflictsContainer.innerHTML = `
|
||||
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
|
||||
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
conflictsContainer.innerHTML = conflicts.map(c => {
|
||||
const isError = c.severity === 'error';
|
||||
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
|
||||
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
|
||||
const badgeIcon = isError
|
||||
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
|
||||
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
|
||||
|
||||
return `
|
||||
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
|
||||
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
|
||||
<div style="flex: 1;">
|
||||
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
|
||||
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Загружаем данные при старте
|
||||
await loadData();
|
||||
|
||||
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
|
||||
const realtimeTimer = setInterval(() => {
|
||||
if (document.getElementById('current-time-slot')) {
|
||||
updateRealtimeMonitoring();
|
||||
} else {
|
||||
clearInterval(realtimeTimer);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export async function initDepartmentWorkspace() {
|
||||
const endInput = document.getElementById('department-workspace-end');
|
||||
const refreshButton = document.getElementById('department-workspace-refresh');
|
||||
const importForm = document.getElementById('department-subject-import-form');
|
||||
const importRowsContainer = document.getElementById('subject-import-rows');
|
||||
const importAddButton = document.getElementById('subject-import-add');
|
||||
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
||||
const subjectCount = document.getElementById('department-subject-count');
|
||||
const commentsCard = document.getElementById('department-comments-card');
|
||||
@@ -21,13 +23,26 @@ export async function initDepartmentWorkspace() {
|
||||
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
|
||||
const commentsTextInput = document.getElementById('department-comment-text');
|
||||
const commentsCloseButton = document.getElementById('department-comments-close');
|
||||
let lastTeachers = null;
|
||||
let lastWorkload = null;
|
||||
let teachersErrorMessage = '';
|
||||
let workloadErrorMessage = '';
|
||||
|
||||
setDefaultDates(startInput, endInput);
|
||||
refreshButton?.addEventListener('click', loadWorkspace);
|
||||
departmentSelect?.addEventListener('change', loadWorkspace);
|
||||
startInput?.addEventListener('change', loadWorkload);
|
||||
startInput?.addEventListener('change', handleStartDateChange);
|
||||
endInput?.addEventListener('change', loadWorkload);
|
||||
importForm?.addEventListener('submit', importSubjects);
|
||||
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
|
||||
importRowsContainer?.addEventListener('click', (event) => {
|
||||
const removeBtn = event.target.closest('.subject-import-remove');
|
||||
if (removeBtn) {
|
||||
const rows = readImportRows();
|
||||
rows.splice(Number(removeBtn.dataset.index), 1);
|
||||
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
|
||||
}
|
||||
});
|
||||
commentsForm?.addEventListener('submit', saveComment);
|
||||
commentsCloseButton?.addEventListener('click', () => {
|
||||
commentsCard.hidden = true;
|
||||
@@ -46,6 +61,7 @@ export async function initDepartmentWorkspace() {
|
||||
departmentField.hidden = true;
|
||||
departmentSelect.value = userDepartmentId || '';
|
||||
}
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
await loadWorkspace();
|
||||
} catch (error) {
|
||||
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
|
||||
@@ -53,6 +69,11 @@ export async function initDepartmentWorkspace() {
|
||||
|
||||
async function loadWorkspace() {
|
||||
hideAlert('department-workspace-alert');
|
||||
lastTeachers = null;
|
||||
lastWorkload = null;
|
||||
teachersErrorMessage = '';
|
||||
workloadErrorMessage = '';
|
||||
renderTeachers();
|
||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
|
||||
}
|
||||
|
||||
@@ -73,7 +94,7 @@ export async function initDepartmentWorkspace() {
|
||||
<td>${escapeHtml(subject.name || '-')}</td>
|
||||
<td>${escapeHtml(subject.description || '-')}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-edit-classroom department-comments-open"
|
||||
<button type="button" class="btn btn-sm btn-secondary department-comments-open"
|
||||
data-subject-id="${escapeHtml(subject.id)}"
|
||||
data-subject-name="${escapeHtml(subject.name || '')}">
|
||||
Открыть
|
||||
@@ -87,38 +108,57 @@ export async function initDepartmentWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderImportRows(rows) {
|
||||
if (!importRowsContainer) return;
|
||||
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
|
||||
<div class="subject-import-row" data-index="${index}">
|
||||
<div class="form-group">
|
||||
<label>Код</label>
|
||||
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function readImportRows() {
|
||||
if (!importRowsContainer) return [];
|
||||
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
|
||||
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
|
||||
name: (row.querySelector('.subject-import-name')?.value || '').trim()
|
||||
}));
|
||||
}
|
||||
|
||||
async function importSubjects(event) {
|
||||
event.preventDefault();
|
||||
const alertId = 'department-subject-import-alert';
|
||||
hideAlert(alertId);
|
||||
const departmentId = selectedDepartmentId();
|
||||
const rows = document.getElementById('department-subject-import-text').value
|
||||
.split('\n')
|
||||
.map(row => row.trim())
|
||||
.filter(Boolean)
|
||||
.map(row => {
|
||||
const [code, ...nameParts] = row.split(';');
|
||||
return {
|
||||
code: (code || '').trim(),
|
||||
name: nameParts.join(';').trim(),
|
||||
const rows = readImportRows()
|
||||
.filter(item => item.name)
|
||||
.map(item => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
departmentId: Number(departmentId)
|
||||
};
|
||||
})
|
||||
.filter(item => item.name);
|
||||
}));
|
||||
|
||||
if (!departmentId) {
|
||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error');
|
||||
showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/department/subjects/import', rows);
|
||||
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
|
||||
document.getElementById('department-subject-import-text').value = '';
|
||||
renderImportRows([{ code: '', name: '' }]);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
|
||||
@@ -172,25 +212,28 @@ export async function initDepartmentWorkspace() {
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
const container = document.getElementById('department-teachers-list');
|
||||
container.textContent = 'Загрузка...';
|
||||
lastTeachers = null;
|
||||
teachersErrorMessage = '';
|
||||
renderTeachers();
|
||||
try {
|
||||
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||
container.innerHTML = teachers.length
|
||||
? teachers.map(teacher => `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
|
||||
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Преподаватели не найдены</div>';
|
||||
lastTeachers = Array.isArray(teachers) ? teachers : [];
|
||||
renderTeachers();
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
|
||||
lastTeachers = [];
|
||||
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
|
||||
renderTeachers();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkload() {
|
||||
const container = document.getElementById('department-workload-list');
|
||||
if (!startInput.value || !endInput.value) {
|
||||
lastWorkload = [];
|
||||
workloadErrorMessage = 'Выберите начало и окончание периода';
|
||||
renderTeachers();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startDate: startInput.value,
|
||||
endDate: endInput.value
|
||||
@@ -198,23 +241,84 @@ export async function initDepartmentWorkspace() {
|
||||
if (selectedDepartmentId()) {
|
||||
params.set('departmentId', selectedDepartmentId());
|
||||
}
|
||||
container.textContent = 'Загрузка...';
|
||||
lastWorkload = null;
|
||||
workloadErrorMessage = '';
|
||||
renderTeachers();
|
||||
try {
|
||||
const workload = await api.get(`/api/workload/teachers?${params}`);
|
||||
container.innerHTML = workload.length
|
||||
? workload.map(item => `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
|
||||
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
|
||||
</article>
|
||||
`).join('')
|
||||
: '<div class="loading-row">Нагрузка не найдена</div>';
|
||||
lastWorkload = Array.isArray(workload) ? workload : [];
|
||||
renderTeachers();
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
|
||||
lastWorkload = [];
|
||||
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
|
||||
renderTeachers();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartDateChange() {
|
||||
if (startInput.value && endInput.value && startInput.value > endInput.value) {
|
||||
endInput.value = '';
|
||||
}
|
||||
await loadWorkload();
|
||||
}
|
||||
|
||||
function renderTeachers() {
|
||||
const container = document.getElementById('department-teachers-list');
|
||||
if (!container) return;
|
||||
|
||||
if (lastTeachers === null || lastWorkload === null) {
|
||||
container.textContent = 'Загрузка...';
|
||||
return;
|
||||
}
|
||||
|
||||
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
|
||||
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
|
||||
const workloadByTeacher = new Map();
|
||||
const usedWorkloadKeys = new Set();
|
||||
|
||||
workload
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.forEach(item => {
|
||||
workloadKeys(item).forEach(key => {
|
||||
if (!workloadByTeacher.has(key)) {
|
||||
workloadByTeacher.set(key, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rows = teachers.map(teacher => {
|
||||
const workloadItem = workloadKeys(teacher)
|
||||
.map(key => workloadByTeacher.get(key))
|
||||
.find(Boolean);
|
||||
if (workloadItem) {
|
||||
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
|
||||
}
|
||||
return renderTeacherMetric({
|
||||
name: teacher.fullName || teacher.username || '-',
|
||||
meta: teacher.jobTitle || 'Должность не указана',
|
||||
workload: workloadItem
|
||||
}, workloadErrorMessage);
|
||||
});
|
||||
|
||||
workload
|
||||
.filter(item => item && (item.id || item.name))
|
||||
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
|
||||
.forEach(item => {
|
||||
rows.push(renderTeacherMetric({
|
||||
name: item.name || '-',
|
||||
meta: 'Есть занятия в расписании кафедры',
|
||||
workload: item
|
||||
}, workloadErrorMessage));
|
||||
});
|
||||
|
||||
if (rows.length) {
|
||||
container.innerHTML = rows.join('');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
|
||||
}
|
||||
|
||||
function selectedDepartmentId() {
|
||||
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
||||
}
|
||||
@@ -261,3 +365,36 @@ function subjectCountLabel(count) {
|
||||
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
||||
return `${count} дисциплин`;
|
||||
}
|
||||
|
||||
function renderTeacherMetric(item, workloadErrorMessage) {
|
||||
return `
|
||||
<article class="metric-card">
|
||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
|
||||
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function workloadLabel(item, workloadErrorMessage) {
|
||||
if (workloadErrorMessage) {
|
||||
return workloadErrorMessage;
|
||||
}
|
||||
if (!item) {
|
||||
return '0 ак. ч., 0 занятий за период';
|
||||
}
|
||||
const hours = String(item.academicHours ?? 0);
|
||||
const lessons = String(item.lessonCount ?? 0);
|
||||
return `${hours} ак. ч., ${lessons} занятий за период`;
|
||||
}
|
||||
|
||||
function workloadKeys(item) {
|
||||
return [
|
||||
item.id == null ? null : `id:${item.id}`,
|
||||
normalizeTeacherName(item.fullName || item.name || item.username)
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTeacherName(value) {
|
||||
return value ? `name:${String(value).trim().toLowerCase()}` : null;
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
export async function initDepartmentsData() {
|
||||
const deptTbody = document.getElementById('departments-tbody');
|
||||
const specTbody = document.getElementById('specialties-tbody');
|
||||
|
||||
const createDeptForm = document.getElementById('create-department-form');
|
||||
const createSpecForm = document.getElementById('create-specialty-form');
|
||||
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');
|
||||
|
||||
let departments = [];
|
||||
let specialties = [];
|
||||
|
||||
async function loadData() {
|
||||
// Load Departments
|
||||
try {
|
||||
departments = await api.get('/api/departments');
|
||||
renderDepartments();
|
||||
} catch (e) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
}
|
||||
|
||||
// Load Specialties
|
||||
try {
|
||||
specialties = await api.get('/api/specialties');
|
||||
renderSpecialties();
|
||||
} catch (e) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDepartments() {
|
||||
if (!departments || !departments.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
return;
|
||||
}
|
||||
deptTbody.innerHTML = departments.map(d => `
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSpecialties() {
|
||||
if (!specialties || !specialties.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
||||
return;
|
||||
}
|
||||
specTbody.innerHTML = specialties.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
createDeptForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-dept-alert');
|
||||
const name = document.getElementById('dept-name').value.trim();
|
||||
const code = document.getElementById('dept-code').value.trim();
|
||||
|
||||
if (!name || !code) {
|
||||
showAlert('create-dept-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
|
||||
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
|
||||
createDeptForm.reset();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
|
||||
editSpecClose.addEventListener('click', () => editSpecModal.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');
|
||||
});
|
||||
|
||||
await loadData();
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
|
||||
|
||||
export let allEquipments = [];
|
||||
|
||||
export async function fetchEquipments() {
|
||||
try {
|
||||
allEquipments = await api.get('/api/equipments');
|
||||
return allEquipments;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch equipments", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
if (!equipments.length) {
|
||||
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = equipments.map(eq => {
|
||||
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
|
||||
return `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${eq.id}" ${isChecked}>
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
|
||||
</label>
|
||||
`}).join('');
|
||||
updateSelectText(containerId, textId);
|
||||
}
|
||||
|
||||
export async function initEquipments() {
|
||||
const equipmentsTbody = document.getElementById('equipments-tbody');
|
||||
const createEquipmentForm = document.getElementById('create-equipment-form');
|
||||
|
||||
async function loadEquipments() {
|
||||
try {
|
||||
const equipments = await fetchEquipments();
|
||||
renderEquipments(equipments);
|
||||
} catch (e) {
|
||||
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEquipments(equipments) {
|
||||
if (!equipments || !equipments.length) {
|
||||
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
||||
return;
|
||||
}
|
||||
equipmentsTbody.innerHTML = equipments.map(eq => `
|
||||
<tr>
|
||||
<td>${eq.id}</td>
|
||||
<td>${escapeHtml(eq.name)}</td>
|
||||
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
createEquipmentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideAlert('create-equipment-alert');
|
||||
const name = document.getElementById('new-equipment-name').value.trim();
|
||||
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
|
||||
|
||||
try {
|
||||
const data = await api.post('/api/equipments', { name });
|
||||
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
|
||||
createEquipmentForm.reset();
|
||||
loadEquipments();
|
||||
} catch (e) {
|
||||
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
equipmentsTbody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-delete');
|
||||
if (!btn) return;
|
||||
if (!confirm('Удалить оборудование?')) return;
|
||||
try {
|
||||
await api.delete('/api/equipments/' + btn.dataset.id);
|
||||
loadEquipments();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Ошибка удаления');
|
||||
}
|
||||
});
|
||||
|
||||
loadEquipments();
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { fetchEducationForms } from './edu-forms.js';
|
||||
|
||||
async function fetchEducationForms() {
|
||||
try {
|
||||
return await api.get('/api/education-forms');
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch education forms", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function initGroups() {
|
||||
const groupsTbody = document.getElementById('groups-tbody');
|
||||
@@ -10,6 +18,8 @@ export async function initGroups() {
|
||||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||||
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
||||
const filterEfSelect = document.getElementById('filter-ef');
|
||||
|
||||
// Модалка группы
|
||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
|
||||
const editGroupForm = document.getElementById('edit-group-form');
|
||||
@@ -17,19 +27,30 @@ export async function initGroups() {
|
||||
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
|
||||
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
|
||||
const editGroupProfileSelect = document.getElementById('edit-group-profile');
|
||||
const calendarForm = document.getElementById('group-calendar-form');
|
||||
const calendarGroupSelect = document.getElementById('calendar-group');
|
||||
const calendarYearSelect = document.getElementById('calendar-year');
|
||||
const calendarSelect = document.getElementById('calendar-id');
|
||||
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody');
|
||||
const subgroupForm = document.getElementById('subgroup-form');
|
||||
const subgroupIdInput = document.getElementById('subgroup-id');
|
||||
const subgroupGroupSelect = document.getElementById('subgroup-group');
|
||||
const subgroupNameInput = document.getElementById('subgroup-name');
|
||||
const subgroupCapacityInput = document.getElementById('subgroup-capacity');
|
||||
const subgroupSubmitButton = document.getElementById('subgroup-submit');
|
||||
const subgroupResetButton = document.getElementById('subgroup-reset');
|
||||
const subgroupsTbody = document.getElementById('subgroups-tbody');
|
||||
|
||||
// Модалка подгрупп
|
||||
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
|
||||
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
|
||||
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
|
||||
const subgroupFormTitle = document.getElementById('subgroup-form-title');
|
||||
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||||
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||||
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||||
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
|
||||
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
|
||||
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||||
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||||
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||||
|
||||
// Модалка календарей
|
||||
const modalManageCalendar = document.getElementById('modal-manage-calendar');
|
||||
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
|
||||
const calendarGroupContext = document.getElementById('calendar-group-context');
|
||||
const manageCalendarForm = document.getElementById('manage-calendar-form');
|
||||
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
|
||||
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
|
||||
const manageCalendarSelect = document.getElementById('manage-calendar-id');
|
||||
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
|
||||
|
||||
let allGroups = [];
|
||||
let subgroups = [];
|
||||
@@ -48,25 +69,35 @@ export async function initGroups() {
|
||||
filterEfSelect.addEventListener('change', applyGroupFilter);
|
||||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||||
calendarGroupSelect.addEventListener('change', () => {
|
||||
populateCalendarSelect();
|
||||
loadAssignments();
|
||||
});
|
||||
calendarYearSelect.addEventListener('change', populateCalendarSelect);
|
||||
|
||||
createGroupForm.addEventListener('submit', createGroup);
|
||||
editGroupForm.addEventListener('submit', updateGroup);
|
||||
calendarForm.addEventListener('submit', saveAssignment);
|
||||
subgroupForm.addEventListener('submit', saveSubgroup);
|
||||
subgroupGroupSelect.addEventListener('change', renderSubgroups);
|
||||
subgroupResetButton.addEventListener('click', resetSubgroupForm);
|
||||
|
||||
// Модалка подгрупп
|
||||
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||||
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||||
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||
|
||||
// Модалка календарей
|
||||
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||||
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
|
||||
|
||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick);
|
||||
|
||||
// Закрытие модалок
|
||||
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
||||
modalEditGroup.addEventListener('click', (event) => {
|
||||
if (event.target === modalEditGroup) {
|
||||
modalEditGroup.classList.remove('open');
|
||||
}
|
||||
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
|
||||
});
|
||||
|
||||
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
|
||||
modalManageSubgroups.addEventListener('click', (event) => {
|
||||
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
|
||||
});
|
||||
|
||||
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
|
||||
modalManageCalendar.addEventListener('click', (event) => {
|
||||
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,12 +115,10 @@ export async function initGroups() {
|
||||
populateDepartmentSelects(departments);
|
||||
populateSpecialitySelects(specialties);
|
||||
populateYearSelect();
|
||||
populateCalendarSelect();
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,22 +132,18 @@ export async function initGroups() {
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
allGroups = await api.get('/api/groups');
|
||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||
applyGroupFilter();
|
||||
populateGroupSelect();
|
||||
populateSubgroupGroupSelect();
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroups() {
|
||||
try {
|
||||
subgroups = await api.get('/api/subgroups');
|
||||
renderSubgroups();
|
||||
} catch (error) {
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
console.error("Failed to load subgroups", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,22 +157,29 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
<tr>
|
||||
<td>${group.id}</td>
|
||||
<td>${escapeHtml(group.name)}</td>
|
||||
<td><strong>${escapeHtml(group.name)}</strong></td>
|
||||
<td>${escapeHtml(String(group.groupSize))}</td>
|
||||
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
||||
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
||||
<td>${group.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||
<td class="group-actions-cell">
|
||||
<div class="group-actions">
|
||||
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -222,41 +254,18 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
function populateYearSelect() {
|
||||
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
if (!calendarYearSelect.value && academicYears.length) {
|
||||
calendarYearSelect.value = String(academicYears[0].id);
|
||||
if (academicYears.length) {
|
||||
manageCalendarYearSelect.value = String(academicYears[0].id);
|
||||
}
|
||||
syncSelects(calendarYearSelect);
|
||||
}
|
||||
|
||||
function populateGroupSelect() {
|
||||
const current = calendarGroupSelect.value;
|
||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
calendarGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
calendarGroupSelect.value = String(allGroups[0].id);
|
||||
}
|
||||
syncSelects(calendarGroupSelect);
|
||||
}
|
||||
|
||||
function populateSubgroupGroupSelect() {
|
||||
const current = subgroupGroupSelect.value;
|
||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
subgroupGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
subgroupGroupSelect.value = String(allGroups[0].id);
|
||||
}
|
||||
syncSelects(subgroupGroupSelect);
|
||||
syncSelects(manageCalendarYearSelect);
|
||||
}
|
||||
|
||||
function populateCalendarSelect() {
|
||||
const group = selectedCalendarGroup();
|
||||
const yearId = calendarYearSelect.value;
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
const yearId = manageCalendarYearSelect.value;
|
||||
const matching = group && yearId
|
||||
? calendars.filter(calendar =>
|
||||
String(calendar.academicYearId) === String(yearId)
|
||||
@@ -265,12 +274,12 @@ export async function initGroups() {
|
||||
&& String(calendar.studyFormId) === String(group.educationFormId)
|
||||
)
|
||||
: [];
|
||||
calendarSelect.innerHTML = matching.length
|
||||
manageCalendarSelect.innerHTML = matching.length
|
||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет подходящих графиков</option>';
|
||||
syncSelects(calendarSelect);
|
||||
syncSelects(manageCalendarSelect);
|
||||
}
|
||||
|
||||
async function createGroup(event) {
|
||||
@@ -285,7 +294,6 @@ export async function initGroups() {
|
||||
createGroupForm.reset();
|
||||
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
@@ -303,7 +311,6 @@ export async function initGroups() {
|
||||
modalEditGroup.classList.remove('open');
|
||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
||||
}
|
||||
@@ -340,23 +347,46 @@ export async function initGroups() {
|
||||
}
|
||||
|
||||
async function handleGroupTableClick(event) {
|
||||
const btnDelete = event.target.closest('.btn-delete');
|
||||
const btnDelete = event.target.closest('.btn-archive-group');
|
||||
const btnEdit = event.target.closest('.btn-edit-group');
|
||||
const btnRestore = event.target.closest('.btn-restore-group');
|
||||
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
|
||||
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить группу?')) return;
|
||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
alert(error.message || 'Ошибка архивирования');
|
||||
}
|
||||
}
|
||||
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка восстановления');
|
||||
}
|
||||
}
|
||||
|
||||
if (btnEdit) {
|
||||
openEditGroupModal(btnEdit.dataset.id);
|
||||
}
|
||||
|
||||
if (btnSubgroups) {
|
||||
const groupId = btnSubgroups.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) openSubgroupsModal(group);
|
||||
}
|
||||
|
||||
if (btnCalendar) {
|
||||
const groupId = btnCalendar.dataset.id;
|
||||
const group = allGroups.find(g => g.id == groupId);
|
||||
if (group) openCalendarModal(group);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditGroupModal(id) {
|
||||
@@ -379,21 +409,59 @@ export async function initGroups() {
|
||||
modalEditGroup.classList.add('open');
|
||||
}
|
||||
|
||||
// --- Управление подгруппами ---
|
||||
function openSubgroupsModal(group) {
|
||||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||
manageSubgroupGroupIdInput.value = group.id;
|
||||
resetSubgroupForm();
|
||||
modalManageSubgroups.classList.add('open');
|
||||
renderSubgroupsList(group.id);
|
||||
}
|
||||
|
||||
function renderSubgroupsList(groupId) {
|
||||
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
|
||||
if (!list.length) {
|
||||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||||
<tr>
|
||||
<td>${escapeHtml(sub.name)}</td>
|
||||
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function resetSubgroupForm() {
|
||||
manageSubgroupIdInput.value = '';
|
||||
manageSubgroupNameInput.value = '';
|
||||
manageSubgroupCapacityInput.value = '';
|
||||
subgroupFormTitle.textContent = 'Создание подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Создать';
|
||||
btnCancelSubgroupEdit.style.display = 'none';
|
||||
hideAlert('manage-subgroup-alert');
|
||||
}
|
||||
|
||||
async function saveSubgroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('subgroup-alert');
|
||||
const groupId = subgroupGroupSelect.value;
|
||||
const name = subgroupNameInput.value.trim();
|
||||
const capacity = subgroupCapacityInput.value;
|
||||
const subgroupId = subgroupIdInput.value;
|
||||
hideAlert('manage-subgroup-alert');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
const subgroupId = manageSubgroupIdInput.value;
|
||||
const name = manageSubgroupNameInput.value.trim();
|
||||
const capacity = manageSubgroupCapacityInput.value;
|
||||
|
||||
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
|
||||
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
|
||||
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
|
||||
if (!name || !capacity) {
|
||||
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
studentCapacity: capacity ? Number(capacity) : null
|
||||
studentCapacity: Number(capacity)
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -401,87 +469,93 @@ export async function initGroups() {
|
||||
const saved = subgroupId
|
||||
? await api.put(endpoint, payload)
|
||||
: await api.post(endpoint, payload);
|
||||
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubgroups() {
|
||||
const groupId = subgroupGroupSelect.value;
|
||||
const visibleSubgroups = groupId
|
||||
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId))
|
||||
: subgroups;
|
||||
if (!visibleSubgroups.length) {
|
||||
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => `
|
||||
<tr>
|
||||
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td>
|
||||
<td>${escapeHtml(subgroup.name)}</td>
|
||||
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleSubgroupTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-subgroup');
|
||||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||||
const groupId = manageSubgroupGroupIdInput.value;
|
||||
|
||||
if (editButton) {
|
||||
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id));
|
||||
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
|
||||
if (!subgroup) return;
|
||||
subgroupIdInput.value = subgroup.id;
|
||||
subgroupGroupSelect.value = subgroup.groupId;
|
||||
subgroupGroupSelect.disabled = true;
|
||||
subgroupNameInput.value = subgroup.name || '';
|
||||
subgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||
subgroupSubmitButton.textContent = 'Сохранить подгруппу';
|
||||
syncSelects(subgroupGroupSelect);
|
||||
hideAlert('subgroup-alert');
|
||||
|
||||
manageSubgroupIdInput.value = subgroup.id;
|
||||
manageSubgroupNameInput.value = subgroup.name || '';
|
||||
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||
subgroupFormTitle.textContent = 'Редактирование подгруппы';
|
||||
btnSaveSubgroup.textContent = 'Сохранить';
|
||||
btnCancelSubgroupEdit.style.display = 'inline-block';
|
||||
hideAlert('manage-subgroup-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id));
|
||||
if (!subgroup || !confirm('Удалить подгруппу?')) return;
|
||||
if (!confirm('Удалить подгруппу?')) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`);
|
||||
showAlert('subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
renderSubgroupsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetSubgroupForm() {
|
||||
subgroupIdInput.value = '';
|
||||
subgroupNameInput.value = '';
|
||||
subgroupCapacityInput.value = '';
|
||||
subgroupGroupSelect.disabled = false;
|
||||
subgroupSubmitButton.textContent = 'Создать подгруппу';
|
||||
syncSelects(subgroupGroupSelect);
|
||||
hideAlert('subgroup-alert');
|
||||
// --- Управление календарными графиками ---
|
||||
async function openCalendarModal(group) {
|
||||
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
|
||||
manageCalendarGroupIdInput.value = group.id;
|
||||
hideAlert('manage-calendar-alert');
|
||||
populateCalendarSelect();
|
||||
modalManageCalendar.classList.add('open');
|
||||
await loadAssignmentsList(group.id);
|
||||
}
|
||||
|
||||
async function loadAssignmentsList(groupId) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||
renderAssignmentsList();
|
||||
} catch (error) {
|
||||
manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignmentsList() {
|
||||
if (!currentAssignments.length) {
|
||||
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function saveAssignment(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('group-calendar-alert');
|
||||
const groupId = calendarGroupSelect.value;
|
||||
const academicYearId = calendarYearSelect.value;
|
||||
const calendarId = calendarSelect.value;
|
||||
hideAlert('manage-calendar-alert');
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
const academicYearId = manageCalendarYearSelect.value;
|
||||
const calendarId = manageCalendarSelect.value;
|
||||
|
||||
if (!groupId || !academicYearId || !calendarId) {
|
||||
showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error');
|
||||
if (!academicYearId || !calendarId) {
|
||||
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -490,74 +564,36 @@ export async function initGroups() {
|
||||
academicYearId: Number(academicYearId),
|
||||
calendarId: Number(calendarId)
|
||||
});
|
||||
showAlert('group-calendar-alert', 'Календарный график назначен', 'success');
|
||||
await loadAssignments();
|
||||
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
|
||||
await loadAssignmentsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
||||
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignments() {
|
||||
const groupId = calendarGroupSelect.value;
|
||||
if (!groupId) {
|
||||
currentAssignments = [];
|
||||
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||
renderAssignments();
|
||||
} catch (error) {
|
||||
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignments() {
|
||||
if (!currentAssignments.length) {
|
||||
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
|
||||
return;
|
||||
}
|
||||
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||
<td>
|
||||
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleAssignmentTableClick(event) {
|
||||
manageCalendarTbody.addEventListener('click', async (event) => {
|
||||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||
if (!deleteButton) return;
|
||||
if (!confirm('Удалить назначение графика?')) return;
|
||||
const groupId = manageCalendarGroupIdInput.value;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`);
|
||||
showAlert('group-calendar-alert', 'Назначение удалено', 'success');
|
||||
await loadAssignments();
|
||||
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
|
||||
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
|
||||
await loadAssignmentsList(groupId);
|
||||
} catch (error) {
|
||||
showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||||
}
|
||||
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function selectedCalendarGroup() {
|
||||
return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value));
|
||||
}
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function departmentLabel(departmentId) {
|
||||
const department = departments.find(item => item.id == departmentId);
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function groupName(groupId) {
|
||||
const group = allGroups.find(item => String(item.id) === String(groupId));
|
||||
return group ? group.name : groupId || '-';
|
||||
}
|
||||
|
||||
function specialityLabel(specialityId) {
|
||||
const speciality = specialties.find(item => item.id == specialityId);
|
||||
if (!speciality) return specialityId || '-';
|
||||
@@ -566,6 +602,21 @@ export async function initGroups() {
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
}
|
||||
|
||||
function statusBadgeClass(group) {
|
||||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||
return 'badge-unavailable';
|
||||
}
|
||||
if (group.studyState === 'NOT_STARTED') {
|
||||
return 'badge-ef';
|
||||
}
|
||||
return 'badge-available';
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
export async function initProfiles() {
|
||||
const createProfileForm = document.getElementById('create-profile-form');
|
||||
const profileSpecialtySelect = document.getElementById('profile-specialty');
|
||||
const profileNameInput = document.getElementById('profile-name');
|
||||
const profileDescriptionInput = document.getElementById('profile-description');
|
||||
const profilesTbody = document.getElementById('profiles-tbody');
|
||||
const profilesContext = document.getElementById('profiles-context');
|
||||
const refreshButton = document.getElementById('profiles-refresh');
|
||||
const editProfileForm = document.getElementById('edit-profile-form');
|
||||
const editProfileModal = document.getElementById('modal-edit-profile');
|
||||
const editProfileClose = document.getElementById('modal-edit-profile-close');
|
||||
|
||||
let specialties = [];
|
||||
let profiles = [];
|
||||
|
||||
await loadSpecialties();
|
||||
|
||||
profileSpecialtySelect.addEventListener('change', loadProfiles);
|
||||
refreshButton.addEventListener('click', loadProfiles);
|
||||
|
||||
createProfileForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
hideAlert('create-profile-alert');
|
||||
|
||||
const specialtyId = profileSpecialtySelect.value;
|
||||
const name = profileNameInput.value.trim();
|
||||
const description = profileDescriptionInput.value.trim();
|
||||
|
||||
if (!specialtyId || !name) {
|
||||
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
|
||||
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
|
||||
createProfileForm.reset();
|
||||
profileSpecialtySelect.value = specialtyId;
|
||||
syncSelect(profileSpecialtySelect);
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
profilesTbody.addEventListener('click', async (event) => {
|
||||
const editButton = event.target.closest('.btn-edit-profile');
|
||||
const deleteButton = event.target.closest('.btn-delete-profile');
|
||||
|
||||
if (editButton) {
|
||||
const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id));
|
||||
if (!profile) return;
|
||||
|
||||
document.getElementById('edit-profile-id').value = profile.id;
|
||||
document.getElementById('edit-profile-name').value = profile.name || '';
|
||||
document.getElementById('edit-profile-description').value = profile.description || '';
|
||||
hideAlert('edit-profile-alert');
|
||||
editProfileModal.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить профиль обучения?')) return;
|
||||
try {
|
||||
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`);
|
||||
showAlert('create-profile-alert', 'Профиль удалён', 'success');
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
editProfileForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
hideAlert('edit-profile-alert');
|
||||
|
||||
const specialtyId = profileSpecialtySelect.value;
|
||||
const id = document.getElementById('edit-profile-id').value;
|
||||
const name = document.getElementById('edit-profile-name').value.trim();
|
||||
const description = document.getElementById('edit-profile-description').value.trim();
|
||||
|
||||
if (!specialtyId || !id || !name) {
|
||||
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
|
||||
editProfileModal.classList.remove('open');
|
||||
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
|
||||
editProfileModal.addEventListener('click', (event) => {
|
||||
if (event.target === editProfileModal) {
|
||||
editProfileModal.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSpecialties() {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
specialties = await api.get('/api/specialties');
|
||||
renderSpecialtySelect();
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
profileSpecialtySelect.innerHTML = '<option value="">Не удалось загрузить специальности</option>';
|
||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
syncSelect(profileSpecialtySelect);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpecialtySelect() {
|
||||
if (!specialties.length) {
|
||||
profileSpecialtySelect.innerHTML = '<option value="">Сначала создайте специальность</option>';
|
||||
syncSelect(profileSpecialtySelect);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = profileSpecialtySelect.value;
|
||||
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||
specialties.map(specialty => {
|
||||
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
|
||||
const name = specialty.specialityName || specialty.name || 'Без названия';
|
||||
return `<option value="${specialty.id}">${escapeHtml(code)} — ${escapeHtml(name)}</option>`;
|
||||
}).join('');
|
||||
|
||||
if (current && specialties.some(specialty => String(specialty.id) === String(current))) {
|
||||
profileSpecialtySelect.value = current;
|
||||
} else {
|
||||
profileSpecialtySelect.value = String(specialties[0].id);
|
||||
}
|
||||
syncSelect(profileSpecialtySelect);
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
const specialtyId = profileSpecialtySelect.value;
|
||||
const specialty = specialties.find(item => String(item.id) === String(specialtyId));
|
||||
profilesContext.textContent = specialty ? specialtyLabel(specialty) : '';
|
||||
|
||||
if (!specialtyId) {
|
||||
profiles = [];
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
|
||||
renderProfiles();
|
||||
} catch (error) {
|
||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
if (!profiles.length) {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
profilesTbody.innerHTML = profiles.map(profile => `
|
||||
<tr>
|
||||
<td>${profile.id}</td>
|
||||
<td>${escapeHtml(profile.name)}</td>
|
||||
<td>${escapeHtml(profile.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function specialtyLabel(specialty) {
|
||||
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
|
||||
const name = specialty.specialityName || specialty.name || 'Без названия';
|
||||
return `${code} · ${name}`;
|
||||
}
|
||||
|
||||
function syncSelect(select) {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
|
||||
{ parity: 'EVEN', label: 'Чётная' }
|
||||
];
|
||||
const MOBILE_QUERY = '(max-width: 760px)';
|
||||
const DEFAULT_SEMESTER_WEEKS = 22;
|
||||
|
||||
export async function initScheduleView() {
|
||||
const role = localStorage.getItem('role');
|
||||
@@ -451,9 +452,6 @@ export async function initScheduleView() {
|
||||
<th class="room-day-cell">
|
||||
<strong>${escapeHtml(dayShort(day))}</strong>
|
||||
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
|
||||
<div class="room-day-parity-dates">
|
||||
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')}
|
||||
</div>
|
||||
</th>
|
||||
`).join('');
|
||||
const rows = slots.map(slot => `
|
||||
@@ -597,6 +595,72 @@ export async function initScheduleView() {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function getLessonWeeksText(lesson) {
|
||||
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
|
||||
return '';
|
||||
}
|
||||
const totalHours = lesson.lessonTypeAcademicHours;
|
||||
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
|
||||
|
||||
// Каждое занятие - это 2 часа
|
||||
const totalLessons = Math.ceil(totalHours / 2);
|
||||
if (totalLessons <= 0) return '';
|
||||
|
||||
const parity = lesson.ruleParity || lesson.parity;
|
||||
let startWeek, endWeek;
|
||||
|
||||
if (parity === 'EVEN' || parity === 'ODD') {
|
||||
// Раз в две недели
|
||||
startWeek = lesson.weekNumber - consumedBefore;
|
||||
endWeek = startWeek + (totalLessons - 1) * 2;
|
||||
} else {
|
||||
// Каждую неделю
|
||||
const steps = Math.floor(consumedBefore / 2);
|
||||
startWeek = lesson.weekNumber - steps;
|
||||
endWeek = startWeek + totalLessons - 1;
|
||||
}
|
||||
|
||||
if (startWeek <= 0) startWeek = 1;
|
||||
if (endWeek < startWeek) endWeek = startWeek;
|
||||
|
||||
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
|
||||
}
|
||||
|
||||
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
|
||||
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
|
||||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||||
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
|
||||
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
|
||||
return startWeek === endWeek
|
||||
? `(${startWeek} нед.)`
|
||||
: `(с ${startWeek} по ${endWeek} нед.)`;
|
||||
}
|
||||
|
||||
function semesterWeeksForLesson(lesson) {
|
||||
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
|
||||
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
|
||||
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));
|
||||
}
|
||||
|
||||
function dateInRange(date, startDate, endDate) {
|
||||
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
|
||||
}
|
||||
|
||||
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 renderLessonCard(lesson) {
|
||||
const subgroupText = (lesson.subgroupNames || []).join(', ');
|
||||
const meta = [
|
||||
@@ -605,9 +669,15 @@ export async function initScheduleView() {
|
||||
PARITY_LABELS[lesson.parity] || lesson.parity
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const weeksText = getLessonWeeksText(lesson);
|
||||
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="lesson-card schedule-view-lesson-card">
|
||||
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
|
||||
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
|
||||
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
|
||||
${weeksBadge}
|
||||
</div>
|
||||
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
||||
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
|
||||
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
||||
@@ -657,13 +727,14 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
async function loadDictionaries(role, userDepartmentId) {
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([
|
||||
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types')
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/admin/calendar/years').catch(() => [])
|
||||
]);
|
||||
|
||||
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
|
||||
@@ -681,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
departments
|
||||
departments,
|
||||
semesters: (academicYears || []).flatMap(year => year.semesters || [])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -895,26 +967,15 @@ function parityForDate(date, lessons) {
|
||||
}
|
||||
|
||||
function combinedWeekCaption(block) {
|
||||
return CELL_PARITY_LAYOUT
|
||||
.map(({ parity, label }) => {
|
||||
const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean);
|
||||
if (!dates.length) return `${label}: дат нет`;
|
||||
return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`;
|
||||
})
|
||||
.join('; ');
|
||||
return 'Нечётная и чётная недели';
|
||||
}
|
||||
|
||||
function mobileDayCaption(datesByParity) {
|
||||
return CELL_PARITY_LAYOUT
|
||||
.map(({ parity, label }) => {
|
||||
const date = datesByParity?.[parity];
|
||||
return `${label}: ${date ? formatDate(date) : 'даты нет'}`;
|
||||
})
|
||||
.join(' · ');
|
||||
return 'Нечётная / Чётная';
|
||||
}
|
||||
|
||||
function periodCaption(range) {
|
||||
return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`;
|
||||
return 'Учебное расписание';
|
||||
}
|
||||
|
||||
function lessonSlotKey(lesson) {
|
||||
|
||||
@@ -17,12 +17,18 @@ const PARITY_LABELS = {
|
||||
EVEN: 'чётная'
|
||||
};
|
||||
|
||||
const CELL_PARITY_LAYOUT = [
|
||||
{ parity: 'ODD', label: 'Нечётная' },
|
||||
{ parity: 'EVEN', label: 'Чётная' }
|
||||
];
|
||||
|
||||
const SEMESTER_LABELS = {
|
||||
autumn: 'осенний',
|
||||
spring: 'весенний'
|
||||
};
|
||||
|
||||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
const ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
|
||||
const LESSON_TYPE_LIMITS = [
|
||||
{
|
||||
@@ -70,6 +76,17 @@ export async function initSchedule() {
|
||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||
const rulesTbody = document.getElementById('schedule-tbody');
|
||||
const refreshRulesButton = document.getElementById('schedule-refresh');
|
||||
const visualToggleButton = document.getElementById('schedule-visual-toggle');
|
||||
const visualCloseButton = document.getElementById('schedule-visual-close');
|
||||
const visualDrawer = document.getElementById('schedule-visual-drawer');
|
||||
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
|
||||
const visualStage = document.getElementById('schedule-visual-stage');
|
||||
const visualSummary = document.getElementById('schedule-visual-summary');
|
||||
const visualYearSelect = document.getElementById('schedule-visual-year');
|
||||
const visualSemesterSelect = document.getElementById('schedule-visual-semester');
|
||||
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
||||
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||||
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
||||
|
||||
let rules = [];
|
||||
let subjects = [];
|
||||
@@ -79,7 +96,14 @@ export async function initSchedule() {
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let selectedVisualYearId = '';
|
||||
let selectedVisualSemesterId = '';
|
||||
let selectedVisualGroupIds = new Set();
|
||||
let visualGroupFilterTouched = false;
|
||||
let visualPeriodTouched = false;
|
||||
let syncingVisualPeriod = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -96,6 +120,36 @@ export async function initSchedule() {
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
visualYearSelect?.addEventListener('change', handleVisualYearChange);
|
||||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||||
visualUseFormButton?.addEventListener('click', () => {
|
||||
const formSemesterId = ruleSemesterSelect.value;
|
||||
if (formSemesterId) {
|
||||
visualPeriodTouched = true;
|
||||
setVisualPeriodBySemesterId(formSemesterId);
|
||||
}
|
||||
visualGroupFilterTouched = false;
|
||||
selectedVisualGroupIds = new Set();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
visualSelectAllButton?.addEventListener('click', () => {
|
||||
selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
|
||||
visualGroupFilterTouched = true;
|
||||
renderVisualSchedule();
|
||||
});
|
||||
ruleSemesterSelect?.addEventListener('change', () => {
|
||||
if (visualPeriodTouched) return;
|
||||
selectDefaultVisualPeriod();
|
||||
renderVisualSchedule();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeVisualDrawer();
|
||||
});
|
||||
|
||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||
const removeButton = event.target.closest('.schedule-slot-remove');
|
||||
@@ -156,24 +210,31 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
const academicYears = await api.get('/api/admin/calendar/years');
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
populateSemesterSelects();
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
rules = await api.get('/api/admin/schedule-rules');
|
||||
if (!visualPeriodTouched) {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
renderRules();
|
||||
renderVisualSchedule();
|
||||
} catch (error) {
|
||||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
setVisualState('Ошибка загрузки правил расписания');
|
||||
}
|
||||
}
|
||||
|
||||
function renderRules() {
|
||||
if (!rules.length) {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||||
renderVisualSchedule();
|
||||
return;
|
||||
}
|
||||
rulesTbody.innerHTML = rules.map(rule => `
|
||||
@@ -185,13 +246,474 @@ export async function initSchedule() {
|
||||
<td>${renderRuleLoadSummary(rule)}</td>
|
||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||
<div class="schedule-rule-table-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleVisualDrawer() {
|
||||
if (!visualDrawer) return;
|
||||
if (visualDrawer.classList.contains('open')) {
|
||||
closeVisualDrawer();
|
||||
} else {
|
||||
openVisualDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
function openVisualDrawer() {
|
||||
if (!visualDrawer) return;
|
||||
visualDrawer.classList.add('open');
|
||||
visualDrawer.setAttribute('aria-hidden', 'false');
|
||||
visualToggleButton?.classList.add('open');
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||||
if (visualToggleButton) visualToggleButton.textContent = '›';
|
||||
if (visualBackdrop) visualBackdrop.hidden = false;
|
||||
}
|
||||
|
||||
function closeVisualDrawer() {
|
||||
if (!visualDrawer) return;
|
||||
visualDrawer.classList.remove('open');
|
||||
visualDrawer.setAttribute('aria-hidden', 'true');
|
||||
visualToggleButton?.classList.remove('open');
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'false');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
|
||||
if (visualToggleButton) visualToggleButton.textContent = '‹';
|
||||
if (visualBackdrop) visualBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
function renderVisualSchedule() {
|
||||
if (!visualStage || !visualSummary) return;
|
||||
if (!selectedVisualSemesterId) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||
return;
|
||||
}
|
||||
if (!rules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Нет сохранённых правил';
|
||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||
return;
|
||||
}
|
||||
|
||||
const visualRules = visualRulesForSelectedPeriod();
|
||||
const periodLabel = visualPeriodLabel();
|
||||
if (!visualRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||
return;
|
||||
}
|
||||
|
||||
const allGroups = collectVisualGroups(visualRules);
|
||||
syncVisualGroupFilter(allGroups);
|
||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||||
if (!allGroups.length || !slots.length) {
|
||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||
setVisualState('У правил должны быть группы и слоты занятий');
|
||||
return;
|
||||
}
|
||||
if (!columns.length) {
|
||||
visualSummary.textContent = 'Выберите группы для просмотра';
|
||||
setVisualState('Отметьте одну или несколько групп в фильтре');
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||
if (!rows.length) {
|
||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
|
||||
return;
|
||||
}
|
||||
|
||||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualStage.innerHTML = `
|
||||
<div class="schedule-visual-table-wrap">
|
||||
<table class="schedule-visual-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="schedule-visual-day-head">День</th>
|
||||
<th class="schedule-visual-time-head">Время</th>
|
||||
${columns.map(group => `<th>${escapeHtml(group.name)}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows.map(renderVisualRow).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildVisualRows(columns, slots, lessonsByCell) {
|
||||
const rows = [];
|
||||
DAY_OPTIONS.forEach(day => {
|
||||
const dayRows = slots
|
||||
.map(slot => ({
|
||||
day,
|
||||
slot,
|
||||
cells: columns.map(group => ({
|
||||
group,
|
||||
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
|
||||
}))
|
||||
}))
|
||||
.filter(row => row.cells.some(cell => cell.lessons.length));
|
||||
|
||||
dayRows.forEach((row, index) => {
|
||||
rows.push({
|
||||
...row,
|
||||
dayRowspan: index === 0 ? dayRows.length : 0
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderVisualRow(row) {
|
||||
return `
|
||||
<tr>
|
||||
${row.dayRowspan ? `<td class="schedule-visual-day-cell" rowspan="${row.dayRowspan}">${escapeHtml(row.day.label)}</td>` : ''}
|
||||
<td class="schedule-visual-time-cell">
|
||||
<strong>${escapeHtml(row.slot.orderLabel)}</strong>
|
||||
<span>${escapeHtml(row.slot.timeLabel)}</span>
|
||||
</td>
|
||||
${row.cells.map(cell => {
|
||||
const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
|
||||
return `<td class="schedule-visual-lesson-cell${emptyClass}">${renderVisualCellContent(cell.lessons)}</td>`;
|
||||
}).join('')}
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
|
||||
const grouped = new Map();
|
||||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
|
||||
visualRules.forEach(rule => {
|
||||
(rule.slots || []).forEach((slot, index) => {
|
||||
const slotKey = visualRuleSlotKey(slot, index);
|
||||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||||
if (!allowedGroupIds.has(String(groupId))) return;
|
||||
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
|
||||
if (!weeks.length) return;
|
||||
const lesson = {
|
||||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||||
ruleId: rule.id,
|
||||
parity: slot.parity || 'BOTH',
|
||||
subjectName: rule.subjectName || 'Без дисциплины',
|
||||
teacherName: slot.teacherName || '',
|
||||
classroomName: slot.classroomName || '',
|
||||
lessonTypeName: slot.lessonTypeName || '',
|
||||
lessonFormat: slot.lessonFormat || '',
|
||||
subgroupNames: slotSubgroupNames(slot),
|
||||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
|
||||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
||||
};
|
||||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(lesson);
|
||||
});
|
||||
});
|
||||
});
|
||||
grouped.forEach(items => items.sort(compareVisualLessons));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function buildActiveWeeksByRuleSlot(visualRules) {
|
||||
const activeWeeks = new Map();
|
||||
visualRules.forEach(rule => {
|
||||
const maxWeeks = maxWeeksForRule(rule);
|
||||
(rule.groupIds || []).forEach(groupId => {
|
||||
LESSON_TYPE_LIMITS.forEach(config => {
|
||||
const totalHours = Number(rule[config.hoursField] || 0);
|
||||
if (totalHours <= 0) return;
|
||||
const categorySlots = (rule.slots || [])
|
||||
.map((slot, index) => ({ slot, index }))
|
||||
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
|
||||
.sort(compareSlotItems);
|
||||
if (!categorySlots.length) return;
|
||||
|
||||
let consumedHours = 0;
|
||||
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
|
||||
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
|
||||
for (const item of categorySlots) {
|
||||
if (consumedHours >= totalHours) break;
|
||||
if (!slotAppliesToWeek(item.slot, week)) continue;
|
||||
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
|
||||
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
|
||||
activeWeeks.get(key).push(week);
|
||||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return activeWeeks;
|
||||
}
|
||||
|
||||
function renderVisualCellContent(lessons) {
|
||||
if (!lessons.length) return '';
|
||||
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
|
||||
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
|
||||
const shouldSplit = lessons.some(l => l.parity !== 'BOTH');
|
||||
|
||||
if (!shouldSplit) {
|
||||
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
|
||||
}
|
||||
|
||||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
|
||||
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
|
||||
return `
|
||||
<div class="schedule-visual-half schedule-visual-half-${parity.toLowerCase()}">
|
||||
<span class="schedule-visual-half-label">${escapeHtml(label)}</span>
|
||||
${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function uniqueVisualLessons(lessons) {
|
||||
const unique = new Map();
|
||||
lessons.forEach(lesson => {
|
||||
unique.set(visualLessonSignature([lesson]), lesson);
|
||||
});
|
||||
return Array.from(unique.values()).sort(compareVisualLessons);
|
||||
}
|
||||
|
||||
function renderVisualLesson(lesson) {
|
||||
const meta = [
|
||||
lesson.lessonTypeName,
|
||||
lesson.lessonFormat,
|
||||
lesson.teacherName,
|
||||
lesson.classroomName
|
||||
].filter(Boolean).join(' · ');
|
||||
const subgroupText = lesson.subgroupNames.join(', ');
|
||||
return `
|
||||
<article class="schedule-visual-lesson">
|
||||
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
|
||||
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
|
||||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||
<button type="button"
|
||||
class="schedule-visual-edit-rule"
|
||||
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
|
||||
title="Изменить правило"
|
||||
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">⋮</button>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function collectVisualGroups(visualRules = rules) {
|
||||
const byId = new Map();
|
||||
visualRules.forEach(rule => {
|
||||
(rule.groupIds || []).forEach((id, index) => {
|
||||
if (!byId.has(String(id))) {
|
||||
byId.set(String(id), {
|
||||
id,
|
||||
name: (rule.groupNames || [])[index] || groupNameById(id)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
|
||||
}
|
||||
|
||||
function collectVisualSlotsFromRules(visualRules = rules) {
|
||||
const byKey = new Map();
|
||||
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||||
const key = visualSlotKey(slot);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
orderNumber: slot.timeSlotOrder,
|
||||
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
|
||||
timeLabel: slot.timeSlotLabel || 'Время не указано'
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(byKey.values()).sort(compareVisualSlots);
|
||||
}
|
||||
|
||||
function visualSlotFromTimeSlot(slot) {
|
||||
return {
|
||||
key: String(slot.id),
|
||||
orderNumber: slot.orderNumber,
|
||||
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
|
||||
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
|
||||
};
|
||||
}
|
||||
|
||||
function setVisualState(message) {
|
||||
if (!visualStage) return;
|
||||
visualStage.innerHTML = `<div class="loading-row schedule-visual-state">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function handleVisualGroupFilterChange(event) {
|
||||
if (!event.target.matches('input[type="checkbox"]')) return;
|
||||
selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(input => String(input.value)));
|
||||
visualGroupFilterTouched = true;
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualYearChange() {
|
||||
if (syncingVisualPeriod) return;
|
||||
selectedVisualYearId = visualYearSelect?.value || '';
|
||||
selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
|
||||
? String(firstSemesterForYear(selectedVisualYearId).id)
|
||||
: '';
|
||||
visualPeriodTouched = true;
|
||||
visualGroupFilterTouched = false;
|
||||
populateVisualSemesterSelect();
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualSemesterChange() {
|
||||
if (syncingVisualPeriod) return;
|
||||
selectedVisualSemesterId = visualSemesterSelect?.value || '';
|
||||
visualPeriodTouched = true;
|
||||
visualGroupFilterTouched = false;
|
||||
renderVisualSchedule();
|
||||
}
|
||||
|
||||
function handleVisualStageClick(event) {
|
||||
const editButton = event.target.closest('.schedule-visual-edit-rule');
|
||||
if (!editButton) return;
|
||||
const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId));
|
||||
if (!rule) {
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
}
|
||||
fillRuleForm(rule);
|
||||
closeVisualDrawer();
|
||||
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
|
||||
}
|
||||
|
||||
function syncVisualGroupFilter(allGroups) {
|
||||
ensureVisualGroupSelection(allGroups);
|
||||
if (!visualGroupFilter) return;
|
||||
if (!allGroups.length) {
|
||||
visualGroupFilter.innerHTML = '<span class="muted">Нет групп в правилах</span>';
|
||||
return;
|
||||
}
|
||||
visualGroupFilter.innerHTML = allGroups.map(group => {
|
||||
const id = String(group.id);
|
||||
return `
|
||||
<label class="schedule-visual-group-chip">
|
||||
<input type="checkbox" value="${escapeHtml(id)}" ${selectedVisualGroupIds.has(id) ? 'checked' : ''}>
|
||||
<span>${escapeHtml(group.name)}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function ensureVisualGroupSelection(allGroups) {
|
||||
const availableIds = new Set(allGroups.map(group => String(group.id)));
|
||||
selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
|
||||
if (visualGroupFilterTouched) return;
|
||||
|
||||
const formIds = selectedRuleGroupIds()
|
||||
.map(String)
|
||||
.filter(id => availableIds.has(id));
|
||||
if (!visualGroupFilterTouched && formIds.length) {
|
||||
selectedVisualGroupIds = new Set(formIds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedVisualGroupIds.size && allGroups.length) {
|
||||
selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultVisualPeriod() {
|
||||
const formSemester = semesterById(ruleSemesterSelect?.value);
|
||||
const ruleSemester = firstSemesterWithRules();
|
||||
const fallbackSemester = semesters[0];
|
||||
const semester = formSemester || ruleSemester || fallbackSemester;
|
||||
setVisualPeriodBySemesterId(semester?.id || '');
|
||||
}
|
||||
|
||||
function setVisualPeriodBySemesterId(semesterId) {
|
||||
const semester = semesterById(semesterId);
|
||||
selectedVisualSemesterId = semester ? String(semester.id) : '';
|
||||
selectedVisualYearId = semester ? String(semester.academicYearId) : '';
|
||||
populateVisualYearSelect();
|
||||
populateVisualSemesterSelect();
|
||||
}
|
||||
|
||||
function populateVisualYearSelect() {
|
||||
if (!visualYearSelect) return;
|
||||
visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
|
||||
visualYearSelect.value = selectedVisualYearId;
|
||||
} else if (academicYears.length) {
|
||||
selectedVisualYearId = String(academicYears[0].id);
|
||||
visualYearSelect.value = selectedVisualYearId;
|
||||
} else {
|
||||
selectedVisualYearId = '';
|
||||
visualYearSelect.value = '';
|
||||
}
|
||||
syncVisualPeriodSelects(visualYearSelect);
|
||||
}
|
||||
|
||||
function populateVisualSemesterSelect() {
|
||||
if (!visualSemesterSelect) return;
|
||||
const yearSemesters = semestersForYear(selectedVisualYearId);
|
||||
visualSemesterSelect.innerHTML = yearSemesters.length
|
||||
? '<option value="">Выберите семестр</option>' + yearSemesters
|
||||
.map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
|
||||
.join('')
|
||||
: '<option value="">Нет семестров</option>';
|
||||
|
||||
if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
|
||||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||
} else if (yearSemesters.length) {
|
||||
selectedVisualSemesterId = String(yearSemesters[0].id);
|
||||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||||
} else {
|
||||
selectedVisualSemesterId = '';
|
||||
visualSemesterSelect.value = '';
|
||||
}
|
||||
syncVisualPeriodSelects(visualSemesterSelect);
|
||||
}
|
||||
|
||||
function syncVisualPeriodSelects(...selects) {
|
||||
syncingVisualPeriod = true;
|
||||
syncSelects(...selects);
|
||||
syncingVisualPeriod = false;
|
||||
}
|
||||
|
||||
function visualRulesForSelectedPeriod() {
|
||||
if (!selectedVisualSemesterId) return [];
|
||||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
}
|
||||
|
||||
function firstSemesterWithRules() {
|
||||
const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
|
||||
return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
|
||||
}
|
||||
|
||||
function semesterById(id) {
|
||||
return semesters.find(semester => String(semester.id) === String(id));
|
||||
}
|
||||
|
||||
function semestersForYear(yearId) {
|
||||
return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
|
||||
}
|
||||
|
||||
function firstSemesterForYear(yearId) {
|
||||
return semestersForYear(yearId)[0] || null;
|
||||
}
|
||||
|
||||
function visualPeriodLabel() {
|
||||
const semester = semesterById(selectedVisualSemesterId);
|
||||
return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
|
||||
}
|
||||
|
||||
function renderRuleSlotsSummary(slots) {
|
||||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||||
return slots.map(slot => {
|
||||
@@ -332,10 +854,10 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
`).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 'строк';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,109 +4,121 @@ 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 = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function populateDepartmentsSelect() {
|
||||
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||
allDepartments.map(d => {
|
||||
const name = d.departmentName || d.name || 'Без названия';
|
||||
const code = d.departmentCode || d.code || d.id;
|
||||
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
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 = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubjects(subjects) {
|
||||
if (!subjects || !subjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>';
|
||||
function renderSubjects() {
|
||||
if (!allSubjects || !allSubjects.length) {
|
||||
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
|
||||
return;
|
||||
}
|
||||
subjectsTbody.innerHTML = subjects.map(s => `
|
||||
|
||||
// Группируем преподавателей по дисциплинам
|
||||
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 => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
|
||||
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.name)}</td>
|
||||
<td><strong>${escapeHtml(s.name)}</strong></td>
|
||||
<td>${escapeHtml(s.code || '-')}</td>
|
||||
<td>${s.departmentId || '-'}</td>
|
||||
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
|
||||
</tr>`).join('');
|
||||
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
|
||||
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function populateSubjectSelect(subjects) {
|
||||
if (!assignSubjectSelect) return;
|
||||
const currentVal = assignSubjectSelect.value;
|
||||
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
||||
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).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 = '<option value="">Ошибка загрузки</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function populateTeacherSelect(teachers) {
|
||||
if (!assignTeacherSelect) return;
|
||||
const currentVal = assignTeacherSelect.value;
|
||||
if (!teachers || !teachers.length) {
|
||||
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
|
||||
return;
|
||||
}
|
||||
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).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 = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeacherSubjects(tsArray) {
|
||||
if (!tsArray || !tsArray.length) {
|
||||
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
|
||||
return;
|
||||
}
|
||||
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
|
||||
<tr>
|
||||
<td>${escapeHtml(ts.username)}</td>
|
||||
<td>${escapeHtml(ts.subjectName)}</td>
|
||||
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
|
||||
</tr>`).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', {
|
||||
@@ -114,50 +126,138 @@ export async function initSubjects() {
|
||||
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;
|
||||
async function handleSubjectsTableClick(e) {
|
||||
const btnDelete = e.target.closest('.btn-delete-subject');
|
||||
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||||
|
||||
if (btnDelete) {
|
||||
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 || 'Ошибка удаления'); }
|
||||
});
|
||||
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||||
await loadSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
}
|
||||
}
|
||||
|
||||
assignTeacherForm.addEventListener('submit', async (e) => {
|
||||
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 = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
|
||||
return;
|
||||
}
|
||||
manageTsTbody.innerHTML = list.map(ts => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<option value="">Все преподаватели привязаны</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).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)
|
||||
});
|
||||
loadTeacherSubjects();
|
||||
} catch (e) { alert(e.message || 'Ошибка удаления'); }
|
||||
userId: Number(btnDelete.dataset.userId),
|
||||
subjectId: Number(btnDelete.dataset.subjectId)
|
||||
});
|
||||
|
||||
loadInitialData();
|
||||
// Перезагрузим привязки
|
||||
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||
renderSubjectTeachers(subjectId);
|
||||
populateTeachersSelect(subjectId);
|
||||
|
||||
// Обновим главную таблицу
|
||||
renderSubjects();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления привязки');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Вспомогательные функции ---
|
||||
function departmentLabel(departmentId) {
|
||||
const department = allDepartments.find(item => item.id == departmentId);
|
||||
if (!department) return departmentId || '-';
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
549
frontend/admin/js/views/university-structure.js
Normal file
@@ -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 = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
|
||||
// Загрузка специальностей
|
||||
try {
|
||||
specialties = await api.get('/api/specialties');
|
||||
renderSpecialties();
|
||||
populateSpecialtiesDropdown();
|
||||
} catch (e) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
|
||||
// Загрузка всех профилей
|
||||
try {
|
||||
allProfiles = await api.get('/api/specialties/profiles');
|
||||
renderAllProfiles();
|
||||
} catch (e) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDepartments() {
|
||||
if (!departments || !departments.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
deptTbody.innerHTML = departments.map(d => `
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSpecialties() {
|
||||
if (!specialties || !specialties.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
specTbody.innerHTML = specialties.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
|
||||
renderProfilesTable();
|
||||
} catch (e) {
|
||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfilesTable() {
|
||||
if (!currentProfiles || !currentProfiles.length) {
|
||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
profilesTbody.innerHTML = currentProfiles.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<option value="">Выберите специальность</option>' +
|
||||
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
|
||||
tabProfileSpecSelect.value = currentValue;
|
||||
}
|
||||
|
||||
function renderAllProfiles() {
|
||||
if (!allProfiles || !allProfiles.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
tabProfileTbody.innerHTML = allProfiles.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||
<td>${escapeHtml(p.name)}</td>
|
||||
<td>${escapeHtml(p.description || '-')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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();
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function initUsers() {
|
||||
<td>
|
||||
${u.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
|
||||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="../css/components.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
</head>
|
||||
@@ -31,7 +32,7 @@
|
||||
</svg>
|
||||
<span>Настройки</span>
|
||||
</div>
|
||||
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
@@ -56,6 +57,24 @@
|
||||
</svg>
|
||||
<span>Временные слоты</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
<line x1="9" y1="7" x2="17" y2="7" />
|
||||
<line x1="9" y1="11" x2="15" y2="11" />
|
||||
</svg>
|
||||
<span>Формы обучения</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-tab="database">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||
</svg>
|
||||
<span>База данных</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="/admin/" class="btn-back">
|
||||
@@ -75,7 +94,7 @@
|
||||
<!-- Основная область -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
|
||||
@@ -26,11 +26,27 @@ const ROUTES = {
|
||||
await module.initTimeSlotsSettings();
|
||||
}
|
||||
},
|
||||
database: {
|
||||
title: 'База данных',
|
||||
file: 'views/database.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/database.js');
|
||||
await module.initDatabase();
|
||||
}
|
||||
},
|
||||
'edu-forms': {
|
||||
title: 'Формы обучения',
|
||||
file: 'views/edu-forms.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/edu-forms.js');
|
||||
await module.initEduForms();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_TABS = {
|
||||
ADMIN: ['general', 'time-slots'],
|
||||
EDUCATION_OFFICE: ['time-slots']
|
||||
ADMIN: ['general', 'time-slots', 'database', 'edu-forms'],
|
||||
EDUCATION_OFFICE: ['time-slots', 'edu-forms']
|
||||
};
|
||||
const allowedTabs = new Set(ROLE_TABS[role] || []);
|
||||
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
|
||||
|
||||
8
frontend/admin/js/views/database.js → frontend/admin/settings/js/views/database.js
Executable file → Normal file
@@ -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 async function initDatabase() {
|
||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||
@@ -68,7 +68,7 @@ export async function initDatabase() {
|
||||
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
|
||||
<td>${escapeHtml(t.username || '—')}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td><button class="btn-delete" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
|
||||
</tr>`;
|
||||
}).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;
|
||||
8
frontend/admin/js/views/edu-forms.js → frontend/admin/settings/js/views/edu-forms.js
Executable file → Normal file
@@ -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() {
|
||||
<tr>
|
||||
<td>${ef.id}</td>
|
||||
<td>${escapeHtml(ef.name)}</td>
|
||||
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
|
||||
</tr>`).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 {
|
||||
@@ -143,8 +143,8 @@ export async function initTimeSlotsSettings() {
|
||||
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
||||
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||
<button type="button" class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
4
frontend/admin/views/database.html → frontend/admin/settings/views/database.html
Executable file → Normal file
@@ -61,10 +61,10 @@
|
||||
<label for="tenant-password">Пароль</label>
|
||||
<input type="password" id="tenant-password" placeholder="••••••••" required>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="btn-test-connection" style="height: fit-content;">
|
||||
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
|
||||
Тест
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" style="height: fit-content;">
|
||||
<button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
2
frontend/admin/views/edu-forms.html → frontend/admin/settings/views/edu-forms.html
Executable file → Normal file
@@ -7,7 +7,7 @@
|
||||
<label for="new-ef-name">Название</label>
|
||||
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-md btn-primary">Создать</button>
|
||||
</div>
|
||||
<div class="form-alert" id="create-ef-alert" role="alert"></div>
|
||||
</form>
|
||||