captor = ArgumentCaptor.forClass(TeacherDepartmentAssignment.class);
+ verify(assignmentRepository).save(captor.capture());
+ TeacherDepartmentAssignment saved = captor.getValue();
+ assertThat(saved.getTeacher()).isEqualTo(teacher);
+ assertThat(saved.getDepartment()).isEqualTo(department);
+ assertThat(saved.getPrimaryAssignment()).isFalse();
+ assertThat(saved.getCreatedBy()).isEqualTo(3L);
+ }
+
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
diff --git a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java
index b751e4e..5c39d3d 100644
--- a/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java
+++ b/backend/src/test/java/com/magistr/app/controller/UserControllerTest.java
@@ -23,13 +23,16 @@ class UserControllerTest {
void teachersUseUnifiedUserResponseMapping() {
UserRepository userRepository = mock(UserRepository.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
+ TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
UserController controller = new UserController(
userRepository,
null,
departmentRepository,
- mock(TeacherDepartmentAssignmentRepository.class)
+ assignmentRepository
);
User teacher = user(10L, "teacher", Role.TEACHER, 2L);
+ when(assignmentRepository.findDepartmentTeachersAtDate(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.any()))
+ .thenReturn(List.of());
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 2L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(teacher));
when(departmentRepository.findById(2L))
diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md
index 9225edb..7e3cb74 100644
--- a/docs/ACADEMIC_CALENDAR.md
+++ b/docs/ACADEMIC_CALENDAR.md
@@ -19,6 +19,7 @@
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
+- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
@@ -64,7 +65,8 @@
3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
-6. В разделе групп администратор назначает календарный график группе на нужный учебный год.
+6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
+7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря.
diff --git a/docs/API.md b/docs/API.md
index faa2506..fb4570d 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -147,6 +147,8 @@ Refresh-токен ротируется при каждом успешном о
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`). Ответ использует ту же структуру `UserResponse`, что и `GET /api/users`.
+Выборка учитывает активные записи `teacher_department_assignments` на текущую дату и legacy-привязку `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
+
### `POST /api/users`
Создание пользователя.
@@ -165,7 +167,7 @@ Refresh-токен ротируется при каждом успешном о
**Валидация:**
- `username` — обязателен и уникален
-- `password` — минимум 4 символа
+- `password` — минимум 8 символов
- `role` — `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
@@ -196,7 +198,40 @@ Refresh-токен ротируется при каждом успешном о
### `GET /api/users/teachers/by-department/{departmentId}?date=2026-06-01`
-Список преподавателей кафедры на конкретную дату по таблице истории.
+Список преподавателей кафедры на конкретную дату по таблице истории и legacy-привязке `users.department_id`. Роль `DEPARTMENT` может запрашивать только свою кафедру.
+
+---
+
+## Заявки на создание преподавателей
+
+Администратор просматривает и обрабатывает заявки кафедр на создание новых преподавателей.
+
+| Метод | URL | Назначение |
+|-------|-----|------------|
+| `GET` | `/api/teacher-requests?status=PENDING` | Список заявок, опционально с фильтром статуса |
+| `POST` | `/api/teacher-requests/{id}/approve` | Одобрить заявку, скорректировать данные и создать преподавателя |
+| `POST` | `/api/teacher-requests/{id}/reject` | Отклонить заявку |
+
+**Тело одобрения:**
+```json
+{
+ "departmentId": 2,
+ "username": "teacher.new",
+ "password": "secure-pass",
+ "fullName": "Новый Преподаватель",
+ "jobTitle": "Доцент",
+ "reviewComment": "Данные проверены"
+}
+```
+
+Пароль задаёт только администратор при одобрении заявки. Сама заявка пароль не хранит.
+
+**Тело отклонения:**
+```json
+{
+ "reviewComment": "Нужно уточнить ФИО"
+}
+```
---
@@ -361,6 +396,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки |
+| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
+| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика |
**Тело создания/обновления графика:**
```json
@@ -375,6 +412,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
```
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
+`courseCount` должен быть в диапазоне `1..8`.
**Ячейка сетки графика:**
```json
@@ -388,6 +426,17 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
}
```
+**Привязка дисциплин к графику:**
+```json
+[
+ { "semesterNumber": 1, "subjectId": 1 },
+ { "semesterNumber": 1, "subjectId": 2 },
+ { "semesterNumber": 2, "subjectId": 3 }
+]
+```
+
+`semesterNumber` — номер учебного семестра внутри графика: для 4 курсов доступны значения `1..8`. API принимает только существующие неархивные дисциплины из `/api/subjects`, не допускает дубли одной дисциплины в одном семестре и возвращает сохранённые записи с `subjectName`, `subjectCode` и `departmentId`. `PUT /subjects` выполняет атомарную полную замену списка: перед вставкой нового набора старые привязки этого графика удаляются и синхронизируются с БД.
+
### `POST /api/admin/schedule-rules`
Создание правила динамического расписания.
@@ -435,6 +484,25 @@ CRUD доступен по:
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
+`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
+
+```json
+{
+ "message": "Невозможно сохранить правило: слот занят",
+ "conflictRule": {
+ "id": 12,
+ "subjectName": "Математический анализ",
+ "semesterId": 1,
+ "groupNames": ["ИБ-101"],
+ "slots": []
+ },
+ "conflictFields": ["classroom"],
+ "conflictReasons": ["Аудитория"]
+}
+```
+
+`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
+
### `GET /api/lesson-types`
Справочник типов занятий для конструктора правил расписания.
@@ -539,10 +607,33 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/subjects/{subjectId}/comments` | Комментарии дисциплины |
| `POST` | `/api/department/subjects/{subjectId}/comments` | Добавить комментарий |
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
+| `POST` | `/api/department/teachers/{teacherId}/assignments` | Добавить существующего преподавателя на кафедру |
+| `GET` | `/api/department/teacher-requests` | Заявки кафедры на создание преподавателей |
+| `POST` | `/api/department/teacher-requests` | Создать заявку на нового преподавателя |
| `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
+`POST /api/department/teachers/{teacherId}/assignments` создаёт дополнительную открытую связь преподавателя с кафедрой (`is_primary=false`). Для роли `DEPARTMENT` кафедра берётся из текущего пользователя, администратор может передать `departmentId`.
+
+```json
+{
+ "departmentId": 2,
+ "comment": "Совместительство"
+}
+```
+
+`POST /api/department/teacher-requests` принимает логин, ФИО, должность и комментарий. Повторная pending-заявка с тем же логином запрещена.
+
+```json
+{
+ "username": "teacher.new",
+ "fullName": "Новый Преподаватель",
+ "jobTitle": "Доцент",
+ "comment": "Нужен для дисциплин кафедры"
+}
+```
+
---
## Кафедры и специальности
@@ -686,6 +777,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
+Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы.
+Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`.
+Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления.
+
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп |
@@ -729,6 +824,30 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
+**Ответ назначения:**
+```json
+{
+ "id": 12,
+ "groupId": 1,
+ "groupName": "ИВТ-21-1",
+ "academicYearId": 1,
+ "academicYearTitle": "2025-2026",
+ "calendarId": 5,
+ "calendarTitle": "09.03.04 очная форма 2025-2026",
+ "subjects": [
+ {
+ "id": 44,
+ "calendarId": 5,
+ "semesterNumber": 1,
+ "subjectId": 1,
+ "subjectName": "Высшая математика",
+ "subjectCode": "Б1.О.01",
+ "departmentId": 1
+ }
+ ]
+}
+```
+
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
---
diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md
index 88722a7..b9bcb54 100644
--- a/docs/BUSINESS_LOGIC.md
+++ b/docs/BUSINESS_LOGIC.md
@@ -8,7 +8,7 @@
|------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
-| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
+| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
@@ -27,7 +27,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
-- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя.
+- привязки `/api/teacher-subjects` разрешены только если преподаватель и дисциплина относятся к кафедре текущего пользователя;
+- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
+- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
---
@@ -58,8 +60,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
-- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
+- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
+- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms)
@@ -93,14 +96,19 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
-### Перевод преподавателей между кафедрами
+### Кафедральные связи преподавателей
-Текущая кафедра преподавателя хранится в `users.department_id`, но история переводов фиксируется в `teacher_department_assignments`.
+Основная кафедра преподавателя хранится в `users.department_id` для совместимости, а актуальные и исторические связи фиксируются в `teacher_department_assignments`. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся основной, дополнительные связи создаются как неосновные.
Правила:
- у преподавателя должна быть одна открытая основная кафедра;
+- преподаватель может иметь несколько открытых неосновных кафедр;
+- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается датой `valid_to`, новая открывается с `valid_from`;
+- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
+- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
+- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- расписание и отчёты за прошлые периоды не теряют связь с прежней кафедрой.
---
@@ -118,6 +126,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `academic_calendar_days` | Дневная сетка по курсу и дате |
+| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
@@ -171,13 +180,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
-- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
+- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
+- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
### Точечные изменения расписания
diff --git a/docs/BUTTON_STYLING_PLAN.md b/docs/BUTTON_STYLING_PLAN.md
deleted file mode 100644
index 68db2c9..0000000
--- a/docs/BUTTON_STYLING_PLAN.md
+++ /dev/null
@@ -1,420 +0,0 @@
-# План стилизации кнопок
-
-Дата аудита: 01.06.2026
-Среда аудита: `https://localhost`
-Метод: визуальный обход через Playwright, фиксация скриншотов и computed styles.
-
-## 1. Краткое резюме
-
-Интерфейс уже имеет узнаваемую базу: тёмная админ-панель, фиолетовый акцент, компактные таблицы и отдельные публичные кабинеты преподавателя/студента. Главная проблема не в отдельных кнопках, а в отсутствии единого контракта между смыслом действия и его визуальным видом.
-
-Сейчас одинаковые классы могут выглядеть по-разному в разных SPA, а одинаковые действия могут получать разные цвета, размеры и плотность. Из-за этого пользователь каждый раз заново считывает интерфейс: где главное действие, где безопасное второстепенное, а где рискованное.
-
-Цель новой структуры: закрепить единую кнопку как системный компонент с понятными ролями, размерами и правилами использования.
-
-## 2. Что было проверено
-
-| Раздел | Проверенные сценарии | Скриншот |
-|---|---|---|
-| Авторизация | Вход, показ пароля, переключение темы | [`audit-buttons-01-login.png`](../audit-buttons-01-login.png) |
-| Дашборд админки | Быстрые переходы, recheck, сайдбар | [`audit-buttons-02-dashboard.png`](../audit-buttons-02-dashboard.png) |
-| Пользователи | Создание пользователя, архивирование | [`audit-buttons-03-users-form.png`](../audit-buttons-03-users-form.png) |
-| Меню настроек | Выпадающее меню, выход, переход в настройки | [`audit-buttons-04-settings-menu.png`](../audit-buttons-04-settings-menu.png) |
-| Настройки | Навигация отдельной SPA | [`audit-buttons-05-settings-general.png`](../audit-buttons-05-settings-general.png) |
-| Кабинет преподавателя | Недели, сегодня, выход, тема | [`audit-buttons-06-teacher.png`](../audit-buttons-06-teacher.png) |
-| Кабинет студента | Недели, сегодня, выход, тема | [`audit-buttons-07-student.png`](../audit-buttons-07-student.png) |
-| Модальное окно | Создание ручной сетки, отмена, закрытие | [`audit-buttons-08-modal.png`](../audit-buttons-08-modal.png) |
-
-Дополнительно были сохранены скриншоты отдельных разделов: календарный график, аудитории, группы, дисциплины, расписание, формы обучения, база данных, временные слоты.
-
-## 3. Текущее состояние
-
-### 3.1. Авторизация
-
-
-
-Найдены кнопки:
-
-| Кнопка | Текущая роль | Комментарий |
-|---|---|---|
-| `Войти` | Primary | Корректно считывается как главное действие экрана. |
-| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. |
-| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. |
-
-Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру.
-
-### 3.2. Дашборд админки
-
-
-
-Найдены кнопки и ссылки-кнопки:
-
-| Кнопка | Текущий вид | Проблема |
-|---|---|---|
-| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. |
-| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. |
-| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. |
-| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. |
-| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. |
-
-Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию.
-
-### 3.3. Формы и таблицы админки
-
-
-
-Типичные кнопки:
-
-| Кнопка | Смысл | Текущее впечатление |
-|---|---|---|
-| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. |
-| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. |
-| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. |
-| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. |
-
-Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках.
-
-### 3.4. Меню настроек и выход
-
-
-
-Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль.
-
-Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки.
-
-### 3.5. Отдельная SPA настроек
-
-
-
-В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему.
-
-Пример различий:
-
-| Класс | Основная админка | Настройки |
-|---|---|---|
-| `btn-primary` | Часто прозрачный фон, белый текст, визуальная заливка может приходить из другого слоя | Фиолетовая заливка `rgb(139, 92, 246)`, высота `40px`, жирность `700` |
-| `btn-delete` | Красный tint, высота часто `28px`, font-weight `400` | Красный tint, высота `40px`, font-weight `700` |
-| `btn-secondary` | Фиолетовый tint или светлая surface-кнопка | Нейтральная surface-кнопка |
-
-Вывод: одинаковое имя класса не является дизайн-контрактом. Компонент кнопки нужно нормализовать на уровне токенов и модификаторов.
-
-### 3.6. Кабинеты преподавателя и студента
-
-
-
-
-
-В публичных кабинетах используется отдельная светлая система:
-
-| Элемент | Преподаватель | Студент |
-|---|---|---|
-| `Сегодня` | Primary blue `rgb(29, 78, 216)` | Primary teal `rgb(15, 118, 110)` |
-| `Выйти` | Outline, белый фон, radius `8px` | Outline, белый фон, radius `8px` |
-| `‹` / `›` | Outline icon-like buttons | Outline icon-like buttons |
-| `Переключить тему` | Текстовый символ `◐` | Текстовый символ `◐` |
-
-Проблема: одно действие `Сегодня` меняет цвет в зависимости от кабинета. Это допустимо для брендинга ролей, но плохо для системных кнопок. Ролевые цвета лучше использовать в расписании, бейджах и карточках, а не в базовой кнопочной иерархии.
-
-### 3.7. Модальные окна
-
-
-
-Найдены:
-
-| Кнопка | Роль | Рекомендация |
-|---|---|---|
-| `Создать сетку` | Primary | Оставить primary. |
-| `Отмена` | Secondary/Ghost | Сделать ghost или secondary без акцента. |
-| `Закрыть` (`×`) | Icon close | Привести к icon-only button `40x40` или `36x36`, с `aria-label="Закрыть"`. |
-
-Модальная структура в целом правильная, но её нужно подчинить тем же токенам, что и остальные кнопки.
-
-## 4. Семантическая группировка найденных кнопок
-
-### Primary: главное действие
-
-Примеры: `Войти`, `Создать`, `Добавить`, `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот`, `Создать сетку`.
-
-Правило: одна primary-кнопка на форму, модальное окно или смысловой блок. Если на экране несколько форм, primary может быть в каждой форме, но не должна превращаться в оформление всех кнопок подряд.
-
-### Secondary: обычное второстепенное действие
-
-Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус`, `Преподаватели`, `Загрузить сетку`, `Заполнить диапазон`.
-
-Правило: использовать для действий, которые помогают выполнить задачу, но не являются главным подтверждением.
-
-### Tertiary / Ghost: тихое действие
-
-Примеры: `Очистить`, `Очистить форму`, `Отмена`, `Назад в панель`, `Свернуть`, `Из формы`, `Все`, `Точечные изменения / Просмотр`.
-
-Правило: использовать там, где действие должно быть доступно, но не конкурировать с сохранением или созданием.
-
-### Danger: деструктивное действие
-
-Примеры: `Удалить`.
-
-Правило: только для необратимых или почти необратимых операций. Красный цвет должен быть зарезервирован именно под риск.
-
-### Danger subtle: рискованное, но не удаление
-
-Примеры: `Архив`, `Архивировать`, `Удалить сетку`, `Выйти`.
-
-Правило: использовать для действий с негативным или сессионным эффектом, но без визуальной паники solid-red кнопки.
-
-### Icon button: компактное служебное действие
-
-Примеры: `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›`.
-
-Правило: фиксированный квадратный размер, единое состояние hover/focus, обязательный доступный текст через `aria-label`.
-
-### Navigation item: навигация, а не кнопка действия
-
-Примеры: пункты сайдбара `Дашборд`, `Конструктор правил`, `Календарный график`, `Пользователи`, `Общие настройки`.
-
-Правило: не смешивать навигацию с action button. У неё отдельные active, hover и focus-состояния.
-
-## 5. Предлагаемая дизайн-система кнопок
-
-### 5.1. Токены
-
-| Токен | Светлая тема | Тёмная тема | Назначение |
-|---|---|---|---|
-| `--button-primary-bg` | `#4F46E5` | `#8B5CF6` | Основное действие |
-| `--button-primary-bg-hover` | `#4338CA` | `#7C3AED` | Hover primary |
-| `--button-primary-text` | `#FFFFFF` | `#FFFFFF` | Текст primary |
-| `--button-secondary-bg` | `#FFFFFF` | `rgba(255,255,255,0.04)` | Второстепенная поверхность |
-| `--button-secondary-bg-hover` | `#F1F5F9` | `rgba(255,255,255,0.08)` | Hover secondary |
-| `--button-secondary-border` | `#CBD5E1` | `rgba(255,255,255,0.10)` | Граница secondary |
-| `--button-secondary-text` | `#334155` | `#F8FAFC` | Текст secondary |
-| `--button-ghost-bg-hover` | `#F8FAFC` | `rgba(255,255,255,0.06)` | Hover ghost |
-| `--button-danger-bg` | `#DC2626` | `#DC2626` | Удаление |
-| `--button-danger-bg-hover` | `#B91C1C` | `#B91C1C` | Hover удаления |
-| `--button-danger-subtle-bg` | `#FEF2F2` | `rgba(239,68,68,0.13)` | Риск без solid-заливки |
-| `--button-danger-subtle-text` | `#B91C1C` | `#FECACA` | Текст risk subtle |
-| `--button-focus-ring` | `rgba(79,70,229,0.35)` | `rgba(139,92,246,0.45)` | Focus outline |
-
-Примечание: для всего продукта лучше выбрать один primary-акцент. Для текущей тёмной админки логично оставить фиолетовый `#8B5CF6`, а для светлой темы использовать более читаемый `#4F46E5`. Ролевые цвета преподавателя и студента лучше вынести из кнопок в расписание и бейджи.
-
-### 5.2. Размеры
-
-| Размер | Высота | Padding | Шрифт | Где применять |
-|---|---:|---|---|---|
-| `sm` | `32px` | `0 12px` | `13px / 600` | Таблицы, компактные действия строк |
-| `md` | `40px` | `0 16px` | `14px / 600` | Формы, фильтры, модальные окна |
-| `lg` | `48px` | `0 20px` | `15px / 700` | Вход, главный CTA экрана |
-| `icon-sm` | `32x32` | `0` | icon `18px` | Табличные иконки |
-| `icon-md` | `40x40` | `0` | icon `20px` | Верхние панели, модалки, навигация недель |
-
-Базовый `border-radius`: `8px`.
-
-Почему `8px`: интерфейс административный и рабочий. Радиус `8px` сохраняет мягкость, но выглядит собраннее, чем повсеместные `10px`, особенно в таблицах и плотных формах.
-
-### 5.3. Варианты
-
-#### Primary
-
-Использовать для главного подтверждения.
-
-| Состояние | Стиль |
-|---|---|
-| Default | Фон primary, белый текст, без видимой границы |
-| Hover | Фон темнее на 8-12% |
-| Focus | Видимый ring `2px` |
-| Disabled | Нейтральный фон, muted-текст, `cursor: not-allowed` |
-
-Примеры: `Войти`, `Создать`, `Сохранить правило`, `Создать сетку`.
-
-#### Secondary
-
-Использовать для обычных действий рядом с primary.
-
-| Состояние | Стиль |
-|---|---|
-| Default | Surface-фон, тонкая граница, основной текст |
-| Hover | Чуть более светлая/контрастная поверхность |
-| Focus | Такой же focus ring, как у primary |
-| Disabled | Opacity `0.5` |
-
-Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`.
-
-#### Ghost
-
-Использовать для тихих действий.
-
-| Состояние | Стиль |
-|---|---|
-| Default | Прозрачный фон, muted/regular text |
-| Hover | Лёгкий surface-hover |
-| Focus | Ring без изменения иерархии |
-
-Примеры: `Отмена`, `Очистить`, `Назад в панель`, `Свернуть`.
-
-#### Danger
-
-Использовать только для удаления.
-
-| Состояние | Стиль |
-|---|---|
-| Default | Красная заливка, белый текст |
-| Hover | Более тёмный красный |
-| Focus | Красный или общий focus ring |
-
-Примеры: `Удалить`.
-
-#### Danger subtle
-
-Использовать для рискованных действий без полного удаления.
-
-| Состояние | Стиль |
-|---|---|
-| Default | Красный tint-фон, красный текст, мягкая border |
-| Hover | Более насыщенный tint |
-| Focus | Видимый ring |
-
-Примеры: `Архивировать`, `Архив`, `Выйти`, `Удалить сетку`.
-
-#### Icon button
-
-Использовать для действий, где текст заменён иконкой.
-
-| Параметр | Правило |
-|---|---|
-| Размер | `32x32` в таблицах, `40x40` в тулбарах |
-| Иконка | 18-20px, один набор иконок |
-| Текст | Только через `aria-label` |
-| Форма | Квадрат с radius `8px`, не круг, кроме специальных случаев avatar/fab |
-
-Примеры: `Переключить тему`, `Показать пароль`, `Закрыть`, `Предыдущая неделя`, `Следующая неделя`.
-
-## 6. Маппинг текущих кнопок на новую систему
-
-| Текущие кнопки | Новый тип | Размер |
-|---|---|---|
-| `Войти` | `Primary` | `lg` |
-| `Создать`, `Добавить` | `Primary` | `md` |
-| `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот` | `Primary` | `md` |
-| `Создать сетку` | `Primary` | `md` |
-| `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус` | `Secondary` | `sm` в таблицах, `md` в формах |
-| `Преподаватели`, `Подгруппы`, `Календарь`, `Сетка` | `Secondary` | `sm` |
-| `Очистить`, `Очистить форму`, `Отмена`, `Свернуть`, `Из формы`, `Все` | `Ghost` | `sm` или `md` |
-| `Точечные изменения / Просмотр`, `Назад в панель` | `Ghost navigation` | `md` |
-| `Удалить` | `Danger` | `sm` в таблицах, `md` в модалках |
-| `Архив`, `Архивировать`, `Выйти`, `Удалить сетку` | `Danger subtle` | `sm` или `md` |
-| `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, `‹`, `›` | `Icon button` | `icon-sm` или `icon-md` |
-| Пункты сайдбара | `Nav item` | отдельный nav-компонент |
-
-## 7. Правила композиции
-
-### Формы
-
-Primary-кнопка должна стоять в конце формы или action row. Рядом с ней допустимы secondary/ghost-действия, но не ещё одна primary без причины.
-
-Рекомендуемый порядок:
-
-| Сценарий | Порядок |
-|---|---|
-| Форма создания | `Очистить` как ghost, `Создать` как primary |
-| Форма редактирования | `Отмена` как ghost, `Сохранить` как primary |
-| Фильтр | `Сбросить` как ghost, `Показать` как secondary или primary, если это единственное действие блока |
-
-### Таблицы
-
-В строках таблицы использовать только `sm`.
-
-Рекомендуемый порядок:
-
-1. Основное безопасное действие: `Изменить`, `Преподаватели`, `Подгруппы`.
-2. Дополнительное действие: `Календарь`, `Сетка`.
-3. Рискованное действие: `Архивировать` или `Удалить`.
-
-Удаление лучше отделять визуально от остальных кнопок небольшим gap.
-
-### Модальные окна
-
-В модалке primary должен соответствовать заголовку и цели модалки.
-
-Пример:
-
-| Заголовок | Primary | Secondary/Ghost |
-|---|---|---|
-| `Новая ручная сетка` | `Создать сетку` | `Отмена` |
-| `Удалить слот` | `Удалить` как danger | `Отмена` |
-| `Редактировать слот` | `Сохранить слот` | `Отмена` |
-
-Кнопка закрытия всегда icon-only с `aria-label="Закрыть"`.
-
-### Навигация
-
-Сайдбарные пункты не должны наследовать стили action-кнопок. У них отдельная логика:
-
-| Состояние | Стиль |
-|---|---|
-| Default | Прозрачный фон, muted-текст |
-| Hover | Нейтральный surface-hover |
-| Active | Акцентный tint-фон, accent-текст |
-| Focus | Видимый ring/outline |
-
-## 8. Главные исправления по приоритету
-
-| Приоритет | Что сделать | Почему важно |
-|---|---|---|
-| P1 | Нормализовать `btn-primary`, `btn-secondary`, `btn-delete` между основной админкой и настройками | Один класс должен означать один визуальный контракт |
-| P1 | Развести `Danger` и `Danger subtle` | `Удалить` и `Архивировать` не должны выглядеть одинаково опасными |
-| P1 | Ввести размерную шкалу `sm/md/lg/icon` | Таблицы и формы станут визуально ровнее |
-| P2 | Унифицировать `Переключить тему`, `Закрыть`, `‹`, `›`, `Показать пароль` как icon buttons | Повысит предсказуемость и доступность |
-| P2 | Убрать ролевые primary-цвета из student/teacher кнопок | Одинаковые действия будут считываться одинаково |
-| P3 | Заменить текстовые символы `◐`, `×`, `‹`, `›` на иконки единого набора | Интерфейс станет аккуратнее и доступнее |
-
-## 9. Визуальный референс поведения
-
-```text
-Primary
-[ Сохранить правило ]
-Фон: акцентный, текст белый. Только главное действие.
-
-Secondary
-[ Изменить ]
-Фон: поверхность, тонкая граница. Обычное действие.
-
-Ghost
-[ Отмена ]
-Фон: прозрачный. Тихое действие без конкуренции.
-
-Danger
-[ Удалить ]
-Фон: красный. Только необратимое действие.
-
-Danger subtle
-[ Архивировать ]
-Фон: красный tint. Рискованное, но не полноценное удаление.
-
-Icon
-[ icon ]
-Фиксированный квадрат. Доступное название через aria-label.
-```
-
-## 10. Почему это решение улучшит интерфейс
-
-Единая кнопочная система снижает когнитивную нагрузку. Пользователь перестаёт разгадывать каждый экран и начинает быстро узнавать роли: главное действие, второстепенное действие, отмена, удаление, навигация.
-
-Разделение `Danger` и `Danger subtle` особенно важно для админ-панели. Сейчас `Архивировать` визуально конкурирует с `Удалить`, хотя риск у этих действий разный. После унификации красный solid будет означать только настоящее удаление.
-
-Единая размерная шкала сделает таблицы спокойнее. В строках не будет скачков высоты и плотности, а формы и модалки начнут выглядеть как части одной системы.
-
-Общий primary-цвет вернёт продукту целостность. Ролевые цвета можно сохранить там, где они помогают считывать расписание и принадлежность данных, но базовые действия должны оставаться стабильными.
-
-## 11. Приложение: дополнительные скриншоты
-
-| Раздел | Скриншот |
-|---|---|
-| Конструктор правил |  |
-| Календарный график |  |
-| Просмотр расписаний |  |
-| Аудиторный фонд |  |
-| Группы обучения |  |
-| Структура вуза |  |
-| Дисциплины |  |
-| Пользователи |  |
-| Временные слоты |  |
-| Формы обучения |  |
-| База данных |  |
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index bc3f180..146d48d 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -148,6 +148,21 @@ erDiagram
TEXT comment
}
+ teacher_creation_requests {
+ BIGSERIAL id PK
+ BIGINT department_id FK
+ VARCHAR username
+ VARCHAR full_name
+ VARCHAR job_title
+ TEXT comment
+ VARCHAR status
+ BIGINT requested_by FK
+ BIGINT reviewed_by FK
+ TIMESTAMP reviewed_at
+ TEXT review_comment
+ BIGINT created_teacher_id FK
+ }
+
subject_comments {
BIGSERIAL id PK
BIGINT subject_id FK
@@ -233,6 +248,14 @@ erDiagram
BIGINT activity_type_id FK
}
+ academic_calendar_subjects {
+ BIGSERIAL id PK
+ BIGINT calendar_id FK
+ INT semester_number
+ BIGINT subject_id FK
+ TIMESTAMP created_at
+ }
+
student_group_calendar_assignments {
BIGSERIAL id PK
BIGINT group_id FK
@@ -306,11 +329,14 @@ erDiagram
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id"
+ departments ||--o{ teacher_creation_requests : "department_id"
+ users ||--o{ teacher_creation_requests : "requested_by/reviewed_by/created_teacher_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id"
+ subjects ||--o{ academic_calendar_subjects : "subject_id"
subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
@@ -325,6 +351,7 @@ erDiagram
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_id"
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
+ academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
@@ -443,7 +470,7 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов |
-Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
+Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
@@ -528,7 +555,27 @@ erDiagram
| `created_at` | TIMESTAMP | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
-Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя.
+Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной кафедры у преподавателя. Индекс `uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной кафедрой, но позволяет преподавателю иметь несколько открытых неосновных кафедр.
+
+#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
+| Колонка | Тип | Описание |
+|---------|-----|----------|
+| `id` | BIGSERIAL PK | ID заявки |
+| `department_id` | BIGINT FK → departments | Кафедра, которая запрашивает преподавателя |
+| `username` | VARCHAR(50) | Предлагаемый логин |
+| `full_name` | VARCHAR(255) | ФИО преподавателя |
+| `job_title` | VARCHAR(255) | Должность |
+| `comment` | TEXT | Комментарий кафедры |
+| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
+| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
+| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
+| `reviewed_at` | TIMESTAMP | Дата рассмотрения |
+| `review_comment` | TEXT | Комментарий администратора |
+| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
+| `created_at` | TIMESTAMP | Дата создания заявки |
+| `updated_at` | TIMESTAMP | Дата последнего изменения |
+
+Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
#### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание |
@@ -608,7 +655,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
-| `course_count` | INT CHECK(> 0) | Количество курсов в сетке |
+| `course_count` | INT CHECK(1–8) | Количество курсов в сетке |
| `created_at` | TIMESTAMP | Дата создания |
| `updated_at` | TIMESTAMP | Дата обновления |
@@ -623,6 +670,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
+#### `academic_calendar_subjects` — Дисциплины календарного графика
+| Колонка | Тип | Описание |
+|---------|-----|----------|
+| `id` | BIGSERIAL PK | ID |
+| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
+| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
+| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
+| `created_at` | TIMESTAMP | Дата создания привязки |
+
+Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend по `academic_calendars.course_count * 2`.
+
#### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -713,13 +771,13 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
-> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
+> Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
-| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
+| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index d8f54d5..a793505 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -37,35 +37,31 @@ frontend/
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
-│ │ ├── users.js # Управление пользователями
-│ │ ├── groups.js # Управление группами
-│ │ ├── classrooms.js # Управление аудиториями
-│ │ ├── subjects.js # Управление дисциплинами
-│ │ ├── equipments.js # Управление оборудованием
-│ │ ├── edu-forms.js # Формы обучения
-│ │ ├── profiles.js # Профили обучения специальностей
+│ │ ├── dashboard.js # Дашборд
+│ │ ├── users.js # Управление пользователями
+│ │ ├── teacher-requests.js # Заявки кафедр на преподавателей
+│ │ ├── groups.js # Управление группами
+│ │ ├── classrooms.js # Управление аудиториями
+│ │ ├── subjects.js # Управление дисциплинами
+│ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
-│ │ ├── schedule.js # Конструктор правил расписания
+│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики
-│ │ ├── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
-│ │ ├── database.js # Управление тенантами
-│ │ └── departments-data.js # Создание кафедры/специальности
+│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ ├── views/ # HTML-шаблоны представлений
+│ │ ├── dashboard.html
│ │ ├── users.html
+│ │ ├── teacher-requests.html
│ │ ├── groups.html
│ │ ├── classrooms.html
│ │ ├── subjects.html
-│ │ ├── equipments.html
-│ │ ├── edu-forms.html
-│ │ ├── profiles.html
+│ │ ├── university-structure.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
-│ │ ├── auditorium-workload.html
-│ │ ├── database.html
-│ │ └── departments-data.html
+│ │ └── auditorium-workload.html
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
@@ -129,32 +125,32 @@ frontend/
| Tab | Описание | API |
|-----|----------|-----|
+| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` |
-| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
-| `edu-forms` | Формы обучения | `/api/education-forms` |
-| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
-| `equipments` | Оборудование | `/api/equipments` |
+| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` |
-| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
+| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
+| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/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` |
-| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
+| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/education-forms`, `/api/subjects` |
| `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` |
-| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок
-- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
+- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
+- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
+- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
+- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `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`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
-- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
+- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
+- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
+- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
+- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
-- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -208,6 +204,8 @@ export const api = {
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
+После мутаций `api.js` инвалидирует кэш не только по точному URL, но и по связанным префиксам. Для заявок и кафедральных привязок очищаются `/api/users`, `/api/users/teachers`, `/api/teacher-requests`, `/api/department/teacher-requests` и `/api/department/teachers`, чтобы таблицы заявок и списки преподавателей обновлялись без ручного сброса страницы.
+
---
## Аутентификация (Frontend)
diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css
index 444e36c..9cb01b8 100755
--- a/frontend/admin/css/components.css
+++ b/frontend/admin/css/components.css
@@ -385,6 +385,22 @@
align-items: end;
}
+.department-workspace-control-card {
+ display: grid;
+ gap: 1rem;
+}
+
+.department-workspace-control-card .card-header-row {
+ margin-bottom: 0;
+}
+
+.department-workspace-filter-grid {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 0.65fr));
+ gap: 1rem;
+ align-items: end;
+}
+
.schedule-view-metrics,
.department-workspace-grid {
display: grid;
@@ -396,7 +412,99 @@
align-items: stretch;
}
+.department-workspace-grid-wide {
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ align-items: stretch;
+}
+
+.department-workspace-actions-grid {
+ gap: 1rem;
+}
+
+.department-subject-import-card {
+ grid-column: span 7;
+ grid-row: span 2;
+}
+
+.department-teacher-assign-card {
+ grid-column: span 5;
+}
+
+.department-teacher-request-card {
+ grid-column: span 5;
+}
+
+.department-teachers-card {
+ grid-column: 1 / -1;
+}
+
.department-subject-import-card,
+.department-teacher-assign-card,
+.department-teacher-request-card,
+.department-teachers-card,
+.department-workspace-table-card {
+ min-width: 0;
+}
+
+.department-subject-import-card,
+.department-teacher-assign-card,
+.department-teacher-request-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.department-subject-import-card .stack-form,
+.department-teacher-assign-card .stack-form,
+.department-teacher-request-card .stack-form {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.department-subject-import-card .subject-import-slots-panel {
+ flex: 1;
+}
+
+.department-subject-import-card .stack-form > .btn,
+.department-teacher-assign-card .stack-form > .btn,
+.department-teacher-request-card .stack-form > .btn {
+ align-self: flex-start;
+ margin-top: auto;
+}
+
+.teacher-request-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ min-width: 180px;
+}
+
+.teacher-request-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+#teacher-requests-tbody input,
+#teacher-requests-tbody select {
+ min-width: 150px;
+ width: 100%;
+ padding: 0.5rem 0.65rem;
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.85rem;
+}
+
+#teacher-requests-tbody input:focus,
+#teacher-requests-tbody select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
.department-teachers-card {
display: flex;
flex-direction: column;
@@ -534,6 +642,274 @@
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
+.schedule-conflict-modal {
+ z-index: 1200;
+ padding: 0.35rem;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+#schedule-conflict-modal .schedule-conflict-card {
+ width: min(1760px, calc(100vw - 0.7rem));
+ max-width: calc(100vw - 0.7rem);
+ max-height: none;
+ box-sizing: border-box;
+ margin: auto;
+ padding: 1rem;
+ overflow: visible;
+}
+
+.schedule-conflict-head {
+ display: grid;
+ gap: 0.3rem;
+ margin-right: 2rem;
+ margin-bottom: 0.65rem;
+}
+
+.schedule-conflict-head h2 {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 1.05rem;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.schedule-conflict-head p {
+ margin: 0;
+ color: var(--text-secondary);
+ line-height: 1.35;
+}
+
+.schedule-conflict-kicker {
+ width: max-content;
+ padding: 0.2rem 0.55rem;
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ border-radius: 999px;
+ background: rgba(245, 158, 11, 0.12);
+ color: var(--warning);
+ font-size: 0.72rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.schedule-conflict-reasons {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+ margin-top: 0.15rem;
+}
+
+.schedule-conflict-reasons[hidden] {
+ display: none;
+}
+
+.schedule-conflict-reasons span {
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.schedule-conflict-reasons strong {
+ padding: 0.2rem 0.5rem;
+ border: 1px solid rgba(245, 158, 11, 0.35);
+ border-radius: 999px;
+ background: rgba(245, 158, 11, 0.12);
+ color: var(--warning);
+ font-size: 0.76rem;
+ font-weight: 800;
+}
+
+.schedule-conflict-rules {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 0.65rem;
+}
+
+.schedule-conflict-rule-box {
+ display: grid;
+ gap: 0.25rem;
+ min-width: 0;
+ padding: 0.65rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+}
+
+.schedule-conflict-rule-box-active {
+ border-color: rgba(245, 158, 11, 0.35);
+ background: rgba(245, 158, 11, 0.08);
+}
+
+.schedule-conflict-rule-box span,
+.schedule-conflict-slot-fixed span {
+ color: var(--text-secondary);
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.schedule-conflict-rule-box strong,
+.schedule-conflict-slot-fixed strong {
+ min-width: 0;
+ color: var(--text-primary);
+ overflow-wrap: anywhere;
+}
+
+.schedule-conflict-rule-box small,
+.schedule-conflict-slot-fixed small {
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.schedule-conflict-form {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.schedule-conflict-slots {
+ display: grid;
+ gap: 0.55rem;
+ max-height: none;
+ overflow: visible;
+ padding-right: 0;
+}
+
+.schedule-conflict-slot-row {
+ display: grid;
+ grid-template-columns: minmax(160px, 1.05fr) minmax(104px, 0.72fr) minmax(112px, 0.72fr) minmax(132px, 0.85fr) minmax(190px, 1.2fr) minmax(160px, 0.95fr);
+ gap: 0.5rem;
+ align-items: end;
+ min-width: 0;
+ max-width: 100%;
+ padding: 0.6rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--bg-input) 88%, transparent);
+}
+
+.schedule-conflict-slot-fixed {
+ display: grid;
+ gap: 0.25rem;
+ align-self: stretch;
+ align-content: center;
+ min-width: 0;
+}
+
+.schedule-conflict-slot-fixed.schedule-conflict-field-hit {
+ padding-left: 0.55rem;
+ border-left: 3px solid rgba(245, 158, 11, 0.75);
+}
+
+.schedule-conflict-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding-top: 0;
+}
+
+.schedule-conflict-card .form-group,
+.schedule-move-slot-card .form-group {
+ min-width: 0;
+}
+
+.schedule-conflict-card .form-group label,
+.schedule-move-slot-card .form-group label {
+ margin-bottom: 0.28rem;
+ font-size: 0.7rem;
+}
+
+.schedule-conflict-card .form-group select,
+.schedule-move-slot-card .form-group select {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ padding: 0.55rem 0.65rem;
+ font-size: 0.84rem;
+}
+
+.schedule-conflict-card .form-alert,
+.schedule-move-slot-card .form-alert {
+ margin-top: 0;
+}
+
+.schedule-conflict-field-hit label {
+ color: var(--warning);
+}
+
+.schedule-conflict-field-hit select {
+ border-color: rgba(245, 158, 11, 0.65);
+ background: color-mix(in srgb, rgba(245, 158, 11, 0.15) 35%, var(--bg-input));
+ box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.12);
+}
+
+.schedule-visual-context-menu {
+ position: fixed;
+ z-index: 1300;
+ display: grid;
+ min-width: 220px;
+ padding: 0.35rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: rgba(10, 10, 15, 0.97);
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
+}
+
+.schedule-visual-context-menu[hidden] {
+ display: none;
+}
+
+[data-theme="light"] .schedule-visual-context-menu {
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
+}
+
+.schedule-visual-context-menu button {
+ width: 100%;
+ padding: 0.65rem 0.75rem;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.88rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.schedule-visual-context-menu button:hover,
+.schedule-visual-context-menu button:focus-visible {
+ outline: none;
+ background: var(--bg-hover);
+}
+
+.schedule-visual-context-menu button.danger {
+ color: var(--error);
+}
+
+#schedule-move-slot-modal .schedule-move-slot-card {
+ width: min(620px, calc(100vw - 2rem));
+ box-sizing: border-box;
+ margin: auto;
+ max-height: none;
+ padding: 1rem;
+ overflow: visible;
+}
+
+.schedule-move-slot-form {
+ display: grid;
+ gap: 0.55rem;
+}
+
+@media (max-width: 980px) {
+ .schedule-conflict-slot-row {
+ grid-template-columns: minmax(180px, 1.2fr) repeat(2, minmax(140px, 1fr));
+ }
+}
+
.subject-import-rows-list {
display: flex;
flex-direction: column;
@@ -544,7 +920,7 @@
.subject-import-row {
display: grid;
- grid-template-columns: 160px minmax(260px, 1fr) auto;
+ grid-template-columns: minmax(120px, 0.7fr) minmax(240px, 1.3fr) auto;
column-gap: 0.65rem;
row-gap: 0.75rem;
align-items: end;
@@ -691,11 +1067,27 @@
min-width: 0;
}
+.subject-import-row .btn {
+ min-height: 42px;
+}
+
.subject-import-row .form-group label {
margin-bottom: 0.45rem;
}
@media (max-width: 900px) {
+ .department-workspace-grid-wide {
+ grid-template-columns: 1fr;
+ }
+
+ .department-subject-import-card,
+ .department-teacher-assign-card,
+ .department-teacher-request-card,
+ .department-teachers-card {
+ grid-column: 1;
+ grid-row: auto;
+ }
+
.academic-year-semesters {
grid-template-columns: 1fr;
}
@@ -705,6 +1097,57 @@
}
}
+@media (max-width: 1280px) and (min-width: 901px) {
+ .department-workspace-grid-wide {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .department-subject-import-card,
+ .department-teachers-card {
+ grid-column: 1 / -1;
+ grid-row: auto;
+ }
+
+ .department-teacher-assign-card,
+ .department-teacher-request-card {
+ grid-column: span 1;
+ }
+}
+
+@media (max-width: 700px) {
+ .department-workspace-filter-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .subject-import-row {
+ grid-template-columns: 1fr;
+ }
+
+ .department-subject-import-card .stack-form > .btn,
+ .department-teacher-assign-card .stack-form > .btn,
+ .department-teacher-request-card .stack-form > .btn,
+ .subject-import-row .btn {
+ width: 100%;
+ }
+
+ #schedule-conflict-modal .schedule-conflict-card {
+ padding: 1rem;
+ }
+
+ .schedule-conflict-rules,
+ .schedule-conflict-slot-row {
+ grid-template-columns: 1fr;
+ }
+
+ .schedule-conflict-actions {
+ flex-direction: column-reverse;
+ }
+
+ .schedule-conflict-actions .btn {
+ width: 100%;
+ }
+}
+
.academic-year-semester-meta {
display: grid;
gap: 0.2rem;
@@ -728,33 +1171,60 @@
.schedule-visual-handle {
position: fixed;
top: 50%;
- right: 0;
+ right: 0 !important;
z-index: 905;
- width: 40px;
- height: 40px;
- border: 1px solid var(--button-secondary-border);
- border-right: 0;
- border-radius: var(--button-radius) 0 0 var(--button-radius);
- background: var(--button-secondary-bg);
- color: var(--button-secondary-text);
- box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
- transform: translateY(-50%);
- transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
+ width: 46px !important;
+ height: 100px !important;
+ border: 1px solid var(--bg-card-border) !important;
+ border-left: 3px solid var(--accent) !important;
+ border-right: 0 !important;
+ border-radius: 8px 0 0 8px !important;
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg-card)) !important;
+ color: var(--text-primary) !important;
+ box-shadow: -4px 4px 16px rgba(0, 0, 0, 0.25) !important;
+ transform: translateY(-50%) !important;
+ transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1),
+ width 0.3s cubic-bezier(0.16, 1, 0.3, 1),
+ background 0.25s ease,
+ color 0.25s ease,
+ box-shadow 0.3s ease,
+ border-color 0.25s ease !important;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 !important;
}
-.schedule-visual-handle:hover,
-.schedule-visual-handle.open {
- border-color: var(--button-secondary-border);
- background: var(--button-secondary-bg-hover);
- color: var(--button-secondary-text);
+.schedule-visual-handle:hover {
+ background: color-mix(in srgb, var(--accent) 18%, var(--bg-card)) !important;
+ color: var(--text-primary) !important;
+ width: 62px !important;
+ box-shadow: -8px 8px 24px rgba(0, 0, 0, 0.35) !important;
+ transform: translateY(-50%) !important;
}
.schedule-visual-handle.open {
- right: min(92vw, 1180px);
+ right: min(92vw, 1180px) !important;
+ border-color: var(--bg-card-border) !important;
+ border-left: 3px solid var(--accent) !important;
+ background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)) !important;
+ color: var(--text-primary) !important;
+}
+
+.schedule-visual-handle.open:hover {
+ transform: translateY(-50%) !important;
+ width: 62px !important;
}
.schedule-visual-handle.open .schedule-visual-handle-icon {
- transform: rotate(180deg);
+ transform: rotate(180deg) !important;
+}
+
+.schedule-visual-handle-icon {
+ width: 38px !important;
+ height: 38px !important;
+ stroke-width: 3px !important;
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1) !important;
}
.schedule-visual-backdrop {
@@ -1090,31 +1560,31 @@
.schedule-visual-edit-rule {
position: absolute;
- top: 0.26rem;
- right: 0.26rem;
+ top: 0.18rem;
+ right: 0.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
- width: 1.1rem;
- height: 1.1rem;
+ width: 1rem;
+ height: 1.25rem;
padding: 0;
- border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
- border-radius: 50%;
- background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
- color: var(--accent-hover);
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text-secondary);
font: inherit;
- font-size: 0.82rem;
+ font-size: 1rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
- transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
+ box-shadow: none;
+ transition: color var(--transition), transform var(--transition);
}
.schedule-visual-edit-rule:hover,
.schedule-visual-edit-rule:focus-visible {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
- color: var(--text-primary);
+ background: transparent;
+ color: var(--accent-hover);
outline: none;
transform: translateY(-1px);
}
@@ -1135,7 +1605,7 @@
}
.schedule-visual-handle.open {
- right: calc(100vw - 42px);
+ right: calc(100vw - 46px) !important;
}
.schedule-visual-header {
@@ -1154,6 +1624,157 @@
}
}
+.academic-calendar-tabs,
+.university-structure-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--bg-card-border);
+}
+
+.academic-calendar-tab,
+.university-structure-tab {
+ min-height: 40px;
+ padding: 0.55rem 0.9rem;
+ border: 1px solid transparent;
+ border-bottom: none;
+ border-radius: var(--radius-sm) var(--radius-sm) 0 0;
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ transition: color var(--transition), background var(--transition), border-color var(--transition);
+}
+
+.academic-calendar-tab:hover,
+.academic-calendar-tab:focus-visible,
+.university-structure-tab:hover,
+.university-structure-tab:focus-visible {
+ color: var(--text-primary);
+ background: var(--bg-hover);
+ outline: none;
+}
+
+.academic-calendar-tab.active,
+.university-structure-tab.active {
+ color: var(--accent-hover);
+ background: var(--bg-card);
+ border-color: var(--bg-card-border);
+}
+
+.academic-calendar-tab-panel,
+.university-structure-tab-panel {
+ display: grid;
+ gap: 1rem;
+}
+
+.academic-calendar-tab-panel[hidden],
+.university-structure-tab-panel[hidden] {
+ display: none;
+}
+
+.calendar-subject-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 0.75rem;
+}
+
+.calendar-subject-semester {
+ min-height: 104px;
+ padding: 0.8rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--bg-input) 84%, var(--bg-card));
+}
+
+.calendar-subject-semester-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.calendar-subject-semester-head strong {
+ color: var(--text-primary);
+ font-size: 0.92rem;
+}
+
+.calendar-subject-semester-head span,
+.calendar-subject-empty {
+ color: var(--text-secondary);
+ font-size: 0.84rem;
+}
+
+.calendar-subject-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.calendar-subject-chip {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ min-height: 30px;
+ padding: 0.25rem 0.28rem 0.25rem 0.55rem;
+ gap: 0.35rem;
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--bg-card-border));
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--accent) 9%, var(--bg-card));
+ color: var(--text-primary);
+ font-size: 0.84rem;
+}
+
+.calendar-subject-chip span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.calendar-subject-remove {
+ width: 22px;
+ height: 22px;
+ flex: 0 0 22px;
+ border: none;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
+ color: var(--danger);
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.calendar-subject-remove:hover,
+.calendar-subject-remove:focus-visible {
+ background: var(--danger);
+ color: white;
+ outline: none;
+}
+
+.assignment-subjects {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 220px;
+}
+
+.assignment-subject-semester {
+ display: grid;
+ gap: 0.15rem;
+}
+
+.assignment-subject-semester strong {
+ color: var(--accent-hover);
+ font-size: 0.78rem;
+}
+
+.assignment-subject-semester span {
+ color: var(--text-primary);
+ font-size: 0.86rem;
+ line-height: 1.35;
+}
+
.calendar-fill-panel {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
@@ -1660,11 +2281,10 @@
color: var(--text-secondary);
}
-@media (max-width: 1180px) {
-
+@media (max-width: 1350px) {
.schedule-view-filter-grid,
.schedule-view-metrics,
- .department-workspace-grid {
+ .department-workspace-grid:not(.department-workspace-grid-wide) {
grid-template-columns: 1fr;
}
@@ -1674,11 +2294,23 @@
.schedule-slot-row,
.schedule-slot-row.has-subgroup {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .schedule-slot-remove {
+ grid-column: 4;
+ justify-self: end;
+ width: auto;
+ }
+}
+
+@media (max-width: 1024px) {
+ .calendar-fill-panel {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
- .calendar-fill-panel {
- grid-template-columns: repeat(2, minmax(180px, 1fr));
+ .calendar-subject-summary {
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.calendar-semesters-grid {
@@ -1698,6 +2330,17 @@
flex-direction: column;
gap: 0.2rem;
}
+
+ .schedule-slot-row,
+ .schedule-slot-row.has-subgroup {
+ grid-template-columns: repeat(2, minmax(150px, 1fr));
+ }
+
+ .schedule-slot-remove {
+ grid-column: 2;
+ justify-self: end;
+ width: auto;
+ }
}
@media (max-width: 640px) {
@@ -1710,6 +2353,12 @@
grid-template-columns: 1fr;
}
+ .schedule-slot-remove {
+ grid-column: 1;
+ justify-self: stretch;
+ width: 100%;
+ }
+
.subject-import-row {
grid-template-columns: 1fr;
}
@@ -1718,6 +2367,17 @@
grid-template-columns: 1fr;
}
+ .academic-calendar-tab,
+ .university-structure-tab {
+ flex: 1 1 100%;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--bg-card-border);
+ }
+
+ .calendar-subject-summary {
+ grid-template-columns: 1fr;
+ }
+
.calendar-day-dialog-grid {
grid-template-columns: 1fr;
}
@@ -2011,6 +2671,86 @@ input[type="number"] {
box-shadow: 0 0 0 3px var(--accent-glow);
}
+.filter-row .custom-multi-select {
+ width: min(240px, 100%);
+ min-width: 220px;
+}
+
+.filter-row .select-box {
+ min-height: 38px;
+ padding: 0.45rem 0.75rem;
+ font-size: 0.85rem;
+}
+
+.filter-row .select-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.subgroup-config-grid {
+ display: grid;
+ grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
+ grid-template-areas:
+ "count capacities"
+ "actions actions";
+ gap: 1rem 0.75rem;
+ align-items: end;
+}
+
+.subgroup-count-field {
+ grid-area: count;
+ min-width: 0;
+ margin-bottom: 0;
+}
+
+.subgroup-capacity-fields {
+ grid-area: capacities;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.subgroup-capacity-fields:empty {
+ display: none;
+}
+
+.subgroup-capacity-fields-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.subgroup-capacity-fields-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.subgroup-capacity-field {
+ min-width: 0;
+ margin-bottom: 0;
+}
+
+.subgroup-actions {
+ grid-area: actions;
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 0;
+}
+
+@media (max-width: 720px) {
+ .subgroup-config-grid {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "count"
+ "capacities"
+ "actions";
+ }
+
+ .subgroup-capacity-fields {
+ grid-template-columns: 1fr;
+ }
+}
+
/* ===== Custom Multi-Select ===== */
.custom-multi-select {
@@ -2136,6 +2876,17 @@ input[type="number"] {
transition: background 0.2s ease;
}
+.form-group .checkbox-group-vertical .checkbox-item,
+.filter-row .checkbox-group-vertical .checkbox-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
.checkbox-item:hover {
background: var(--bg-hover);
}
diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css
index 57bbc88..207a7d1 100755
--- a/frontend/admin/css/layout.css
+++ b/frontend/admin/css/layout.css
@@ -104,6 +104,7 @@
}
.nav-item svg {
+ flex: 0 0 auto;
transition: transform var(--transition);
}
@@ -112,6 +113,38 @@
transform: scale(1.15) rotate(-5deg);
}
+.nav-item span:not(.nav-badge) {
+ min-width: 0;
+}
+
+.nav-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 22px;
+ height: 22px;
+ margin-left: auto;
+ padding: 0 0.45rem;
+ border: 1px solid rgba(248, 113, 113, 0.28);
+ border-radius: 999px;
+ background: rgba(248, 113, 113, 0.16);
+ color: #fecaca;
+ font-size: 0.72rem;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.nav-badge[hidden],
+.topbar-request-badge[hidden] {
+ display: none !important;
+}
+
+[data-theme="light"] .nav-badge {
+ background: #fee2e2;
+ border-color: #fecaca;
+ color: #b91c1c;
+}
+
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
@@ -247,7 +280,7 @@
display: none;
}
- .sidebar.collapsed .nav-item span,
+ .sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span {
position: absolute;
left: calc(100% + 10px);
@@ -271,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
- [data-theme="light"] .sidebar.collapsed .nav-item span,
+ [data-theme="light"] .sidebar.collapsed .nav-item span:not(.nav-badge),
[data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
- .sidebar.collapsed .nav-item:hover span,
+ .sidebar.collapsed .nav-item:hover span:not(.nav-badge),
.sidebar.collapsed .btn-settings:hover span {
opacity: 1;
visibility: visible;
@@ -298,6 +331,16 @@
padding: 0.75rem 0;
overflow: visible;
}
+
+ .sidebar.collapsed .nav-badge {
+ position: absolute;
+ top: 0.35rem;
+ right: 0.35rem;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 0.3rem;
+ font-size: 0.65rem;
+ }
.sidebar.collapsed .btn-settings {
justify-content: center;
@@ -333,9 +376,44 @@
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
+}
+
+.topbar-title-group {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
flex: 1;
}
+.topbar-request-badge {
+ min-height: 30px;
+ padding: 0 0.75rem;
+ border: 1px solid rgba(248, 113, 113, 0.26);
+ border-radius: 999px;
+ background: rgba(248, 113, 113, 0.13);
+ color: #fecaca;
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 800;
+ line-height: 1;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
+}
+
+.topbar-request-badge:hover {
+ transform: translateY(-1px);
+ background: rgba(248, 113, 113, 0.2);
+ border-color: rgba(248, 113, 113, 0.42);
+}
+
+[data-theme="light"] .topbar-request-badge {
+ background: #fee2e2;
+ border-color: #fecaca;
+ color: #b91c1c;
+}
+
.content {
padding: 1.5rem 2rem;
display: flex;
diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css
index 2ba19c1..aee706c 100644
--- a/frontend/admin/css/modals.css
+++ b/frontend/admin/css/modals.css
@@ -73,3 +73,82 @@
.modal-close:hover {
color: var(--error);
}
+
+#modal-edit-group .edit-group-modal {
+ width: min(980px, calc(100vw - 2rem));
+ max-height: none;
+ overflow: visible;
+}
+
+#modal-edit-group #edit-group-form {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+#modal-edit-group .edit-group-form-row {
+ display: grid;
+ gap: 1rem;
+ align-items: end;
+}
+
+#modal-edit-group .edit-group-form-row-main {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+#modal-edit-group .edit-group-form-row-details {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+}
+
+#modal-edit-group .form-group {
+ min-width: 0;
+}
+
+#modal-edit-group .edit-group-submit {
+ display: flex;
+ align-items: flex-end;
+}
+
+#modal-edit-group .edit-group-submit .btn {
+ width: 100%;
+}
+
+@media (max-width: 980px) {
+ #modal-edit-group .edit-group-form-row-main,
+ #modal-edit-group .edit-group-form-row-details {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 560px) {
+ #modal-edit-group .edit-group-form-row-main,
+ #modal-edit-group .edit-group-form-row-details {
+ grid-template-columns: 1fr;
+ }
+}
+
+#schedule-conflict-modal {
+ padding: 0.35rem;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+#schedule-conflict-modal .modal-content {
+ width: min(1760px, calc(100vw - 0.7rem));
+ max-width: calc(100vw - 0.7rem);
+ max-height: none;
+ box-sizing: border-box;
+ margin: auto;
+ padding: 1rem;
+ overflow: visible;
+}
+
+#schedule-move-slot-modal .modal-content {
+ width: min(620px, calc(100vw - 2rem));
+ max-width: calc(100vw - 2rem);
+ max-height: none;
+ box-sizing: border-box;
+ margin: auto;
+ padding: 1rem;
+ overflow: visible;
+}
diff --git a/frontend/admin/index.html b/frontend/admin/index.html
index 6327494..32d95df 100755
--- a/frontend/admin/index.html
+++ b/frontend/admin/index.html
@@ -66,6 +66,17 @@
Дашборд
+
+
+
+ Заявки
+ 0
+
- Загрузка...
+
+
Загрузка...
+
+
diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js
index 0b679db..ff34b4c 100755
--- a/frontend/admin/js/api.js
+++ b/frontend/admin/js/api.js
@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
}
if (!response.ok) {
- throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
+ const error = new Error(data?.message || `Ошибка HTTP: ${response.status}`);
+ error.status = response.status;
+ error.data = data;
+ throw error;
}
return data;
@@ -109,15 +112,23 @@ function invalidateCache(url) {
'/api/specialties',
'/api/education-forms',
'/api/users',
+ '/api/teacher-requests',
+ '/api/department/teacher-requests',
+ '/api/department/teachers',
'/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);
- }
+ const affectedPrefixes = new Set(cacheKeysToClear.filter(prefix => url.includes(prefix)));
+ if (url.includes('/api/teacher-requests')) {
+ affectedPrefixes.add('/api/users');
+ }
+ if (url.includes('/api/department/teachers')) {
+ affectedPrefixes.add('/api/users/teachers');
+ }
+ for (const prefix of affectedPrefixes) {
+ for (const cacheKey of cache.keys()) {
+ if (cacheKey.includes(prefix)) {
+ cache.delete(cacheKey);
}
}
}
diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js
index 4fda5d3..bff3cf5 100755
--- a/frontend/admin/js/main.js
+++ b/frontend/admin/js/main.js
@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
-import { isAuthenticatedAsAny, logout } from './api.js';
+import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -38,6 +38,14 @@ const ROUTES = {
await module.initUsers();
}
},
+ 'teacher-requests': {
+ title: 'Заявки',
+ file: '/admin/views/teacher-requests.html',
+ init: async () => {
+ const module = await import('./views/teacher-requests.js');
+ await module.initTeacherRequests();
+ }
+ },
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
@@ -115,7 +123,7 @@ const ROUTES = {
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'dashboard',
- tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
+ tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
},
EDUCATION_OFFICE: {
defaultTab: 'dashboard',
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
+const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
+const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
+refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/';
});
+teacherRequestsTitleBadge?.addEventListener('click', () => {
+ switchTab('teacher-requests');
+});
+
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
@@ -271,6 +286,42 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
+async function refreshTeacherRequestBadge(countOverride = null) {
+ if (currentRole !== 'ADMIN') return;
+ try {
+ const count = countOverride === null
+ ? (await api.get('/api/teacher-requests?status=PENDING') || []).length
+ : countOverride;
+ updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
+ updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
+ } catch (error) {
+ if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
+ if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
+ console.warn('Не удалось загрузить счётчик заявок:', error.message);
+ }
+}
+
+function updateTeacherRequestBadge(element, count, label) {
+ if (!element) return;
+ const normalizedCount = Number(count) || 0;
+ element.textContent = label;
+ element.hidden = normalizedCount <= 0;
+ element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
+ element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
+}
+
+function requestCountLabel(count) {
+ const mod10 = count % 10;
+ const mod100 = count % 100;
+ if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
+ return `${count} заявок`;
+}
+
+document.addEventListener('teacherRequestsChanged', (event) => {
+ refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
+});
+
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js
index 9c8eede..2116d6c 100644
--- a/frontend/admin/js/views/academic-calendar.js
+++ b/frontend/admin/js/views/academic-calendar.js
@@ -70,6 +70,16 @@ export async function initAcademicCalendar() {
const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear');
+ const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]'));
+ const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]'));
+ const subjectCalendarSelect = document.getElementById('calendar-subject-calendar');
+ const subjectSemesterSelect = document.getElementById('calendar-subject-semester');
+ const subjectSelect = document.getElementById('calendar-subject-id');
+ const subjectAddButton = document.getElementById('calendar-subject-add');
+ const subjectSaveButton = document.getElementById('calendar-subject-save');
+ const subjectRefreshButton = document.getElementById('calendar-subject-refresh');
+ const subjectSummary = document.getElementById('calendar-subject-summary');
+ const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = [];
let semesters = [];
@@ -78,6 +88,8 @@ export async function initAcademicCalendar() {
let studyForms = [];
let activityTypes = [];
let calendars = [];
+ let subjects = [];
+ let calendarSubjectRows = [];
let timeSlotScopes = [];
let timeSlotDateAssignments = [];
let activeCalendarDay = null;
@@ -95,6 +107,7 @@ export async function initAcademicCalendar() {
await Promise.all([
loadBaseLists(),
loadYears(),
+ loadSubjects(),
loadStudyForms(),
loadActivityTypes(),
loadTimeSlotScopes(),
@@ -106,6 +119,9 @@ export async function initAcademicCalendar() {
}
function bindEvents() {
+ calendarTabButtons.forEach(button => {
+ button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
+ });
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
@@ -115,6 +131,11 @@ export async function initAcademicCalendar() {
editorLoadButton?.addEventListener('click', loadCalendarGrid);
editorSaveButton?.addEventListener('click', saveCalendarGrid);
fillApplyButton?.addEventListener('click', applyCalendarFill);
+ subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects);
+ subjectAddButton?.addEventListener('click', addCalendarSubject);
+ subjectSaveButton?.addEventListener('click', saveCalendarSubjects);
+ subjectRefreshButton?.addEventListener('click', loadCalendarSubjects);
+ subjectSummary?.addEventListener('click', handleSubjectSummaryClick);
calendarGrid?.addEventListener('change', (event) => {
if (event.target.matches('.calendar-day-activity')) {
@@ -361,6 +382,11 @@ export async function initAcademicCalendar() {
populateStudyFormSelect();
}
+ async function loadSubjects() {
+ subjects = await api.get('/api/subjects');
+ populateSubjectSelect();
+ }
+
async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects();
@@ -380,6 +406,7 @@ export async function initAcademicCalendar() {
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
+ populateSubjectCalendarSelect();
} catch (error) {
calendarsTbody.innerHTML = `| Ошибка загрузки: ${escapeHtml(error.message)} |
`;
throw error;
@@ -402,6 +429,7 @@ export async function initAcademicCalendar() {
+
|
@@ -440,9 +468,11 @@ export async function initAcademicCalendar() {
async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid');
+ const subjectsButton = event.target.closest('.btn-open-calendar-subjects');
const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) {
+ activateCalendarTab('manage');
const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
@@ -460,13 +490,23 @@ export async function initAcademicCalendar() {
}
if (gridButton) {
+ activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id;
- syncSelects(editorCalendarSelect);
+ refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
+ if (subjectsButton) {
+ activateCalendarTab('subjects');
+ subjectCalendarSelect.value = subjectsButton.dataset.id;
+ refreshSelectVisuals(subjectCalendarSelect);
+ await loadCalendarSubjects();
+ subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ return;
+ }
+
if (deleteButton) {
if (!confirm('Удалить календарный график?')) return;
try {
@@ -489,6 +529,149 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert');
}
+ function activateCalendarTab(tabName) {
+ const target = tabName || 'manage';
+ calendarTabButtons.forEach(button => {
+ const active = button.dataset.calendarTab === target;
+ button.classList.toggle('active', active);
+ button.setAttribute('aria-selected', active ? 'true' : 'false');
+ });
+ calendarTabPanels.forEach(panel => {
+ const active = panel.dataset.calendarTabPanel === target;
+ panel.classList.toggle('active', active);
+ panel.hidden = !active;
+ });
+ }
+
+ async function loadCalendarSubjects() {
+ hideAlert('calendar-subject-alert');
+ const calendar = selectedSubjectCalendar();
+ populateSubjectSemesterSelect();
+ calendarSubjectRows = [];
+ if (!calendar) {
+ renderCalendarSubjects();
+ return;
+ }
+ subjectSummary.innerHTML = 'Загрузка...
';
+ try {
+ const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
+ calendarSubjectRows = sortCalendarSubjectRows(rows || []);
+ renderCalendarSubjects();
+ } catch (error) {
+ subjectSummary.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}
`;
+ showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
+ }
+ }
+
+ function addCalendarSubject() {
+ hideAlert('calendar-subject-alert');
+ const calendar = selectedSubjectCalendar();
+ const semesterNumber = Number(subjectSemesterSelect.value);
+ const subjectId = Number(subjectSelect.value);
+ if (!calendar || !semesterNumber || !subjectId) {
+ showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error');
+ return;
+ }
+ if (calendarSubjectRows.some(row =>
+ Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId
+ )) {
+ showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error');
+ return;
+ }
+ const subject = subjects.find(item => Number(item.id) === subjectId);
+ if (!subject) {
+ showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error');
+ return;
+ }
+ calendarSubjectRows = sortCalendarSubjectRows([
+ ...calendarSubjectRows,
+ {
+ id: null,
+ calendarId: calendar.id,
+ semesterNumber,
+ subjectId,
+ subjectName: subject.name,
+ subjectCode: subject.code,
+ departmentId: subject.departmentId
+ }
+ ]);
+ renderCalendarSubjects();
+ showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
+ }
+
+ async function saveCalendarSubjects() {
+ hideAlert('calendar-subject-alert');
+ const calendar = selectedSubjectCalendar();
+ if (!calendar) {
+ showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
+ return;
+ }
+ const payload = calendarSubjectRows.map(row => ({
+ semesterNumber: Number(row.semesterNumber),
+ subjectId: Number(row.subjectId)
+ }));
+ try {
+ const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
+ calendarSubjectRows = sortCalendarSubjectRows(saved || []);
+ renderCalendarSubjects();
+ showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
+ } catch (error) {
+ showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
+ }
+ }
+
+ function handleSubjectSummaryClick(event) {
+ const removeButton = event.target.closest('.calendar-subject-remove');
+ if (!removeButton) return;
+ const semesterNumber = Number(removeButton.dataset.semester);
+ const subjectId = Number(removeButton.dataset.subject);
+ calendarSubjectRows = calendarSubjectRows.filter(row =>
+ Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
+ );
+ renderCalendarSubjects();
+ showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
+ }
+
+ function renderCalendarSubjects() {
+ const calendar = selectedSubjectCalendar();
+ if (!calendar) {
+ subjectCount.textContent = '';
+ subjectSummary.innerHTML = 'Выберите календарный график
';
+ return;
+ }
+
+ const semesterCount = calendarSemesterCount(calendar);
+ subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`;
+ subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
+ const semesterNumber = index + 1;
+ const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber);
+ return `
+
+
+ ${escapeHtml(semesterOptionLabel(semesterNumber))}
+ ${semesterRows.length} дисциплин
+
+ ${semesterRows.length
+ ? `${semesterRows.map(renderSubjectChip).join('')}
`
+ : 'Нет дисциплин
'}
+
+ `;
+ }).join('');
+ }
+
+ function renderSubjectChip(row) {
+ return `
+
+ ${escapeHtml(subjectRowLabel(row))}
+
+
+ `;
+ }
+
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
@@ -568,8 +751,8 @@ export async function initAcademicCalendar() {
selectedCalendarDayKey = null;
selectedCalendarDays = [];
const grouped = groupBy(rows, row => row.courseNumber);
- calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => `
-
+ calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
+