Compare commits
6 Commits
bbfee068da
...
dynamic_sc
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fde2566d | |||
|
|
f3a9905423 | ||
|
|
7926038bdc | ||
|
|
e2a3553a09 | ||
| e989391c8d | |||
| 5b6ffa5c08 |
288
analysis_results.md
Normal file
288
analysis_results.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# Анализ структуры вкладок и предложения по UX/UI оптимизации системы Magistr
|
||||||
|
|
||||||
|
В данном документе представлен детальный анализ текущей структуры интерфейса административной панели (SPA) и настроек системы Magistr. Выявлены пересекающиеся области функционала, сформулированы предложения по их объединению, переносу в настройки или удалению, а также предложены новые функциональные модули и технические решения для оптимизации UX/UI и производительности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Анализ текущей структуры и доступности вкладок
|
||||||
|
|
||||||
|
На данный момент в основном SPA-интерфейсе администратора (`/admin/`) зарегистрировано **13 вкладок**, а в дополнительном SPA настроек (`/admin/settings/`) — **2 вкладки**.
|
||||||
|
|
||||||
|
В зависимости от роли пользователя, сайдбар фильтруется следующим образом:
|
||||||
|
|
||||||
|
| Вкладка (ID) | Описание | Доступность по ролям |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `users` | CRUD пользователей и архивирование | `ADMIN` |
|
||||||
|
| `groups` | CRUD групп, подгрупп и назначений календарей | `ADMIN` |
|
||||||
|
| `edu-forms` | Справочник форм обучения | `ADMIN` |
|
||||||
|
| `profiles` | Управление профилями обучения специальностей | `ADMIN` |
|
||||||
|
| `equipments` | Каталог оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||||
|
| `classrooms` | Список аудиторий с привязкой оборудования | `ADMIN`, `EDUCATION_OFFICE` |
|
||||||
|
| `subjects` | CRUD всех дисциплин и привязка преподавателей | `ADMIN` |
|
||||||
|
| `department-workspace` | Рабочая область кафедры (дисциплины, импорт, нагрузка) | `ADMIN`, `DEPARTMENT` |
|
||||||
|
| `schedule-view` | Просмотр расписаний (read-only) | `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER` |
|
||||||
|
| `schedule` | Конструктор правил динамического расписания | `ADMIN`, `EDUCATION_OFFICE` |
|
||||||
|
| `academic-calendar` | Календарные графики и Excel-редактор сетки | `ADMIN`, `EDUCATION_OFFICE` |
|
||||||
|
| `auditorium-workload` | Матрицы и таблицы загруженности | `ADMIN`, `EDUCATION_OFFICE` |
|
||||||
|
| `database` | Управление тенантами (БД клиентов) | `ADMIN` |
|
||||||
|
| **Settings** (SPA) | Общие настройки, Управление временными слотами | `ADMIN`, `EDUCATION_OFFICE` (ссылка в подвале) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Пересечения функционала и проблемные зоны
|
||||||
|
|
||||||
|
### 2.1. Избыточное дробление академической структуры
|
||||||
|
Для описания структуры обучения сейчас используются четыре отдельные вкладки:
|
||||||
|
1. `departments-data` (Создание кафедры/специальности)
|
||||||
|
2. `profiles` (Профили обучения специальностей)
|
||||||
|
3. `edu-forms` (Формы обучения)
|
||||||
|
4. `groups` (В блоке создания группы выбираются форма, кафедра, специальность и профиль)
|
||||||
|
|
||||||
|
**В чём проблема:**
|
||||||
|
* **Разрыв контекста:** Чтобы завести новую специальность с профилями, администратору сначала нужно пойти во вкладку `departments-data`, создать специальность, затем перейти во вкладку `profiles`, выбрать в селекте эту специальность и добавить профили.
|
||||||
|
* **Перегрузка меню:** 13 вкладок в сайдбаре для роли `ADMIN` — это слишком много. Пользователю трудно ориентироваться визуально.
|
||||||
|
|
||||||
|
### 2.2. Дублирование и раздвоение управления дисциплинами
|
||||||
|
Управление дисциплинами разнесено между вкладками `subjects` (для администратора) и `department-workspace` (для кафедры).
|
||||||
|
|
||||||
|
**В чём проблема:**
|
||||||
|
* Администратор во вкладке `subjects` видит плоскую таблицу всех дисциплин всех кафедр, но не имеет удобных инструментов фильтрации по кафедре.
|
||||||
|
* Форма создания новой дисциплины во вкладке `subjects` требует ввода **числового ID кафедры вручную** (поле `new-subject-department` типа `number`). Это критическая UX-ошибка: администратор не должен помнить числовые ID БД наизусть.
|
||||||
|
* Привязка преподавателей к дисциплинам (`teacher_subjects`) находится на вкладке `subjects`. При этом сама нагрузка преподавателей и список преподавателей кафедры отображаются на вкладке `department-workspace`. Логичнее было бы видеть, какие дисциплины ведёт преподаватель, непосредственно при работе с его нагрузкой или в едином справочнике преподавателей/кафедры.
|
||||||
|
|
||||||
|
### 2.3. Низкая частота использования справочника оборудования (`equipments`)
|
||||||
|
Оборудование существует только как подчиненная сущность для аудиторий (чтобы указать, что в аудитории 101 есть 20 ПК и 1 проектор).
|
||||||
|
|
||||||
|
**В чём проблема:**
|
||||||
|
* Выделение целой вкладки под простой список («Проектор», «ПК») не оправдано. Список оборудования меняется раз в несколько лет.
|
||||||
|
* При этом привязка оборудования к аудиториям происходит на вкладке `classrooms`.
|
||||||
|
|
||||||
|
### 2.4. Перегруженность вкладки групп (`groups`)
|
||||||
|
На вкладке групп на одном экране выводятся сразу три тяжелые формы и три таблицы:
|
||||||
|
1. Создание/редактирование групп + таблица групп.
|
||||||
|
2. Создание подгрупп для лабораторных + таблица подгрупп.
|
||||||
|
3. Назначение календарного графика группе + таблица назначений.
|
||||||
|
|
||||||
|
**В чём проблема:**
|
||||||
|
* Экран перегружен элементами управления.
|
||||||
|
* Для работы с подгруппами или назначениями графиков конкретной группы пользователю приходится выбирать эту группу из длинного выпадающего списка в отдельной форме, вместо того чтобы выполнять действия в контексте строки этой группы в таблице.
|
||||||
|
|
||||||
|
### 2.5. Нахождение системной вкладки «База данных» в общем меню расписания
|
||||||
|
Вкладка `database` отвечает за управление тенантами (добавление новых вузов-клиентов, создание для них БД и запуск Flyway миграций).
|
||||||
|
|
||||||
|
**В чём проблема:**
|
||||||
|
* Это технический функционал уровня Super-Admin / DevOps. Он не имеет отношения к ежедневному планированию расписания. Нахождение его в одном ряду с расписанием и группами нарушает логику интерфейса.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Рекомендации по оптимизации структуры (Что убрать, объединить и перенести)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Было (13 вкладок в меню ADMIN)
|
||||||
|
direction TB
|
||||||
|
T1[database]
|
||||||
|
T2[edu-forms]
|
||||||
|
T3[departments-data]
|
||||||
|
T4[profiles]
|
||||||
|
T5[equipments]
|
||||||
|
T6[classrooms]
|
||||||
|
T7[groups]
|
||||||
|
T8[users]
|
||||||
|
T9[subjects]
|
||||||
|
T10[department-workspace]
|
||||||
|
T11[schedule]
|
||||||
|
T12[schedule-view]
|
||||||
|
T13[academic-calendar]
|
||||||
|
T14[auditorium-workload]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Стало (Сгруппированное меню + настройки)
|
||||||
|
direction TB
|
||||||
|
subgraph Основное меню SPA (Группы вкладок)
|
||||||
|
direction TB
|
||||||
|
DASH[1. Дашборд / Главная]
|
||||||
|
|
||||||
|
subgraph Модуль: Расписание
|
||||||
|
SCH_ED[2. Конструктор расписания]
|
||||||
|
SCH_VI[3. Просмотр расписаний]
|
||||||
|
CAL[4. Календарный график]
|
||||||
|
WORK[5. Загруженность]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Модуль: Структура
|
||||||
|
UNIV[6. Структура вуза<br/>Кафедры, Специальности, Профили]
|
||||||
|
GRP[7. Группы и подгруппы]
|
||||||
|
SUBJ[8. Дисциплины]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Модуль: Ресурсы
|
||||||
|
CLASS[9. Аудиторный фонд<br/>Аудитории + Оборудование]
|
||||||
|
end
|
||||||
|
|
||||||
|
USR[10. Пользователи]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SPA Настройки (Настройки системы)
|
||||||
|
direction TB
|
||||||
|
SET_GEN[Общие настройки]
|
||||||
|
SET_TIME[Временные слоты сетка]
|
||||||
|
SET_FORM[Формы обучения из edu-forms]
|
||||||
|
SET_DB[Базы данных / Тенанты из database]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
T1 -->|Перенести| SET_DB
|
||||||
|
T2 -->|Перенести| SET_FORM
|
||||||
|
T3 -->|Объединить| UNIV
|
||||||
|
T4 -->|Объединить| UNIV
|
||||||
|
T5 -->|Объединить| CLASS
|
||||||
|
T6 -->|Объединить| CLASS
|
||||||
|
T7 -->|Оптимизировать| GRP
|
||||||
|
T8 -->|Оставить| USR
|
||||||
|
T9 -->|Оптимизировать| SUBJ
|
||||||
|
T10 -->|Сохранить для роли Кафедра| SUBJ
|
||||||
|
T11 -->|Оставить| SCH_ED
|
||||||
|
T12 -->|Оставить| SCH_VI
|
||||||
|
T13 -->|Оставить| CAL
|
||||||
|
T14 -->|Оставить| WORK
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1. Перенос в настройки (`/admin/settings/`)
|
||||||
|
1. **База данных (`database`)**: Перенести в SPA настроек в раздел «Управление тенантами». Доступ должен быть только у роли `ADMIN`.
|
||||||
|
2. **Формы обучения (`edu-forms`)**: Перенести в настройки. Данный справочник заполняется один раз и не требует оперативного доступа из главного меню.
|
||||||
|
|
||||||
|
### 3.2. Объединение вкладок
|
||||||
|
1. **Объединить Кафедры, Специальности и Профили** в одну вкладку **«Структура вуза»** (`university-structure`):
|
||||||
|
* Интерфейс делится на две вкладки внутри страницы: **«Кафедры»** и **«Специальности»**.
|
||||||
|
* Управление профилями должно происходить прямо внутри списка специальностей. Например, в строке специальности в таблице добавляется кнопка «Профили», открывающая список профилей этой специальности в раскрывающейся строке (accordion) или модальном окне.
|
||||||
|
2. **Объединить Аудитории (`classrooms`) и Оборудование (`equipments`)** в одну вкладку **«Аудиторный фонд»**:
|
||||||
|
* Основной экран показывает список аудиторий.
|
||||||
|
* Рядом располагается кнопка «Управление каталогом оборудования», которая открывает модальное окно с простым списком (добавить/удалить тип оборудования). Это избавит от необходимости держать под это отдельную вкладку в сайдбаре.
|
||||||
|
|
||||||
|
### 3.3. Реорганизация вкладки групп (`groups`)
|
||||||
|
* **Оставить на вкладке только список групп** с возможностью фильтрации по курсу, кафедре и форме обучения.
|
||||||
|
* **Вынести подгруппы и назначения календарей из глобальных форм:**
|
||||||
|
* В строке таблицы для каждой группы добавить действия:
|
||||||
|
* `[Подгруппы]` — открывает компактное модальное окно для быстрого создания/редактирования подгрупп (лабораторных) именно для этой группы.
|
||||||
|
* `[Календарь]` — открывает модальное окно назначения календарного учебного графика на учебные годы для этой группы.
|
||||||
|
* Это уберет лишние тяжелые формы с главной страницы и исключит необходимость выбора группы из селектов вручную.
|
||||||
|
|
||||||
|
### 3.4. Оптимизация Дисциплин (`subjects`) и Кабинета кафедры
|
||||||
|
* **Исправить форму добавления дисциплины:** Заменить ручной числовой ввод ID кафедры (`new-subject-department`) на стандартный выпадающий список (выбор из загруженных кафедр).
|
||||||
|
* **Связать привязку преподавателей с дисциплинами:** Вместо двух разрозненных таблиц на вкладке `subjects` («Все дисциплины» и «Привязки преподавателей»), объединить их:
|
||||||
|
* В строке дисциплины выводить список привязанных преподавателей (в виде чипов/badge).
|
||||||
|
* По клику на кнопку «Преподаватели» в строке дисциплины открывать модальное окно привязки/отвязки преподавателей для этой конкретной дисциплины.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Что необходимо добавить (Новый функционал)
|
||||||
|
|
||||||
|
### 4.1. Главная страница / Дашборд (`dashboard`)
|
||||||
|
При входе в систему администратор и сотрудники учебного отдела должны видеть не сырые таблицы пользователей или расписания, а высокоуровневую аналитическую панель:
|
||||||
|
* **Информационные карточки (Метрики):**
|
||||||
|
* Всего активных групп / студентов.
|
||||||
|
* Количество задействованных преподавателей.
|
||||||
|
* Процент занятости аудиторного фонда на текущий день.
|
||||||
|
* Количество запланированных пар на сегодня.
|
||||||
|
* **Мониторинг расписания в реальном времени:**
|
||||||
|
* Список идущих прямо сейчас пар (текущий временной слот).
|
||||||
|
* Ближайшие свободные аудитории.
|
||||||
|
* **Быстрый переход:** Кнопки быстрого создания правила расписания, добавления переноса/замены.
|
||||||
|
|
||||||
|
### 4.2. Центр предупреждений и конфликтов (Red Zone)
|
||||||
|
В соответствии с планируемой бизнес-логикой (раздел 5 `docs/BUSINESS_LOGIC.md`), система должна автоматически валидировать расписание и предупреждать о коллизиях.
|
||||||
|
Необходима отдельная вкладка или виджет на дашборде **«Конфликты расписания»**:
|
||||||
|
* **Критические ошибки (Блокирующие генерацию):**
|
||||||
|
* *Накладки преподавателей:* Преподаватель назначен в один и тот же временной слот в разные аудитории / к разным группам (кроме потоковых лекций).
|
||||||
|
* *Накладки аудиторий:* Две разные пары стоят в одной аудитории в один слот.
|
||||||
|
* **Предупреждения (Мягкие проверки):**
|
||||||
|
* *Превышение вместимости:* Численность группы (или сумма подгрупп при совмещенных занятиях) превышает физическую вместимость аудитории.
|
||||||
|
* *Недостаток оборудования:* Дисциплина (например, лабораторная по химии) требует определенного оборудования, которого нет в назначенной аудитории.
|
||||||
|
* *Превышение нагрузки:* Преподаватель превысил недельный лимит часов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Концепция новой структуры меню (Сайдбара)
|
||||||
|
|
||||||
|
Для уменьшения визуального шума сайдбар предлагается сделать **двухуровневым** (сворачиваемые группы) или четко разделить визуальными разделителями:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Логотип Magistr ]
|
||||||
|
---------------------------
|
||||||
|
👤 Кабинет кафедры <-- (Показывается только для роли DEPARTMENT)
|
||||||
|
📈 Дашборд <-- (Новый главный экран для ADMIN / EDU_OFFICE)
|
||||||
|
|
||||||
|
📁 РАСПИСАНИЕ
|
||||||
|
🗓 Конструктор правил
|
||||||
|
🗓 Точечные изменения <-- (Выделенный функционал оверрайдов/переносов)
|
||||||
|
📅 Календарный график
|
||||||
|
📊 Загруженность
|
||||||
|
🔍 Просмотр расписаний
|
||||||
|
|
||||||
|
🗂 СУБЪЕКТЫ И РЕСУРСЫ
|
||||||
|
🏫 Аудиторный фонд <-- (Объединенные Аудитории + Оборудование)
|
||||||
|
🎓 Группы обучения <-- (Группы + модалки подгрупп/календарей)
|
||||||
|
🏛 Структура вуза <-- (Кафедры + Специальности + Профили)
|
||||||
|
📚 Дисциплины <-- (Дисциплины + модалки преподавателей)
|
||||||
|
|
||||||
|
⚙️ СИСТЕМА
|
||||||
|
👥 Пользователи
|
||||||
|
⚙️ Настройки <-- (Переход в настройки SPA: слоты, БД, формы обучения)
|
||||||
|
---------------------------
|
||||||
|
[ Профиль / Выход ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Идеи по оптимизации фронтенда и взаимодействия с API
|
||||||
|
|
||||||
|
### 6.1. Кэширование справочников на клиенте
|
||||||
|
На данный момент при переключении почти каждой вкладки заново запрашиваются списки кафедр, специальностей, учебных годов и т.д.
|
||||||
|
* **Решение:** Реализовать простой кэш-менеджер во фронтенд-клиенте (`api.js` или отдельный `cache.js`):
|
||||||
|
```javascript
|
||||||
|
const cache = new Map();
|
||||||
|
export async function getCachedData(key, fetchPromise) {
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
cache.set(key, await fetchPromise);
|
||||||
|
}
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
export function clearCache(key) {
|
||||||
|
if (key) cache.delete(key);
|
||||||
|
else cache.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Это позволит избежать десятков дублирующих запросов при переключении между вкладками `groups`, `subjects`, `schedule`.
|
||||||
|
|
||||||
|
### 6.2. Ленивая инициализация вкладок (Lazy Loading)
|
||||||
|
Сейчас в `main.js` импортируются все JS-модули вкладок одновременно при загрузке страницы:
|
||||||
|
```javascript
|
||||||
|
import { initUsers } from './views/users.js';
|
||||||
|
import { initGroups } from './views/groups.js';
|
||||||
|
// ...еще 10 импортов
|
||||||
|
```
|
||||||
|
* **Решение:** Перейти на динамический импорт модулей инициализации при переходе на конкретную вкладку:
|
||||||
|
```javascript
|
||||||
|
async function switchTab(tab) {
|
||||||
|
// ...
|
||||||
|
const viewModule = await import(`./views/${tab}.js`);
|
||||||
|
viewModule.init();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Это уменьшит размер начального JS-бандла и ускорит первую загрузку страницы.
|
||||||
|
|
||||||
|
### 6.3. Единый реестр кастомных выпадающих списков
|
||||||
|
В проекте реализованы кастомные селекты (выпадающие списки с поддержкой поиска и чекбоксов). При динамической подгрузке HTML-шаблонов часто требуется их повторная инициализация.
|
||||||
|
* **Решение:** Создать глобальный шину событий (Event Bus) или MutationObserver, который автоматически подхватывает элементы с классами `.custom-select` или атрибутами `data-select` и инициализирует их без вызова ручного кода `initAllCustomDropdowns()` в каждом отдельном скрипте представления.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резюме
|
||||||
|
|
||||||
|
Предложенные изменения позволят:
|
||||||
|
1. Сократить количество пунктов основного меню с **13** до **8-9** логически сгруппированных разделов.
|
||||||
|
2. Исключить ручной ввод ID (заменив на селекты) и разгрузить перегруженные интерфейсы (выносом подгрупп и назначений в контекстные модальные окна групп).
|
||||||
|
3. Повысить отзывчивость интерфейса за счет клиентского кэширования справочников и ленивой загрузки JS-модулей.
|
||||||
|
4. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
|
||||||
@@ -4,9 +4,11 @@ import com.magistr.app.config.auth.AuthContext;
|
|||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.CreateSubjectRequest;
|
import com.magistr.app.dto.CreateSubjectRequest;
|
||||||
import com.magistr.app.dto.SubjectCommentDto;
|
import com.magistr.app.dto.SubjectCommentDto;
|
||||||
|
import com.magistr.app.dto.UserResponse;
|
||||||
import com.magistr.app.model.*;
|
import com.magistr.app.model.*;
|
||||||
import com.magistr.app.repository.SubjectCommentRepository;
|
import com.magistr.app.repository.SubjectCommentRepository;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
@@ -14,6 +16,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -26,15 +29,18 @@ public class DepartmentWorkspaceController {
|
|||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
private final SubjectCommentRepository subjectCommentRepository;
|
private final SubjectCommentRepository subjectCommentRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||||
private final ScheduleQueryService scheduleQueryService;
|
private final ScheduleQueryService scheduleQueryService;
|
||||||
|
|
||||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||||
SubjectCommentRepository subjectCommentRepository,
|
SubjectCommentRepository subjectCommentRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
|
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||||
ScheduleQueryService scheduleQueryService) {
|
ScheduleQueryService scheduleQueryService) {
|
||||||
this.subjectRepository = subjectRepository;
|
this.subjectRepository = subjectRepository;
|
||||||
this.subjectCommentRepository = subjectCommentRepository;
|
this.subjectCommentRepository = subjectCommentRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||||
this.scheduleQueryService = scheduleQueryService;
|
this.scheduleQueryService = scheduleQueryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +115,25 @@ public class DepartmentWorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
public List<?> getTeachers(@RequestParam(required = false) Long departmentId) {
|
public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
|
||||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||||
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED);
|
if (effectiveDepartmentId == null) {
|
||||||
|
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||||
|
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||||
|
.stream()
|
||||||
|
.map(TeacherDepartmentAssignment::getTeacher)
|
||||||
|
.filter(User::isActiveRecord)
|
||||||
|
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||||
|
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||||
|
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||||
|
return teachersById.values().stream()
|
||||||
|
.map(this::toUserResponse)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/schedule")
|
@GetMapping("/schedule")
|
||||||
@@ -137,6 +159,19 @@ public class DepartmentWorkspaceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UserResponse toUserResponse(User user) {
|
||||||
|
UserResponse response = new UserResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getRole().name(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getJobTitle(),
|
||||||
|
user.getDepartmentId()
|
||||||
|
);
|
||||||
|
response.setStatus(user.getStatus());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||||
var user = AuthContext.getCurrentUser();
|
var user = AuthContext.getCurrentUser();
|
||||||
if (user != null && user.role() == Role.DEPARTMENT) {
|
if (user != null && user.role() == Role.DEPARTMENT) {
|
||||||
|
|||||||
@@ -193,6 +193,16 @@ public class SpecialityController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/profiles")
|
||||||
|
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||||
|
logger.info("Получен запрос на получение всех профилей обучения");
|
||||||
|
return specialtyProfileRepository.findAll().stream()
|
||||||
|
.filter(profile -> profile.getSpeciality().isActiveRecord())
|
||||||
|
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
|
||||||
|
.map(this::toProfileDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{specialtyId}/profiles")
|
@GetMapping("/{specialtyId}/profiles")
|
||||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public record RenderedLessonDto(
|
|||||||
String activityType,
|
String activityType,
|
||||||
Integer lessonTypeAcademicHours,
|
Integer lessonTypeAcademicHours,
|
||||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||||
Integer remainingLessonTypeAcademicHoursAfterLesson
|
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||||
|
com.magistr.app.model.ScheduleParity ruleParity
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,7 +406,8 @@ public class ScheduleGeneratorService {
|
|||||||
"Т",
|
"Т",
|
||||||
lessonTypeAcademicHours,
|
lessonTypeAcademicHours,
|
||||||
consumedBeforeLesson,
|
consumedBeforeLesson,
|
||||||
remainingAfterLesson
|
remainingAfterLesson,
|
||||||
|
slot.getParity()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ public class ScheduleQueryService {
|
|||||||
lesson.activityType(),
|
lesson.activityType(),
|
||||||
lesson.lessonTypeAcademicHours(),
|
lesson.lessonTypeAcademicHours(),
|
||||||
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
|
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
|
||||||
lesson.remainingLessonTypeAcademicHoursAfterLesson()
|
lesson.remainingLessonTypeAcademicHoursAfterLesson(),
|
||||||
|
lesson.ruleParity()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,8 @@ public class ScheduleQueryService {
|
|||||||
source.activityType(),
|
source.activityType(),
|
||||||
source.lessonTypeAcademicHours(),
|
source.lessonTypeAcademicHours(),
|
||||||
source.consumedLessonTypeAcademicHoursBeforeLesson(),
|
source.consumedLessonTypeAcademicHoursBeforeLesson(),
|
||||||
source.remainingLessonTypeAcademicHoursAfterLesson()
|
source.remainingLessonTypeAcademicHoursAfterLesson(),
|
||||||
|
source.ruleParity()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.config.auth.AuthenticatedUser;
|
import com.magistr.app.config.auth.AuthenticatedUser;
|
||||||
|
import com.magistr.app.dto.UserResponse;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
|
import com.magistr.app.model.LifecycleEntity;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
|
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||||
|
import com.magistr.app.model.User;
|
||||||
import com.magistr.app.repository.SubjectCommentRepository;
|
import com.magistr.app.repository.SubjectCommentRepository;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import com.magistr.app.service.ScheduleQueryService;
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -35,6 +43,7 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
subjectRepository,
|
subjectRepository,
|
||||||
mock(SubjectCommentRepository.class),
|
mock(SubjectCommentRepository.class),
|
||||||
mock(UserRepository.class),
|
mock(UserRepository.class),
|
||||||
|
mock(TeacherDepartmentAssignmentRepository.class),
|
||||||
mock(ScheduleQueryService.class)
|
mock(ScheduleQueryService.class)
|
||||||
);
|
);
|
||||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
|
||||||
@@ -43,4 +52,48 @@ class DepartmentWorkspaceControllerTest {
|
|||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
|
||||||
|
UserRepository userRepository = mock(UserRepository.class);
|
||||||
|
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
|
||||||
|
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
|
||||||
|
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
|
||||||
|
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
|
||||||
|
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
|
||||||
|
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
|
||||||
|
.thenReturn(List.of(directTeacher, assignedTeacher));
|
||||||
|
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
|
||||||
|
mock(SubjectRepository.class),
|
||||||
|
mock(SubjectCommentRepository.class),
|
||||||
|
userRepository,
|
||||||
|
assignmentRepository,
|
||||||
|
mock(ScheduleQueryService.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<UserResponse> teachers = controller.getTeachers(1L);
|
||||||
|
|
||||||
|
assertThat(teachers).extracting(UserResponse::getFullName)
|
||||||
|
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
|
||||||
|
Department department = new Department(departmentId, "Кафедра", departmentId);
|
||||||
|
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||||
|
assignment.setTeacher(teacher);
|
||||||
|
assignment.setDepartment(department);
|
||||||
|
assignment.setValidFrom(LocalDate.now());
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(Long id, String username, String fullName, Long departmentId) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setRole(Role.TEACHER);
|
||||||
|
user.setFullName(fullName);
|
||||||
|
user.setJobTitle("Доцент");
|
||||||
|
user.setDepartmentId(departmentId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
|
| `GET` | `/api/department/teachers` | Преподаватели кафедры |
|
||||||
| `GET` | `/api/department/schedule` | Расписание кафедры |
|
| `GET` | `/api/department/schedule` | Расписание кафедры |
|
||||||
|
|
||||||
|
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Кафедры и специальности
|
## Кафедры и специальности
|
||||||
|
|||||||
@@ -146,13 +146,15 @@ frontend/
|
|||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
- Вкладка `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`. Действия группы выводятся в `group-actions` с фиксированными колонками, чтобы активные и архивные строки не смещали кнопки. Кнопка восстановления архивной группы использует стиль `btn-restore-group`: размеры и поведение как у архивной кнопки, но с жёлтым warning-акцентом.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной компактной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Блоки загрузки дисциплин и преподавателей стоят в равных по высоте колонках; загрузка дисциплин использует фиксированную колонку кода и равномерные отступы до названия, а у карточки преподавателей задан стабильный зазор между заголовком и нагрузкой. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||||
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
|
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Типы занятий в селекте слотов сортируются в том же порядке, что и блоки часов: лекция, лабораторная, практика. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
|
||||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- В таблице учебных годов семестры показываются компактными отдельными плашками в горизонтальной сетке: осенний и весенний семестры стоят рядом, у каждого есть собственная кнопка редактирования, а название года, период и действия учебного года центрируются относительно строки семестров. После нажатия заголовок формы уточняет, какой семестр открыт. Периоды учебных годов и семестров отображаются в формате `дд-мм-гггг — дд-мм-гггг`.
|
||||||
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||||
@@ -229,6 +231,8 @@ export const api = {
|
|||||||
- `TEACHER` → `/teacher/`
|
- `TEACHER` → `/teacher/`
|
||||||
- `STUDENT` → `/student/`
|
- `STUDENT` → `/student/`
|
||||||
|
|
||||||
|
При неуспешной авторизации `script.js` оставляет введённый логин, очищает поле пароля и возвращает фокус в него.
|
||||||
|
|
||||||
### Проверка авторизации
|
### Проверка авторизации
|
||||||
|
|
||||||
На каждой странице проверяется наличие токена и роли:
|
На каждой странице проверяется наличие токена и роли:
|
||||||
@@ -308,6 +312,10 @@ export function isAuthenticatedAsAdmin() {
|
|||||||
4. **`modals.css`** — Модальные окна
|
4. **`modals.css`** — Модальные окна
|
||||||
5. **`departments-data.css`** — Стили создания кафедры/специальности
|
5. **`departments-data.css`** — Стили создания кафедры/специальности
|
||||||
|
|
||||||
|
Мультиселекты с чекбоксами используют общий паттерн `.checkbox-item` + `.checkmark` + `.checkbox-label`; вкладка `classrooms` применяет его для выбора оборудования в создании и редактировании аудитории.
|
||||||
|
|
||||||
|
Дашборд использует классы `dashboard-container`, `dashboard-metrics-grid`, `dashboard-middle` и `dashboard-side-stack`: средний ряд растягивается по высоте самого высокого столбца, а правые компактные карточки выравниваются одинаковыми строками.
|
||||||
|
|
||||||
### Темизация
|
### Темизация
|
||||||
|
|
||||||
CSS-переменные позволяют поддерживать светлую/тёмную тему:
|
CSS-переменные позволяют поддерживать светлую/тёмную тему:
|
||||||
@@ -337,6 +345,8 @@ CSS-переменные позволяют поддерживать светл
|
|||||||
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
|
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
|
||||||
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
|
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
|
||||||
|
|
||||||
|
- **Секционные подписи sidebar** (`Расписание`, `Ресурсы`, `Система`) оформляются в `layout.css`, а их контраст для светлой и тёмной темы задаётся переменными `--sidebar-section-title` и `--sidebar-section-divider` в `main.css`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OpenTelemetry (`otel.js`)
|
## OpenTelemetry (`otel.js`)
|
||||||
|
|||||||
@@ -576,12 +576,31 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-view-control-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.schedule-view-results-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.schedule-view-table-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-view-control-card,
|
.schedule-view-control-card,
|
||||||
.schedule-view-results-card,
|
.schedule-view-results-card,
|
||||||
.schedule-view-table-card {
|
.schedule-view-table-card {
|
||||||
animation-duration: 0.28s;
|
animation-duration: 0.28s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-view-results-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-view-control-header,
|
.schedule-view-control-header,
|
||||||
.schedule-view-results-header,
|
.schedule-view-results-header,
|
||||||
.schedule-view-table-header {
|
.schedule-view-table-header {
|
||||||
@@ -589,6 +608,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-view-control-header h2,
|
.schedule-view-control-header h2,
|
||||||
@@ -684,10 +704,11 @@
|
|||||||
|
|
||||||
.schedule-view-result-tabs {
|
.schedule-view-result-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
margin-top: 1rem;
|
margin-top: 0;
|
||||||
padding-bottom: 0.1rem;
|
padding-bottom: 0.1rem;
|
||||||
overflow-x: auto;
|
overflow-x: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-view-result-tab {
|
.schedule-view-result-tab {
|
||||||
|
|||||||
@@ -48,6 +48,189 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Dashboard ===== */
|
||||||
|
.dashboard-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card {
|
||||||
|
--metric-color: var(--accent);
|
||||||
|
--metric-bg: rgba(99, 102, 241, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-left: 4px solid var(--metric-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card--students {
|
||||||
|
--metric-color: #10b981;
|
||||||
|
--metric-bg: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card--teachers {
|
||||||
|
--metric-color: #f59e0b;
|
||||||
|
--metric-bg: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card--classrooms {
|
||||||
|
--metric-color: #3b82f6;
|
||||||
|
--metric-bg: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card .metric-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--metric-color);
|
||||||
|
background: var(--metric-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-middle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-monitor-card,
|
||||||
|
.dashboard-compact-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-monitor-card {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-time-slot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-monitor-table {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-side-stack {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-compact-card {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1.25rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-badge-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-action-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-action-link.btn-secondary {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-action-link.btn-secondary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard-metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-middle {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-side-stack {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.dashboard-metrics-grid,
|
||||||
|
.dashboard-side-stack {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Form Structure ===== */
|
/* ===== Form Structure ===== */
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -167,14 +350,34 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.department-workspace-grid {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-subject-import-card,
|
||||||
|
.department-teachers-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-teachers-card .card-header-row {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-teachers-card .metric-grid {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card,
|
.metric-card,
|
||||||
.department-comment-item {
|
.department-comment-item {
|
||||||
padding: 0.85rem;
|
padding: 0.8rem 0.85rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -183,7 +386,7 @@
|
|||||||
.metric-card strong,
|
.metric-card strong,
|
||||||
.department-comment-item strong {
|
.department-comment-item strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.28rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +397,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.department-workload-list {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.department-comment-list {
|
.department-comment-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -262,6 +461,8 @@
|
|||||||
|
|
||||||
.schedule-slots-panel {
|
.schedule-slots-panel {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-slots-list {
|
.schedule-slots-list {
|
||||||
@@ -269,6 +470,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row {
|
||||||
@@ -282,10 +484,39 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-slot-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-slot-row.has-subgroup {
|
.schedule-slot-row.has-subgroup {
|
||||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-import-rows-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-import-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px minmax(260px, 1fr) auto;
|
||||||
|
column-gap: 0.65rem;
|
||||||
|
row-gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule-actions {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
.slot-subgroup-multi .select-text {
|
.slot-subgroup-multi .select-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -296,16 +527,97 @@
|
|||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-slot-summary,
|
.schedule-slot-summary {
|
||||||
.schedule-semester-line {
|
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-semester-line {
|
.academic-year-semesters {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-years-table tbody td {
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-years-table thead th:first-child,
|
||||||
|
.academic-years-table thead th:nth-child(2),
|
||||||
|
.academic-years-table thead th:last-child {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-center-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semesters-cell {
|
||||||
|
min-width: 520px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-actions-cell {
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-actions {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
|
min-height: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-import-code-field,
|
||||||
|
.subject-import-name-field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-import-row .form-group label {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.academic-year-semesters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semesters-cell {
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-type {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-year-semester-period {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-inline-table {
|
.schedule-inline-table {
|
||||||
@@ -406,7 +718,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-visual-filter-head > span {
|
.schedule-visual-filter-head>span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -606,7 +918,7 @@
|
|||||||
background: color-mix(in srgb, var(--accent) 13%, var(--bg-primary));
|
background: color-mix(in srgb, var(--accent) 13%, var(--bg-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-visual-half + .schedule-visual-half {
|
.schedule-visual-half+.schedule-visual-half {
|
||||||
border-top: 1px solid var(--bg-card-border);
|
border-top: 1px solid var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,11 +1092,15 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-block {
|
.calendar-semester-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
@@ -830,9 +1146,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-table {
|
.calendar-semester-table {
|
||||||
width: max-content;
|
--calendar-day-head-width: 32px;
|
||||||
min-width: 100%;
|
--calendar-day-cell-size: 22px;
|
||||||
|
--calendar-min-table-width: 538px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: var(--calendar-min-table-width);
|
||||||
|
max-width: none;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
@@ -841,32 +1162,43 @@
|
|||||||
|
|
||||||
.calendar-semester-table th,
|
.calendar-semester-table th,
|
||||||
.calendar-semester-table td {
|
.calendar-semester-table td {
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-table tbody tr {
|
.calendar-semester-table tbody tr {
|
||||||
height: 19px;
|
height: var(--calendar-day-cell-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-head-col {
|
||||||
|
width: var(--calendar-day-head-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-week-col {
|
||||||
|
width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-table thead th {
|
.calendar-semester-table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 18px;
|
height: var(--calendar-day-cell-size);
|
||||||
min-width: 18px;
|
padding: 0;
|
||||||
padding: 0 0.12rem;
|
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-table .calendar-day-head {
|
.calendar-semester-table .calendar-day-head {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 30px;
|
width: var(--calendar-day-head-width);
|
||||||
min-width: 30px;
|
min-width: var(--calendar-day-head-width);
|
||||||
|
max-width: var(--calendar-day-head-width);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -875,10 +1207,8 @@
|
|||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 18px;
|
height: var(--calendar-day-cell-size);
|
||||||
min-width: 18px;
|
min-height: var(--calendar-day-cell-size);
|
||||||
height: 19px;
|
|
||||||
min-height: 19px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card));
|
background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card));
|
||||||
border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border));
|
border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border));
|
||||||
@@ -986,30 +1316,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-tooltip-code {
|
.calendar-day-tooltip-code {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
display: inline-grid;
|
.calendar-day-tooltip-head .calendar-day-tooltip-code {
|
||||||
place-items: center;
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-tooltip-code span {
|
.calendar-day-tooltip-code span {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
transform: translateY(2px);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-tooltip-body {
|
.calendar-day-tooltip-body {
|
||||||
@@ -1019,12 +1355,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-empty-day {
|
.calendar-empty-day {
|
||||||
width: 18px;
|
height: var(--calendar-day-cell-size);
|
||||||
min-width: 18px;
|
|
||||||
height: 19px;
|
|
||||||
background: color-mix(in srgb, var(--bg-input) 65%, transparent);
|
background: color-mix(in srgb, var(--bg-input) 65%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-week-placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-empty-semester {
|
.calendar-empty-semester {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1176,6 +1514,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|
||||||
.schedule-view-filter-grid,
|
.schedule-view-filter-grid,
|
||||||
.schedule-view-metrics,
|
.schedule-view-metrics,
|
||||||
.department-workspace-grid {
|
.department-workspace-grid {
|
||||||
@@ -1198,6 +1537,8 @@
|
|||||||
.calendar-semesters-grid {
|
.calendar-semesters-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-auto-rows: auto;
|
grid-auto-rows: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-semester-block {
|
.calendar-semester-block {
|
||||||
@@ -1222,6 +1563,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-import-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-fill-panel {
|
.calendar-fill-panel {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1257,8 +1602,12 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-layer-active {
|
.select-layer-active {
|
||||||
position: relative;
|
position: relative !important;
|
||||||
z-index: 300;
|
z-index: 10001 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.open {
|
||||||
|
z-index: 10002 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-trigger {
|
.custom-select-trigger {
|
||||||
@@ -1278,9 +1627,11 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-row .custom-select-trigger {
|
.filter-row .custom-select-trigger {
|
||||||
|
height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border-color: transparent;
|
border-color: var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-trigger:hover {
|
.custom-select-trigger:hover {
|
||||||
@@ -1356,18 +1707,45 @@ input[type="number"] {
|
|||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-select-search-container {
|
||||||
|
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-search-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar for Dropdown */
|
/* Custom Scrollbar for Dropdown */
|
||||||
.custom-select-menu::-webkit-scrollbar {
|
.custom-select-menu::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-menu::-webkit-scrollbar-track {
|
.custom-select-menu::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-menu::-webkit-scrollbar-thumb {
|
.custom-select-menu::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-menu::-webkit-scrollbar-thumb:hover {
|
.custom-select-menu::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
@@ -1409,7 +1787,8 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-item.placeholder-item {
|
.custom-select-item.placeholder-item {
|
||||||
display: none; /* Hide placeholder options in the actual dropdown list naturally */
|
display: none;
|
||||||
|
/* Hide placeholder options in the actual dropdown list naturally */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme selects */
|
/* Light theme selects */
|
||||||
@@ -1427,6 +1806,7 @@ input[type="number"] {
|
|||||||
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb {
|
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb {
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb:hover {
|
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
@@ -1452,7 +1832,8 @@ input[type="number"] {
|
|||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-row label {
|
.filter-row label {
|
||||||
@@ -1465,12 +1846,15 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-row input {
|
.filter-row input {
|
||||||
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
|
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,14 +1953,17 @@ input[type="number"] {
|
|||||||
.checkbox-group-vertical::-webkit-scrollbar {
|
.checkbox-group-vertical::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group-vertical::-webkit-scrollbar-track {
|
.checkbox-group-vertical::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group-vertical::-webkit-scrollbar-thumb {
|
.checkbox-group-vertical::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
.checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1971,7 @@ input[type="number"] {
|
|||||||
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb {
|
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb {
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
@@ -1629,15 +2017,15 @@ input[type="number"] {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-item:hover input ~ .checkmark {
|
.checkbox-item:hover input~.checkmark {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-item input:focus ~ .checkmark {
|
.checkbox-item input:focus~.checkmark {
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-item input:checked ~ .checkmark {
|
.checkbox-item input:checked~.checkmark {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
|
||||||
@@ -1654,7 +2042,7 @@ input[type="number"] {
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-item input:checked ~ .checkmark::after {
|
.checkbox-item input:checked~.checkmark::after {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,12 +2092,35 @@ input[type="number"] {
|
|||||||
box-shadow: 0 2px 10px var(--accent-glow);
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.group-actions-cell {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px 104px 104px 116px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-content: end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete,
|
||||||
|
.btn-restore-group {
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
background: rgba(248, 113, 113, 0.1);
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--error);
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1718,11 +2129,28 @@ input[type="number"] {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
background: rgba(248, 113, 113, 0.2);
|
background: rgba(248, 113, 113, 0.2);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-restore-group {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restore-group:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-icon-toggle {
|
.btn-icon-toggle {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
@@ -1761,6 +2189,12 @@ input[type="number"] {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-actions .group-action-btn {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Alerts ===== */
|
/* ===== Alerts ===== */
|
||||||
.form-alert {
|
.form-alert {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2170,3 +2604,15 @@ tbody tr:hover {
|
|||||||
top: 1.25rem;
|
top: 1.25rem;
|
||||||
right: 1.25rem;
|
right: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-weeks-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--sidebar-section-divider);
|
||||||
|
margin: 0.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
padding: 0.5rem 1.25rem 0.25rem;
|
||||||
|
color: var(--sidebar-section-title);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -307,6 +322,14 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-section-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-section-divider {
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .btn-settings {
|
.sidebar.collapsed .btn-settings {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.65rem 0;
|
padding: 0.65rem 0;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
--bg-input: rgba(255, 255, 255, 0.04);
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||||
|
--sidebar-section-title: rgba(248, 250, 252, 0.45);
|
||||||
|
--sidebar-section-divider: rgba(229, 231, 235, 0.1);
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--text-primary: #f8fafc;
|
--text-primary: #f8fafc;
|
||||||
@@ -48,6 +50,8 @@
|
|||||||
--bg-input: rgba(0, 0, 0, 0.03);
|
--bg-input: rgba(0, 0, 0, 0.03);
|
||||||
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
||||||
--bg-hover: rgba(0, 0, 0, 0.05);
|
--bg-hover: rgba(0, 0, 0, 0.05);
|
||||||
|
--sidebar-section-title: rgba(51, 65, 85, 0.78);
|
||||||
|
--sidebar-section-divider: rgba(148, 163, 184, 0.32);
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #475569;
|
--text-secondary: #475569;
|
||||||
--text-placeholder: #94a3b8;
|
--text-placeholder: #94a3b8;
|
||||||
|
|||||||
@@ -43,72 +43,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a href="#" class="nav-item" data-tab="users">
|
<!-- Главная -->
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<a href="#" class="nav-item" data-tab="dashboard">
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
<span>Пользователи</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="departments-data">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
<rect x="3" y="3" width="7" height="9" />
|
||||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
<rect x="14" y="3" width="7" height="5" />
|
||||||
|
<rect x="14" y="12" width="7" height="9" />
|
||||||
|
<rect x="3" y="16" width="7" height="5" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Создание кафедры/специальности</span>
|
<span>Дашборд</span>
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="profiles">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
|
||||||
<path d="M16 11.5 18 14l3-5"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Профили обучения</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="groups">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
|
||||||
</svg>
|
|
||||||
<span>Группы</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="edu-forms">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
|
||||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
|
||||||
<line x1="9" y1="7" x2="17" y2="7" />
|
|
||||||
<line x1="9" y1="11" x2="15" y2="11" />
|
|
||||||
</svg>
|
|
||||||
<span>Формы обучения</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="equipments">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Оборудование</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="classrooms">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
|
||||||
</svg>
|
|
||||||
<span>Аудитории</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="subjects">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
||||||
</svg>
|
|
||||||
<span>Дисциплины</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="#" class="nav-item" data-tab="department-workspace">
|
<a href="#" class="nav-item" data-tab="department-workspace">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -120,14 +65,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Кабинет кафедры</span>
|
<span>Кабинет кафедры</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="schedule-view">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<!-- Раздел: Расписание -->
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
<div class="sidebar-section-divider"></div>
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
<div class="sidebar-section-title">Расписание</div>
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
<span>Просмотр расписаний</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="nav-item" data-tab="schedule">
|
<a href="#" class="nav-item" data-tab="schedule">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
@@ -135,7 +77,7 @@
|
|||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Расписание занятий</span>
|
<span>Конструктор правил</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="academic-calendar">
|
<a href="#" class="nav-item" data-tab="academic-calendar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -155,13 +97,63 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Загруженность</span>
|
<span>Загруженность</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="nav-item" data-tab="database">
|
<a href="#" class="nav-item" data-tab="schedule-view">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>База данных</span>
|
<span>Просмотр расписаний</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Раздел: Субъекты и ресурсы -->
|
||||||
|
<div class="sidebar-section-divider"></div>
|
||||||
|
<div class="sidebar-section-title">Ресурсы</div>
|
||||||
|
|
||||||
|
<a href="#" class="nav-item" data-tab="classrooms">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
|
</svg>
|
||||||
|
<span>Аудиторный фонд</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="groups">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
<span>Группы обучения</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="university-structure">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||||
|
</svg>
|
||||||
|
<span>Структура вуза</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="subjects">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Дисциплины</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Раздел: Система -->
|
||||||
|
<div class="sidebar-section-divider"></div>
|
||||||
|
<div class="sidebar-section-title">Система</div>
|
||||||
|
|
||||||
|
<a href="#" class="nav-item" data-tab="users">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
<span>Пользователи</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
|||||||
@@ -92,6 +92,41 @@ export async function refreshAccessToken() {
|
|||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
const CACHABLE_PREFIXES = [
|
||||||
|
'/api/departments',
|
||||||
|
'/api/specialties',
|
||||||
|
'/api/education-forms',
|
||||||
|
'/api/users/teachers',
|
||||||
|
'/api/equipments',
|
||||||
|
'/api/classrooms'
|
||||||
|
];
|
||||||
|
|
||||||
|
function invalidateCache(url) {
|
||||||
|
const cacheKeysToClear = [
|
||||||
|
'/api/departments',
|
||||||
|
'/api/specialties',
|
||||||
|
'/api/education-forms',
|
||||||
|
'/api/users',
|
||||||
|
'/api/equipments',
|
||||||
|
'/api/classrooms'
|
||||||
|
];
|
||||||
|
for (const prefix of cacheKeysToClear) {
|
||||||
|
if (url.includes(prefix)) {
|
||||||
|
for (const cacheKey of cache.keys()) {
|
||||||
|
if (cacheKey.includes(prefix)) {
|
||||||
|
cache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
@@ -102,6 +137,7 @@ export async function logout() {
|
|||||||
console.warn('Не удалось завершить серверную сессию:', e.message);
|
console.warn('Не удалось завершить серверную сессию:', e.message);
|
||||||
} finally {
|
} finally {
|
||||||
clearAuthState();
|
clearAuthState();
|
||||||
|
clearCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +157,26 @@ export function clearAuthState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (url) => apiFetch(url, 'GET'),
|
get: async (url) => {
|
||||||
post: (url, body) => apiFetch(url, 'POST', body),
|
const isCachable = CACHABLE_PREFIXES.some(prefix => url.startsWith(prefix));
|
||||||
put: (url, body) => apiFetch(url, 'PUT', body),
|
if (isCachable) {
|
||||||
delete: (url, body = null) => apiFetch(url, 'DELETE', body)
|
if (!cache.has(url)) {
|
||||||
|
cache.set(url, await apiFetch(url, 'GET'));
|
||||||
|
}
|
||||||
|
return cache.get(url);
|
||||||
|
}
|
||||||
|
return apiFetch(url, 'GET');
|
||||||
|
},
|
||||||
|
post: async (url, body) => {
|
||||||
|
invalidateCache(url);
|
||||||
|
return apiFetch(url, 'POST', body);
|
||||||
|
},
|
||||||
|
put: async (url, body) => {
|
||||||
|
invalidateCache(url);
|
||||||
|
return apiFetch(url, 'PUT', body);
|
||||||
|
},
|
||||||
|
delete: async (url, body = null) => {
|
||||||
|
invalidateCache(url);
|
||||||
|
return apiFetch(url, 'DELETE', body);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,6 +99,35 @@ export class CustomSelect {
|
|||||||
this.menu.innerHTML = '';
|
this.menu.innerHTML = '';
|
||||||
const options = Array.from(this.originalSelect.options);
|
const options = Array.from(this.originalSelect.options);
|
||||||
|
|
||||||
|
// Show search input if more than 6 options exist
|
||||||
|
if (options.length > 6) {
|
||||||
|
const searchLi = document.createElement('li');
|
||||||
|
searchLi.className = 'custom-select-search-container';
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.placeholder = 'Поиск...';
|
||||||
|
searchInput.className = 'custom-select-search-input';
|
||||||
|
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
const query = e.target.value.toLowerCase();
|
||||||
|
const items = this.menu.querySelectorAll('.custom-select-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.classList.contains('placeholder-item')) return;
|
||||||
|
const text = item.textContent.toLowerCase();
|
||||||
|
if (text.includes(query)) {
|
||||||
|
item.style.display = '';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
searchLi.appendChild(searchInput);
|
||||||
|
this.menu.appendChild(searchLi);
|
||||||
|
this.searchInput = searchInput;
|
||||||
|
} else {
|
||||||
|
this.searchInput = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'custom-select-item disabled';
|
li.className = 'custom-select-item disabled';
|
||||||
@@ -171,7 +200,22 @@ export class CustomSelect {
|
|||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
this.layerParent?.classList.add('select-layer-active');
|
this.layerParent?.classList.add('select-layer-active');
|
||||||
|
this.originalSelect.closest('.form-group, .form-field')?.classList.add('select-layer-active');
|
||||||
this.wrapper.classList.add('open');
|
this.wrapper.classList.add('open');
|
||||||
|
|
||||||
|
if (this.searchInput) {
|
||||||
|
this.searchInput.value = '';
|
||||||
|
const items = this.menu.querySelectorAll('.custom-select-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
if (!item.classList.contains('placeholder-item')) {
|
||||||
|
item.style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
this.searchInput.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
const selectedItem = this.menu.querySelector('.selected');
|
const selectedItem = this.menu.querySelector('.selected');
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
@@ -194,6 +238,7 @@ export class CustomSelect {
|
|||||||
close() {
|
close() {
|
||||||
this.wrapper.classList.remove('open');
|
this.wrapper.classList.remove('open');
|
||||||
this.layerParent?.classList.remove('select-layer-active');
|
this.layerParent?.classList.remove('select-layer-active');
|
||||||
|
this.originalSelect.closest('.form-group, .form-field')?.classList.remove('select-layer-active');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleItemClick(e, index) {
|
handleItemClick(e, index) {
|
||||||
|
|||||||
@@ -20,47 +20,106 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
startDropdownAutoObserver();
|
startDropdownAutoObserver();
|
||||||
});
|
});
|
||||||
|
|
||||||
import { initUsers } from './views/users.js';
|
|
||||||
import { initGroups } from './views/groups.js';
|
|
||||||
import { initEduForms } from './views/edu-forms.js';
|
|
||||||
import { initEquipments } from './views/equipments.js';
|
|
||||||
import { initClassrooms } from './views/classrooms.js';
|
|
||||||
import { initSubjects } from './views/subjects.js';
|
|
||||||
import {initSchedule} from "./views/schedule.js";
|
|
||||||
import {initDatabase} from "./views/database.js";
|
|
||||||
import {initDepartmentsData} from "./views/departments-data.js";
|
|
||||||
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
|
|
||||||
import {initProfiles} from "./views/profiles.js";
|
|
||||||
import {initAcademicCalendar} from "./views/academic-calendar.js";
|
|
||||||
import {initScheduleView} from "./views/schedule-view.js";
|
|
||||||
import {initDepartmentWorkspace} from "./views/department-workspace.js";
|
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers },
|
dashboard: {
|
||||||
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups },
|
title: 'Дашборд',
|
||||||
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms },
|
file: '/admin/views/dashboard.html',
|
||||||
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments },
|
init: async () => {
|
||||||
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms },
|
const module = await import('./views/dashboard.js');
|
||||||
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects },
|
await module.initDashboard();
|
||||||
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace },
|
}
|
||||||
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView },
|
},
|
||||||
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule },
|
users: {
|
||||||
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar },
|
title: 'Управление пользователями',
|
||||||
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload },
|
file: '/admin/views/users.html',
|
||||||
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase },
|
init: async () => {
|
||||||
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData },
|
const module = await import('./views/users.js');
|
||||||
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles },
|
await module.initUsers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
title: 'Управление группами',
|
||||||
|
file: '/admin/views/groups.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/groups.js');
|
||||||
|
await module.initGroups();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
classrooms: {
|
||||||
|
title: 'Аудиторный фонд',
|
||||||
|
file: '/admin/views/classrooms.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/classrooms.js');
|
||||||
|
await module.initClassrooms();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subjects: {
|
||||||
|
title: 'Дисциплины',
|
||||||
|
file: '/admin/views/subjects.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/subjects.js');
|
||||||
|
await module.initSubjects();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'university-structure': {
|
||||||
|
title: 'Структура вуза',
|
||||||
|
file: '/admin/views/university-structure.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/university-structure.js');
|
||||||
|
await module.initUniversityStructure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'department-workspace': {
|
||||||
|
title: 'Кабинет кафедры',
|
||||||
|
file: '/admin/views/department-workspace.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/department-workspace.js');
|
||||||
|
await module.initDepartmentWorkspace();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'schedule-view': {
|
||||||
|
title: 'Просмотр расписаний',
|
||||||
|
file: '/admin/views/schedule-view.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/schedule-view.js');
|
||||||
|
await module.initScheduleView();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
title: 'Расписание занятий',
|
||||||
|
file: '/admin/views/schedule.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/schedule.js');
|
||||||
|
await module.initSchedule();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'academic-calendar': {
|
||||||
|
title: 'Календарный учебный график',
|
||||||
|
file: '/admin/views/academic-calendar.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/academic-calendar.js');
|
||||||
|
await module.initAcademicCalendar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'auditorium-workload': {
|
||||||
|
title: 'Загруженность',
|
||||||
|
file: '/admin/views/auditorium-workload.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/auditorium-workload.js');
|
||||||
|
await module.initAuditoriumWorkload();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_NAVIGATION = {
|
const ROLE_NAVIGATION = {
|
||||||
ADMIN: {
|
ADMIN: {
|
||||||
defaultTab: 'users',
|
defaultTab: 'dashboard',
|
||||||
tabs: Object.keys(ROUTES)
|
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
|
||||||
},
|
},
|
||||||
EDUCATION_OFFICE: {
|
EDUCATION_OFFICE: {
|
||||||
defaultTab: 'schedule-view',
|
defaultTab: 'dashboard',
|
||||||
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments']
|
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
|
||||||
},
|
},
|
||||||
DEPARTMENT: {
|
DEPARTMENT: {
|
||||||
defaultTab: 'department-workspace',
|
defaultTab: 'department-workspace',
|
||||||
@@ -176,10 +235,13 @@ async function switchTab(tab) {
|
|||||||
|
|
||||||
// Initialize logic for the tab
|
// Initialize logic for the tab
|
||||||
if (ROUTES[tab].init) {
|
if (ROUTES[tab].init) {
|
||||||
ROUTES[tab].init();
|
await ROUTES[tab].init();
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
|
if (window.location.hash !== `#${tab}`) {
|
||||||
|
window.location.hash = tab;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
|
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -211,3 +273,10 @@ function configureRoleInterface() {
|
|||||||
|
|
||||||
const hashTab = window.location.hash.replace('#', '');
|
const hashTab = window.location.hash.replace('#', '');
|
||||||
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
|
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const hashTab = window.location.hash.replace('#', '');
|
||||||
|
if (allowedTabs.has(hashTab) && hashTab !== currentTab) {
|
||||||
|
switchTab(hashTab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -217,12 +217,14 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
academicYearsTbody.innerHTML = academicYears.map(year => `
|
academicYearsTbody.innerHTML = academicYears.map(year => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(year.title)}</td>
|
<td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
|
||||||
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
|
<td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
|
||||||
<td>${renderSemesterList(year.semesters || [])}</td>
|
<td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
|
||||||
<td>
|
<td class="academic-year-actions-cell">
|
||||||
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
|
<div class="academic-year-actions">
|
||||||
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
|
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
|
||||||
|
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -230,12 +232,19 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
function renderSemesterList(items) {
|
function renderSemesterList(items) {
|
||||||
if (!items.length) return '<span class="muted">Нет семестров</span>';
|
if (!items.length) return '<span class="muted">Нет семестров</span>';
|
||||||
return items.map(semester => `
|
return `<div class="academic-year-semesters">${items.map(semester => {
|
||||||
<div class="schedule-semester-line">
|
const title = semesterTypeTitle(semester);
|
||||||
${escapeHtml(semesterLabel(semester))}
|
const period = formatDisplayDateRange(semester.startDate, semester.endDate);
|
||||||
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
|
return `
|
||||||
</div>
|
<div class="academic-year-semester-item">
|
||||||
`).join('');
|
<div class="academic-year-semester-meta">
|
||||||
|
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
|
||||||
|
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAcademicYear(event) {
|
async function saveAcademicYear(event) {
|
||||||
@@ -282,7 +291,7 @@ export async function initAcademicCalendar() {
|
|||||||
if (editSemesterButton) {
|
if (editSemesterButton) {
|
||||||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||||||
if (!semester) return;
|
if (!semester) return;
|
||||||
semesterFormTitle.textContent = 'Редактирование семестра';
|
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||||||
semesterIdInput.value = semester.id;
|
semesterIdInput.value = semester.id;
|
||||||
semesterYearSelect.value = semester.academicYearId || '';
|
semesterYearSelect.value = semester.academicYearId || '';
|
||||||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||||||
@@ -580,39 +589,56 @@ export async function initAcademicCalendar() {
|
|||||||
function renderCourseSemesters(course, courseRows) {
|
function renderCourseSemesters(course, courseRows) {
|
||||||
const calendar = selectedEditorCalendar();
|
const calendar = selectedEditorCalendar();
|
||||||
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
|
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
|
||||||
|
const maxWeekColumns = Math.max(
|
||||||
|
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
|
||||||
|
1
|
||||||
|
);
|
||||||
return semestersForCourse.map(block => `
|
return semestersForCourse.map(block => `
|
||||||
<section class="calendar-semester-block">
|
<section class="calendar-semester-block">
|
||||||
<div class="calendar-semester-header">
|
<div class="calendar-semester-header">
|
||||||
<h4>${block.semesterNumber} семестр</h4>
|
<h4>${block.semesterNumber} семестр</h4>
|
||||||
<span>${escapeHtml(block.caption)}</span>
|
<span>${escapeHtml(block.caption)}</span>
|
||||||
</div>
|
</div>
|
||||||
${renderSemesterTable(block.rows)}
|
${renderSemesterTable(block.rows, maxWeekColumns)}
|
||||||
</section>
|
</section>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSemesterTable(rows) {
|
function renderSemesterTable(rows, targetWeekColumns = 0) {
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
|
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowsByWeek = groupBy(rows, row => row.weekNumber);
|
const rowsByWeek = groupBy(rows, row => row.weekNumber);
|
||||||
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
|
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
|
||||||
|
const weekSlots = [...weeks];
|
||||||
|
while (weekSlots.length < targetWeekColumns) {
|
||||||
|
weekSlots.push(null);
|
||||||
|
}
|
||||||
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
|
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
|
||||||
return `
|
return `
|
||||||
<div class="calendar-semester-table-wrap">
|
<div class="calendar-semester-table-wrap">
|
||||||
<table class="calendar-semester-table">
|
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
|
||||||
|
<colgroup>
|
||||||
|
<col class="calendar-day-head-col">
|
||||||
|
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="calendar-day-head">День</th>
|
<th class="calendar-day-head">День</th>
|
||||||
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
|
${weekSlots.map(week => week === null
|
||||||
|
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
|
||||||
|
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${DAY_OPTIONS.map(day => `
|
${DAY_OPTIONS.map(day => `
|
||||||
<tr>
|
<tr>
|
||||||
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
|
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
|
||||||
${weeks.map(week => {
|
${weekSlots.map(week => {
|
||||||
|
if (week === null) {
|
||||||
|
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
|
||||||
|
}
|
||||||
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
||||||
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
||||||
}).join('')}
|
}).join('')}
|
||||||
@@ -624,6 +650,10 @@ export async function initAcademicCalendar() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueWeekNumbers(rows) {
|
||||||
|
return Array.from(new Set(rows.map(row => row.weekNumber)));
|
||||||
|
}
|
||||||
|
|
||||||
function renderCalendarDay(row) {
|
function renderCalendarDay(row) {
|
||||||
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
||||||
const dayName = shortDayLabel(row.dayOfWeek);
|
const dayName = shortDayLabel(row.dayOfWeek);
|
||||||
@@ -1143,7 +1173,13 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
function semesterLabel(semester) {
|
function semesterLabel(semester) {
|
||||||
const type = normalizeSemesterType(semester.semesterType);
|
const type = normalizeSemesterType(semester.semesterType);
|
||||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function semesterTypeTitle(semester) {
|
||||||
|
const type = normalizeSemesterType(semester.semesterType);
|
||||||
|
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSemesterType(type) {
|
function normalizeSemesterType(type) {
|
||||||
@@ -1174,6 +1210,16 @@ export async function initAcademicCalendar() {
|
|||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDisplayDate(value) {
|
||||||
|
const [year, month, day] = String(value || '').split('-');
|
||||||
|
if (!year || !month || !day) return String(value || '');
|
||||||
|
return `${day}-${month}-${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayDateRange(startDate, endDate) {
|
||||||
|
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function dateRangeCaption(rows) {
|
function dateRangeCaption(rows) {
|
||||||
if (!rows.length) return '';
|
if (!rows.length) return '';
|
||||||
const dates = rows.map(row => row.date).sort();
|
const dates = rows.map(row => row.date).sort();
|
||||||
|
|||||||
@@ -68,13 +68,6 @@ const CELL_PARITY_LAYOUT = [
|
|||||||
{ parity: 'EVEN', label: 'Чётная' }
|
{ parity: 'EVEN', label: 'Чётная' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const CAPACITY_BUCKETS = [
|
|
||||||
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
|
|
||||||
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
|
|
||||||
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
|
|
||||||
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function initAuditoriumWorkload() {
|
export async function initAuditoriumWorkload() {
|
||||||
const dateInput = document.getElementById('workload-date');
|
const dateInput = document.getElementById('workload-date');
|
||||||
const refreshButton = document.getElementById('workload-refresh');
|
const refreshButton = document.getElementById('workload-refresh');
|
||||||
@@ -89,6 +82,7 @@ export async function initAuditoriumWorkload() {
|
|||||||
const roomTables = document.getElementById('room-workload-tables');
|
const roomTables = document.getElementById('room-workload-tables');
|
||||||
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
|
const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
|
||||||
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
|
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
|
||||||
|
const minCapacityInput = document.getElementById('workload-min-capacity');
|
||||||
|
|
||||||
let classrooms = [];
|
let classrooms = [];
|
||||||
let teachers = [];
|
let teachers = [];
|
||||||
@@ -117,7 +111,6 @@ export async function initAuditoriumWorkload() {
|
|||||||
|
|
||||||
function initFilters() {
|
function initFilters() {
|
||||||
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
||||||
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
|
|
||||||
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +135,6 @@ export async function initAuditoriumWorkload() {
|
|||||||
|
|
||||||
[
|
[
|
||||||
['building-checkboxes', 'building-text', 'Все корпуса'],
|
['building-checkboxes', 'building-text', 'Все корпуса'],
|
||||||
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
|
|
||||||
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
|
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
|
||||||
].forEach(([containerId, textId, emptyText]) => {
|
].forEach(([containerId, textId, emptyText]) => {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
@@ -153,6 +145,12 @@ export async function initAuditoriumWorkload() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
minCapacityInput?.addEventListener('input', () => {
|
||||||
|
if (currentView === DISPLAY_ALL) {
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
@@ -345,12 +343,6 @@ export async function initAuditoriumWorkload() {
|
|||||||
item => item.value,
|
item => item.value,
|
||||||
item => item.label
|
item => item.label
|
||||||
);
|
);
|
||||||
renderCheckboxes(
|
|
||||||
'capacity-checkboxes',
|
|
||||||
CAPACITY_BUCKETS,
|
|
||||||
item => item.id,
|
|
||||||
item => item.name
|
|
||||||
);
|
|
||||||
renderCheckboxes(
|
renderCheckboxes(
|
||||||
'equipment-checkboxes',
|
'equipment-checkboxes',
|
||||||
equipmentOptions(),
|
equipmentOptions(),
|
||||||
@@ -359,7 +351,6 @@ export async function initAuditoriumWorkload() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
|
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
|
||||||
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
|
|
||||||
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
|
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,14 +925,12 @@ export async function initAuditoriumWorkload() {
|
|||||||
|
|
||||||
function filteredClassrooms() {
|
function filteredClassrooms() {
|
||||||
const selectedBuildings = selectedValues('building-checkboxes');
|
const selectedBuildings = selectedValues('building-checkboxes');
|
||||||
const selectedCapacities = selectedValues('capacity-checkboxes');
|
const minCapacity = parseInt(minCapacityInput?.value || '0', 10) || 0;
|
||||||
const selectedEquipment = selectedValues('equipment-checkboxes');
|
const selectedEquipment = selectedValues('equipment-checkboxes');
|
||||||
|
|
||||||
return classrooms.filter(room => {
|
return classrooms.filter(room => {
|
||||||
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
|
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
|
||||||
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS
|
const capacityMatches = !minCapacity || Number(room.capacity || 0) >= minCapacity;
|
||||||
.filter(bucket => selectedCapacities.includes(bucket.id))
|
|
||||||
.some(bucket => bucket.match(room.capacity));
|
|
||||||
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
|
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
|
||||||
(room.equipments || []).some(item => String(item.id) === String(id))
|
(room.equipments || []).some(item => String(item.id) === String(id))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
|
||||||
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
|
|
||||||
|
async function fetchEquipments() {
|
||||||
|
try {
|
||||||
|
return await api.get('/api/equipments');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch equipments", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
const selected = new Set(selectedIds.map(id => String(id)));
|
||||||
|
|
||||||
|
if (!equipments || !equipments.length) {
|
||||||
|
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
|
||||||
|
updateSelectText(containerId, selectTextId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = equipments.map(eq => `
|
||||||
|
<label class="checkbox-item">
|
||||||
|
<input type="checkbox" value="${escapeHtml(eq.id)}" ${selected.has(String(eq.id)) ? 'checked' : ''}>
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
updateSelectText(containerId, selectTextId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function initClassrooms() {
|
export async function initClassrooms() {
|
||||||
const classroomsTbody = document.getElementById('classrooms-tbody');
|
const classroomsTbody = document.getElementById('classrooms-tbody');
|
||||||
@@ -191,5 +221,82 @@ export async function initClassrooms() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Элементы модалки оборудования
|
||||||
|
const btnManageEquipments = document.getElementById('btn-manage-equipments');
|
||||||
|
const modalManageEquipments = document.getElementById('modal-manage-equipments');
|
||||||
|
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
|
||||||
|
const manageEquipmentForm = document.getElementById('manage-equipment-form');
|
||||||
|
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
|
||||||
|
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
|
||||||
|
|
||||||
|
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
|
||||||
|
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
|
||||||
|
modalManageEquipments.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openManageEquipmentsModal() {
|
||||||
|
hideAlert('manage-equipment-alert');
|
||||||
|
manageEquipmentForm.reset();
|
||||||
|
modalManageEquipments.classList.add('open');
|
||||||
|
await loadManageEquipments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManageEquipments() {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||||
|
try {
|
||||||
|
allEquipments = await fetchEquipments();
|
||||||
|
renderManageEquipmentsTable();
|
||||||
|
// Обновим чекбоксы на основной странице
|
||||||
|
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
|
||||||
|
} catch (e) {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManageEquipmentsTable() {
|
||||||
|
if (!allEquipments || !allEquipments.length) {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
|
||||||
|
<tr>
|
||||||
|
<td>${eq.id}</td>
|
||||||
|
<td>${escapeHtml(eq.name)}</td>
|
||||||
|
<td><button class="btn-delete btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
manageEquipmentForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('manage-equipment-alert');
|
||||||
|
const name = manageEquipmentNameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showAlert('manage-equipment-alert', 'Введите название', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api.post('/api/equipments', { name });
|
||||||
|
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
|
||||||
|
manageEquipmentForm.reset();
|
||||||
|
await loadManageEquipments();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manageEquipmentTbody.addEventListener('click', async (e) => {
|
||||||
|
const btnDelete = e.target.closest('.btn-delete-equipment');
|
||||||
|
if (!btnDelete) return;
|
||||||
|
if (!confirm('Удалить оборудование из каталога?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/equipments/' + btnDelete.dataset.id);
|
||||||
|
await loadManageEquipments();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
}
|
}
|
||||||
|
|||||||
322
frontend/admin/js/views/dashboard.js
Normal file
322
frontend/admin/js/views/dashboard.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
|
export async function initDashboard() {
|
||||||
|
// DOM Элементы
|
||||||
|
const metricGroups = document.getElementById('metric-groups');
|
||||||
|
const metricStudents = document.getElementById('metric-students');
|
||||||
|
const metricTeachers = document.getElementById('metric-teachers');
|
||||||
|
const metricClassrooms = document.getElementById('metric-classrooms');
|
||||||
|
|
||||||
|
const currentTimeSlot = document.getElementById('current-time-slot');
|
||||||
|
const currentLessonsTbody = document.getElementById('current-lessons-tbody');
|
||||||
|
const freeClassroomsList = document.getElementById('free-classrooms-list');
|
||||||
|
const conflictsContainer = document.getElementById('conflicts-container');
|
||||||
|
const btnRecheck = document.getElementById('btn-recheck-conflicts');
|
||||||
|
|
||||||
|
let departments = [];
|
||||||
|
let classrooms = [];
|
||||||
|
let groups = [];
|
||||||
|
let teachers = [];
|
||||||
|
let todayLessons = [];
|
||||||
|
let timeSlots = [];
|
||||||
|
|
||||||
|
btnRecheck.addEventListener('click', async () => {
|
||||||
|
btnRecheck.disabled = true;
|
||||||
|
btnRecheck.textContent = '...';
|
||||||
|
await checkConflicts();
|
||||||
|
btnRecheck.textContent = 'Перепроверить';
|
||||||
|
btnRecheck.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
// Загружаем базовые справочники
|
||||||
|
[departments, classrooms, groups, teachers] = await Promise.all([
|
||||||
|
api.get('/api/departments'),
|
||||||
|
api.get('/api/classrooms?includeArchived=false'),
|
||||||
|
api.get('/api/groups?includeArchived=false'),
|
||||||
|
api.get('/api/users/teachers')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Выводим метрики
|
||||||
|
metricGroups.textContent = groups.length;
|
||||||
|
const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0);
|
||||||
|
metricStudents.textContent = totalStudents;
|
||||||
|
metricTeachers.textContent = teachers.length;
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Загружаем временные слоты и сегодняшнее расписание
|
||||||
|
const lessonsPromises = departments.map(d =>
|
||||||
|
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => [])
|
||||||
|
);
|
||||||
|
const lessonsArrays = await Promise.all(lessonsPromises);
|
||||||
|
todayLessons = lessonsArrays.flat();
|
||||||
|
|
||||||
|
try {
|
||||||
|
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`);
|
||||||
|
} catch (e) {
|
||||||
|
// Если эндпоинт effective не поддерживается, используем базовые слоты
|
||||||
|
const baseSlots = await api.get('/api/admin/time-slots').catch(() => []);
|
||||||
|
// Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично)
|
||||||
|
timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем слоты по времени
|
||||||
|
timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||||
|
|
||||||
|
// Посчитаем занятость аудиторий сегодня
|
||||||
|
const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean));
|
||||||
|
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||||
|
const classPercent = activeClassrooms.length
|
||||||
|
? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100)
|
||||||
|
: 0;
|
||||||
|
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
||||||
|
|
||||||
|
updateRealtimeMonitoring();
|
||||||
|
await checkConflicts();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Dashboard data load error", e);
|
||||||
|
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRealtimeMonitoring() {
|
||||||
|
const now = new Date();
|
||||||
|
const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
|
||||||
|
|
||||||
|
// Находим текущий временной слот
|
||||||
|
let currentSlot = null;
|
||||||
|
for (const slot of timeSlots) {
|
||||||
|
if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) {
|
||||||
|
currentSlot = slot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSlot) {
|
||||||
|
currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`;
|
||||||
|
renderCurrentLessons(currentSlot.orderNumber);
|
||||||
|
renderFreeClassrooms(currentSlot.orderNumber);
|
||||||
|
} else {
|
||||||
|
currentTimeSlot.textContent = 'Вне сетки пар';
|
||||||
|
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
|
||||||
|
|
||||||
|
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
|
||||||
|
renderFreeClassrooms(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentLessons(slotOrder) {
|
||||||
|
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
|
||||||
|
if (!activeLessons.length) {
|
||||||
|
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLessonsTbody.innerHTML = activeLessons.map(l => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
|
||||||
|
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
|
||||||
|
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
|
||||||
|
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFreeClassrooms(slotOrder) {
|
||||||
|
const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED');
|
||||||
|
let occupiedIds = new Set();
|
||||||
|
if (slotOrder !== null) {
|
||||||
|
occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const free = activeClassrooms.filter(c => !occupiedIds.has(c.id));
|
||||||
|
if (!free.length) {
|
||||||
|
freeClassroomsList.innerHTML = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeClassroomsList.innerHTML = free.map(c => `
|
||||||
|
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
|
||||||
|
${escapeHtml(c.name)} (${c.capacity} м.)
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Анализ конфликтов (Red Zone) ---
|
||||||
|
async function checkConflicts() {
|
||||||
|
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||||||
|
|
||||||
|
// Вычисляем даты начала и конца текущей недели
|
||||||
|
const curr = new Date();
|
||||||
|
const day = curr.getDay();
|
||||||
|
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
|
||||||
|
const monday = new Date(curr.setDate(diff));
|
||||||
|
const sunday = new Date(curr.setDate(diff + 6));
|
||||||
|
|
||||||
|
const mondayStr = monday.toISOString().split('T')[0];
|
||||||
|
const sundayStr = sunday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем расписание на всю неделю по кафедрам
|
||||||
|
const weekLessonsPromises = departments.map(d =>
|
||||||
|
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
|
||||||
|
);
|
||||||
|
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
|
||||||
|
const weekLessons = weekLessonsArrays.flat();
|
||||||
|
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
// Мапы для быстрого поиска характеристик групп и аудиторий
|
||||||
|
const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0]));
|
||||||
|
const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0]));
|
||||||
|
|
||||||
|
// 1. Проверка накладок преподавателей
|
||||||
|
// Ключ: date + '_' + timeSlotOrder + '_' + teacherId
|
||||||
|
const teacherSlots = new Map();
|
||||||
|
weekLessons.forEach(l => {
|
||||||
|
if (!l.teacherId) return;
|
||||||
|
const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`;
|
||||||
|
if (!teacherSlots.has(key)) {
|
||||||
|
teacherSlots.set(key, []);
|
||||||
|
}
|
||||||
|
teacherSlots.get(key).push(l);
|
||||||
|
});
|
||||||
|
|
||||||
|
teacherSlots.forEach((lessons, key) => {
|
||||||
|
if (lessons.length > 1) {
|
||||||
|
// Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт
|
||||||
|
const classroomIds = new Set(lessons.map(l => l.classroomId));
|
||||||
|
if (classroomIds.size > 1) {
|
||||||
|
const first = lessons[0];
|
||||||
|
const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', ');
|
||||||
|
conflicts.push({
|
||||||
|
type: 'TEACHER_COLLISION',
|
||||||
|
title: 'Накладка в расписании преподавателя',
|
||||||
|
description: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Проверка накладок аудиторий
|
||||||
|
// Ключ: date + '_' + timeSlotOrder + '_' + classroomId
|
||||||
|
const classroomSlots = new Map();
|
||||||
|
weekLessons.forEach(l => {
|
||||||
|
if (!l.classroomId) return;
|
||||||
|
const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`;
|
||||||
|
if (!classroomSlots.has(key)) {
|
||||||
|
classroomSlots.set(key, []);
|
||||||
|
}
|
||||||
|
classroomSlots.get(key).push(l);
|
||||||
|
});
|
||||||
|
|
||||||
|
classroomSlots.forEach((lessons, key) => {
|
||||||
|
if (lessons.length > 1) {
|
||||||
|
// Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина)
|
||||||
|
const teacherIds = new Set(lessons.map(l => l.teacherId));
|
||||||
|
const subjectIds = new Set(lessons.map(l => l.subjectId));
|
||||||
|
|
||||||
|
if (teacherIds.size > 1 || subjectIds.size > 1) {
|
||||||
|
const first = lessons[0];
|
||||||
|
const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И ');
|
||||||
|
conflicts.push({
|
||||||
|
type: 'CLASSROOM_COLLISION',
|
||||||
|
title: 'Накладка в аудитории',
|
||||||
|
description: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Проверка превышения вместимости аудитории
|
||||||
|
// Считаем сумму студентов для каждого уникального занятия в аудитории
|
||||||
|
classroomSlots.forEach((lessons, key) => {
|
||||||
|
if (!lessons.length) return;
|
||||||
|
const first = lessons[0];
|
||||||
|
const capacity = classroomCapacityMap.get(first.classroomId) || 0;
|
||||||
|
|
||||||
|
// Суммируем численность всех групп, назначенных на этот слот в этой аудитории
|
||||||
|
const groupIds = new Set();
|
||||||
|
lessons.forEach(l => {
|
||||||
|
if (l.groupIds) {
|
||||||
|
l.groupIds.forEach(id => groupIds.add(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalStudents = 0;
|
||||||
|
groupIds.forEach(id => {
|
||||||
|
totalStudents += groupSizeMap.get(id) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalStudents > capacity) {
|
||||||
|
const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', ');
|
||||||
|
conflicts.push({
|
||||||
|
type: 'CAPACITY_OVERFLOW',
|
||||||
|
title: 'Превышение вместимости аудитории',
|
||||||
|
description: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderConflicts(conflicts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Conflicts check error", e);
|
||||||
|
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConflicts(conflicts) {
|
||||||
|
if (!conflicts.length) {
|
||||||
|
conflictsContainer.innerHTML = `
|
||||||
|
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
|
||||||
|
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conflictsContainer.innerHTML = conflicts.map(c => {
|
||||||
|
const isError = c.severity === 'error';
|
||||||
|
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
|
||||||
|
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
|
||||||
|
const badgeIcon = isError
|
||||||
|
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
|
||||||
|
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
|
||||||
|
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
|
||||||
|
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные при старте
|
||||||
|
await loadData();
|
||||||
|
|
||||||
|
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
|
||||||
|
const realtimeTimer = setInterval(() => {
|
||||||
|
if (document.getElementById('current-time-slot')) {
|
||||||
|
updateRealtimeMonitoring();
|
||||||
|
} else {
|
||||||
|
clearInterval(realtimeTimer);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export async function initDepartmentWorkspace() {
|
|||||||
const endInput = document.getElementById('department-workspace-end');
|
const endInput = document.getElementById('department-workspace-end');
|
||||||
const refreshButton = document.getElementById('department-workspace-refresh');
|
const refreshButton = document.getElementById('department-workspace-refresh');
|
||||||
const importForm = document.getElementById('department-subject-import-form');
|
const importForm = document.getElementById('department-subject-import-form');
|
||||||
|
const importRowsContainer = document.getElementById('subject-import-rows');
|
||||||
|
const importAddButton = document.getElementById('subject-import-add');
|
||||||
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
const subjectsTbody = document.getElementById('department-subjects-tbody');
|
||||||
const subjectCount = document.getElementById('department-subject-count');
|
const subjectCount = document.getElementById('department-subject-count');
|
||||||
const commentsCard = document.getElementById('department-comments-card');
|
const commentsCard = document.getElementById('department-comments-card');
|
||||||
@@ -21,13 +23,26 @@ export async function initDepartmentWorkspace() {
|
|||||||
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
|
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
|
||||||
const commentsTextInput = document.getElementById('department-comment-text');
|
const commentsTextInput = document.getElementById('department-comment-text');
|
||||||
const commentsCloseButton = document.getElementById('department-comments-close');
|
const commentsCloseButton = document.getElementById('department-comments-close');
|
||||||
|
let lastTeachers = null;
|
||||||
|
let lastWorkload = null;
|
||||||
|
let teachersErrorMessage = '';
|
||||||
|
let workloadErrorMessage = '';
|
||||||
|
|
||||||
setDefaultDates(startInput, endInput);
|
setDefaultDates(startInput, endInput);
|
||||||
refreshButton?.addEventListener('click', loadWorkspace);
|
refreshButton?.addEventListener('click', loadWorkspace);
|
||||||
departmentSelect?.addEventListener('change', loadWorkspace);
|
departmentSelect?.addEventListener('change', loadWorkspace);
|
||||||
startInput?.addEventListener('change', loadWorkload);
|
startInput?.addEventListener('change', handleStartDateChange);
|
||||||
endInput?.addEventListener('change', loadWorkload);
|
endInput?.addEventListener('change', loadWorkload);
|
||||||
importForm?.addEventListener('submit', importSubjects);
|
importForm?.addEventListener('submit', importSubjects);
|
||||||
|
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
|
||||||
|
importRowsContainer?.addEventListener('click', (event) => {
|
||||||
|
const removeBtn = event.target.closest('.subject-import-remove');
|
||||||
|
if (removeBtn) {
|
||||||
|
const rows = readImportRows();
|
||||||
|
rows.splice(Number(removeBtn.dataset.index), 1);
|
||||||
|
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
commentsForm?.addEventListener('submit', saveComment);
|
commentsForm?.addEventListener('submit', saveComment);
|
||||||
commentsCloseButton?.addEventListener('click', () => {
|
commentsCloseButton?.addEventListener('click', () => {
|
||||||
commentsCard.hidden = true;
|
commentsCard.hidden = true;
|
||||||
@@ -46,6 +61,7 @@ export async function initDepartmentWorkspace() {
|
|||||||
departmentField.hidden = true;
|
departmentField.hidden = true;
|
||||||
departmentSelect.value = userDepartmentId || '';
|
departmentSelect.value = userDepartmentId || '';
|
||||||
}
|
}
|
||||||
|
renderImportRows([{ code: '', name: '' }]);
|
||||||
await loadWorkspace();
|
await loadWorkspace();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
|
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
|
||||||
@@ -53,6 +69,11 @@ export async function initDepartmentWorkspace() {
|
|||||||
|
|
||||||
async function loadWorkspace() {
|
async function loadWorkspace() {
|
||||||
hideAlert('department-workspace-alert');
|
hideAlert('department-workspace-alert');
|
||||||
|
lastTeachers = null;
|
||||||
|
lastWorkload = null;
|
||||||
|
teachersErrorMessage = '';
|
||||||
|
workloadErrorMessage = '';
|
||||||
|
renderTeachers();
|
||||||
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
|
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,38 +108,57 @@ export async function initDepartmentWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderImportRows(rows) {
|
||||||
|
if (!importRowsContainer) return;
|
||||||
|
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
|
||||||
|
<div class="subject-import-row" data-index="${index}">
|
||||||
|
<div class="form-group subject-import-code-field">
|
||||||
|
<label>Код</label>
|
||||||
|
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
|
||||||
|
</div>
|
||||||
|
<div class="form-group subject-import-name-field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-delete subject-import-remove" data-index="${index}">Удалить</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImportRows() {
|
||||||
|
if (!importRowsContainer) return [];
|
||||||
|
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
|
||||||
|
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
|
||||||
|
name: (row.querySelector('.subject-import-name')?.value || '').trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function importSubjects(event) {
|
async function importSubjects(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const alertId = 'department-subject-import-alert';
|
const alertId = 'department-subject-import-alert';
|
||||||
hideAlert(alertId);
|
hideAlert(alertId);
|
||||||
const departmentId = selectedDepartmentId();
|
const departmentId = selectedDepartmentId();
|
||||||
const rows = document.getElementById('department-subject-import-text').value
|
const rows = readImportRows()
|
||||||
.split('\n')
|
.filter(item => item.name)
|
||||||
.map(row => row.trim())
|
.map(item => ({
|
||||||
.filter(Boolean)
|
code: item.code,
|
||||||
.map(row => {
|
name: item.name,
|
||||||
const [code, ...nameParts] = row.split(';');
|
departmentId: Number(departmentId)
|
||||||
return {
|
}));
|
||||||
code: (code || '').trim(),
|
|
||||||
name: nameParts.join(';').trim(),
|
|
||||||
departmentId: Number(departmentId)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(item => item.name);
|
|
||||||
|
|
||||||
if (!departmentId) {
|
if (!departmentId) {
|
||||||
showAlert(alertId, 'Выберите кафедру', 'error');
|
showAlert(alertId, 'Выберите кафедру', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error');
|
showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.post('/api/department/subjects/import', rows);
|
const result = await api.post('/api/department/subjects/import', rows);
|
||||||
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
|
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
|
||||||
document.getElementById('department-subject-import-text').value = '';
|
renderImportRows([{ code: '', name: '' }]);
|
||||||
await loadSubjects();
|
await loadSubjects();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
|
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
|
||||||
@@ -172,25 +212,28 @@ export async function initDepartmentWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTeachers() {
|
async function loadTeachers() {
|
||||||
const container = document.getElementById('department-teachers-list');
|
lastTeachers = null;
|
||||||
container.textContent = 'Загрузка...';
|
teachersErrorMessage = '';
|
||||||
|
renderTeachers();
|
||||||
try {
|
try {
|
||||||
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
|
||||||
container.innerHTML = teachers.length
|
lastTeachers = Array.isArray(teachers) ? teachers : [];
|
||||||
? teachers.map(teacher => `
|
renderTeachers();
|
||||||
<article class="metric-card">
|
|
||||||
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
|
|
||||||
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
|
|
||||||
</article>
|
|
||||||
`).join('')
|
|
||||||
: '<div class="loading-row">Преподаватели не найдены</div>';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
|
lastTeachers = [];
|
||||||
|
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
|
||||||
|
renderTeachers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWorkload() {
|
async function loadWorkload() {
|
||||||
const container = document.getElementById('department-workload-list');
|
if (!startInput.value || !endInput.value) {
|
||||||
|
lastWorkload = [];
|
||||||
|
workloadErrorMessage = 'Выберите начало и окончание периода';
|
||||||
|
renderTeachers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDate: startInput.value,
|
startDate: startInput.value,
|
||||||
endDate: endInput.value
|
endDate: endInput.value
|
||||||
@@ -198,23 +241,84 @@ export async function initDepartmentWorkspace() {
|
|||||||
if (selectedDepartmentId()) {
|
if (selectedDepartmentId()) {
|
||||||
params.set('departmentId', selectedDepartmentId());
|
params.set('departmentId', selectedDepartmentId());
|
||||||
}
|
}
|
||||||
container.textContent = 'Загрузка...';
|
lastWorkload = null;
|
||||||
|
workloadErrorMessage = '';
|
||||||
|
renderTeachers();
|
||||||
try {
|
try {
|
||||||
const workload = await api.get(`/api/workload/teachers?${params}`);
|
const workload = await api.get(`/api/workload/teachers?${params}`);
|
||||||
container.innerHTML = workload.length
|
lastWorkload = Array.isArray(workload) ? workload : [];
|
||||||
? workload.map(item => `
|
renderTeachers();
|
||||||
<article class="metric-card">
|
|
||||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
|
||||||
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
|
|
||||||
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
|
|
||||||
</article>
|
|
||||||
`).join('')
|
|
||||||
: '<div class="loading-row">Нагрузка не найдена</div>';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
|
lastWorkload = [];
|
||||||
|
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
|
||||||
|
renderTeachers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStartDateChange() {
|
||||||
|
if (startInput.value && endInput.value && startInput.value > endInput.value) {
|
||||||
|
endInput.value = '';
|
||||||
|
}
|
||||||
|
await loadWorkload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTeachers() {
|
||||||
|
const container = document.getElementById('department-teachers-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (lastTeachers === null || lastWorkload === null) {
|
||||||
|
container.textContent = 'Загрузка...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
|
||||||
|
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
|
||||||
|
const workloadByTeacher = new Map();
|
||||||
|
const usedWorkloadKeys = new Set();
|
||||||
|
|
||||||
|
workload
|
||||||
|
.filter(item => item && (item.id || item.name))
|
||||||
|
.forEach(item => {
|
||||||
|
workloadKeys(item).forEach(key => {
|
||||||
|
if (!workloadByTeacher.has(key)) {
|
||||||
|
workloadByTeacher.set(key, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = teachers.map(teacher => {
|
||||||
|
const workloadItem = workloadKeys(teacher)
|
||||||
|
.map(key => workloadByTeacher.get(key))
|
||||||
|
.find(Boolean);
|
||||||
|
if (workloadItem) {
|
||||||
|
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
|
||||||
|
}
|
||||||
|
return renderTeacherMetric({
|
||||||
|
name: teacher.fullName || teacher.username || '-',
|
||||||
|
meta: teacher.jobTitle || 'Должность не указана',
|
||||||
|
workload: workloadItem
|
||||||
|
}, workloadErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
workload
|
||||||
|
.filter(item => item && (item.id || item.name))
|
||||||
|
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
|
||||||
|
.forEach(item => {
|
||||||
|
rows.push(renderTeacherMetric({
|
||||||
|
name: item.name || '-',
|
||||||
|
meta: 'Есть занятия в расписании кафедры',
|
||||||
|
workload: item
|
||||||
|
}, workloadErrorMessage));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
container.innerHTML = rows.join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function selectedDepartmentId() {
|
function selectedDepartmentId() {
|
||||||
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
|
||||||
}
|
}
|
||||||
@@ -261,3 +365,36 @@ function subjectCountLabel(count) {
|
|||||||
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
|
||||||
return `${count} дисциплин`;
|
return `${count} дисциплин`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTeacherMetric(item, workloadErrorMessage) {
|
||||||
|
return `
|
||||||
|
<article class="metric-card">
|
||||||
|
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||||
|
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
|
||||||
|
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadLabel(item, workloadErrorMessage) {
|
||||||
|
if (workloadErrorMessage) {
|
||||||
|
return workloadErrorMessage;
|
||||||
|
}
|
||||||
|
if (!item) {
|
||||||
|
return '0 ак. ч., 0 занятий за период';
|
||||||
|
}
|
||||||
|
const hours = String(item.academicHours ?? 0);
|
||||||
|
const lessons = String(item.lessonCount ?? 0);
|
||||||
|
return `${hours} ак. ч., ${lessons} занятий за период`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workloadKeys(item) {
|
||||||
|
return [
|
||||||
|
item.id == null ? null : `id:${item.id}`,
|
||||||
|
normalizeTeacherName(item.fullName || item.name || item.username)
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeacherName(value) {
|
||||||
|
return value ? `name:${String(value).trim().toLowerCase()}` : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { api } from '../api.js';
|
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
|
||||||
|
|
||||||
export async function initDepartmentsData() {
|
|
||||||
const deptTbody = document.getElementById('departments-tbody');
|
|
||||||
const specTbody = document.getElementById('specialties-tbody');
|
|
||||||
|
|
||||||
const createDeptForm = document.getElementById('create-department-form');
|
|
||||||
const createSpecForm = document.getElementById('create-specialty-form');
|
|
||||||
const editDeptForm = document.getElementById('edit-department-form');
|
|
||||||
const editSpecForm = document.getElementById('edit-specialty-form');
|
|
||||||
const editDeptModal = document.getElementById('modal-edit-department');
|
|
||||||
const editSpecModal = document.getElementById('modal-edit-specialty');
|
|
||||||
const editDeptClose = document.getElementById('modal-edit-department-close');
|
|
||||||
const editSpecClose = document.getElementById('modal-edit-specialty-close');
|
|
||||||
|
|
||||||
let departments = [];
|
|
||||||
let specialties = [];
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
// Load Departments
|
|
||||||
try {
|
|
||||||
departments = await api.get('/api/departments');
|
|
||||||
renderDepartments();
|
|
||||||
} catch (e) {
|
|
||||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Specialties
|
|
||||||
try {
|
|
||||||
specialties = await api.get('/api/specialties');
|
|
||||||
renderSpecialties();
|
|
||||||
} catch (e) {
|
|
||||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDepartments() {
|
|
||||||
if (!departments || !departments.length) {
|
|
||||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deptTbody.innerHTML = departments.map(d => `
|
|
||||||
<tr>
|
|
||||||
<td>${d.id}</td>
|
|
||||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
|
||||||
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
|
|
||||||
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSpecialties() {
|
|
||||||
if (!specialties || !specialties.length) {
|
|
||||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
specTbody.innerHTML = specialties.map(s => `
|
|
||||||
<tr>
|
|
||||||
<td>${s.id}</td>
|
|
||||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
|
||||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
|
||||||
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
createDeptForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('create-dept-alert');
|
|
||||||
const name = document.getElementById('dept-name').value.trim();
|
|
||||||
const code = document.getElementById('dept-code').value.trim();
|
|
||||||
|
|
||||||
if (!name || !code) {
|
|
||||||
showAlert('create-dept-alert', 'Заполните все поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
|
|
||||||
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
|
|
||||||
createDeptForm.reset();
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deptTbody.addEventListener('click', async (e) => {
|
|
||||||
const editBtn = e.target.closest('.btn-edit-department');
|
|
||||||
const deleteBtn = e.target.closest('.btn-delete-department');
|
|
||||||
|
|
||||||
if (editBtn) {
|
|
||||||
const department = departments.find(item => item.id == editBtn.dataset.id);
|
|
||||||
if (!department) return;
|
|
||||||
|
|
||||||
document.getElementById('edit-dept-id').value = department.id;
|
|
||||||
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
|
|
||||||
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
|
|
||||||
hideAlert('edit-dept-alert');
|
|
||||||
editDeptModal.classList.add('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteBtn) {
|
|
||||||
if (!confirm('Удалить кафедру?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/departments/' + deleteBtn.dataset.id);
|
|
||||||
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
createSpecForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('create-spec-alert');
|
|
||||||
const name = document.getElementById('spec-name').value.trim();
|
|
||||||
const code = document.getElementById('spec-code').value.trim();
|
|
||||||
|
|
||||||
if (!name || !code) {
|
|
||||||
showAlert('create-spec-alert', 'Заполните все поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
|
|
||||||
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
|
|
||||||
createSpecForm.reset();
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
specTbody.addEventListener('click', async (e) => {
|
|
||||||
const editBtn = e.target.closest('.btn-edit-specialty');
|
|
||||||
const deleteBtn = e.target.closest('.btn-delete-specialty');
|
|
||||||
|
|
||||||
if (editBtn) {
|
|
||||||
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
|
|
||||||
if (!speciality) return;
|
|
||||||
|
|
||||||
document.getElementById('edit-spec-id').value = speciality.id;
|
|
||||||
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
|
|
||||||
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
|
|
||||||
hideAlert('edit-spec-alert');
|
|
||||||
editSpecModal.classList.add('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteBtn) {
|
|
||||||
if (!confirm('Удалить специальность?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
|
|
||||||
showAlert('create-spec-alert', 'Специальность удалена', 'success');
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editDeptForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('edit-dept-alert');
|
|
||||||
const id = document.getElementById('edit-dept-id').value;
|
|
||||||
const name = document.getElementById('edit-dept-name').value.trim();
|
|
||||||
const code = document.getElementById('edit-dept-code').value.trim();
|
|
||||||
|
|
||||||
if (!name || !code) {
|
|
||||||
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
|
|
||||||
editDeptModal.classList.remove('open');
|
|
||||||
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editSpecForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('edit-spec-alert');
|
|
||||||
const id = document.getElementById('edit-spec-id').value;
|
|
||||||
const name = document.getElementById('edit-spec-name').value.trim();
|
|
||||||
const code = document.getElementById('edit-spec-code').value.trim();
|
|
||||||
|
|
||||||
if (!name || !code) {
|
|
||||||
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
|
|
||||||
editSpecModal.classList.remove('open');
|
|
||||||
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
|
|
||||||
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
|
|
||||||
editDeptModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
|
|
||||||
});
|
|
||||||
editSpecModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadData();
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { api } from '../api.js';
|
|
||||||
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
|
|
||||||
|
|
||||||
export let allEquipments = [];
|
|
||||||
|
|
||||||
export async function fetchEquipments() {
|
|
||||||
try {
|
|
||||||
allEquipments = await api.get('/api/equipments');
|
|
||||||
return allEquipments;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch equipments", e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (!container) return;
|
|
||||||
if (!equipments.length) {
|
|
||||||
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = equipments.map(eq => {
|
|
||||||
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
|
|
||||||
return `
|
|
||||||
<label class="checkbox-item">
|
|
||||||
<input type="checkbox" value="${eq.id}" ${isChecked}>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
|
|
||||||
</label>
|
|
||||||
`}).join('');
|
|
||||||
updateSelectText(containerId, textId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initEquipments() {
|
|
||||||
const equipmentsTbody = document.getElementById('equipments-tbody');
|
|
||||||
const createEquipmentForm = document.getElementById('create-equipment-form');
|
|
||||||
|
|
||||||
async function loadEquipments() {
|
|
||||||
try {
|
|
||||||
const equipments = await fetchEquipments();
|
|
||||||
renderEquipments(equipments);
|
|
||||||
} catch (e) {
|
|
||||||
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEquipments(equipments) {
|
|
||||||
if (!equipments || !equipments.length) {
|
|
||||||
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
equipmentsTbody.innerHTML = equipments.map(eq => `
|
|
||||||
<tr>
|
|
||||||
<td>${eq.id}</td>
|
|
||||||
<td>${escapeHtml(eq.name)}</td>
|
|
||||||
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
createEquipmentForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
hideAlert('create-equipment-alert');
|
|
||||||
const name = document.getElementById('new-equipment-name').value.trim();
|
|
||||||
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.post('/api/equipments', { name });
|
|
||||||
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
|
|
||||||
createEquipmentForm.reset();
|
|
||||||
loadEquipments();
|
|
||||||
} catch (e) {
|
|
||||||
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
equipmentsTbody.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('.btn-delete');
|
|
||||||
if (!btn) return;
|
|
||||||
if (!confirm('Удалить оборудование?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/equipments/' + btn.dataset.id);
|
|
||||||
loadEquipments();
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message || 'Ошибка удаления');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadEquipments();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
import { fetchEducationForms } from './edu-forms.js';
|
|
||||||
|
async function fetchEducationForms() {
|
||||||
|
try {
|
||||||
|
return await api.get('/api/education-forms');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch education forms", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initGroups() {
|
export async function initGroups() {
|
||||||
const groupsTbody = document.getElementById('groups-tbody');
|
const groupsTbody = document.getElementById('groups-tbody');
|
||||||
@@ -10,6 +18,8 @@ export async function initGroups() {
|
|||||||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||||||
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
||||||
const filterEfSelect = document.getElementById('filter-ef');
|
const filterEfSelect = document.getElementById('filter-ef');
|
||||||
|
|
||||||
|
// Модалка группы
|
||||||
const modalEditGroup = document.getElementById('modal-edit-group');
|
const modalEditGroup = document.getElementById('modal-edit-group');
|
||||||
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
|
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
|
||||||
const editGroupForm = document.getElementById('edit-group-form');
|
const editGroupForm = document.getElementById('edit-group-form');
|
||||||
@@ -17,19 +27,30 @@ export async function initGroups() {
|
|||||||
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
|
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
|
||||||
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
|
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
|
||||||
const editGroupProfileSelect = document.getElementById('edit-group-profile');
|
const editGroupProfileSelect = document.getElementById('edit-group-profile');
|
||||||
const calendarForm = document.getElementById('group-calendar-form');
|
|
||||||
const calendarGroupSelect = document.getElementById('calendar-group');
|
// Модалка подгрупп
|
||||||
const calendarYearSelect = document.getElementById('calendar-year');
|
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
|
||||||
const calendarSelect = document.getElementById('calendar-id');
|
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
|
||||||
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody');
|
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
|
||||||
const subgroupForm = document.getElementById('subgroup-form');
|
const subgroupFormTitle = document.getElementById('subgroup-form-title');
|
||||||
const subgroupIdInput = document.getElementById('subgroup-id');
|
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||||||
const subgroupGroupSelect = document.getElementById('subgroup-group');
|
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||||||
const subgroupNameInput = document.getElementById('subgroup-name');
|
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||||||
const subgroupCapacityInput = document.getElementById('subgroup-capacity');
|
const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
|
||||||
const subgroupSubmitButton = document.getElementById('subgroup-submit');
|
const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
|
||||||
const subgroupResetButton = document.getElementById('subgroup-reset');
|
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||||||
const subgroupsTbody = document.getElementById('subgroups-tbody');
|
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||||||
|
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||||||
|
|
||||||
|
// Модалка календарей
|
||||||
|
const modalManageCalendar = document.getElementById('modal-manage-calendar');
|
||||||
|
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
|
||||||
|
const calendarGroupContext = document.getElementById('calendar-group-context');
|
||||||
|
const manageCalendarForm = document.getElementById('manage-calendar-form');
|
||||||
|
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
|
||||||
|
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
|
||||||
|
const manageCalendarSelect = document.getElementById('manage-calendar-id');
|
||||||
|
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
|
||||||
|
|
||||||
let allGroups = [];
|
let allGroups = [];
|
||||||
let subgroups = [];
|
let subgroups = [];
|
||||||
@@ -48,25 +69,35 @@ export async function initGroups() {
|
|||||||
filterEfSelect.addEventListener('change', applyGroupFilter);
|
filterEfSelect.addEventListener('change', applyGroupFilter);
|
||||||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||||||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||||||
calendarGroupSelect.addEventListener('change', () => {
|
|
||||||
populateCalendarSelect();
|
|
||||||
loadAssignments();
|
|
||||||
});
|
|
||||||
calendarYearSelect.addEventListener('change', populateCalendarSelect);
|
|
||||||
createGroupForm.addEventListener('submit', createGroup);
|
createGroupForm.addEventListener('submit', createGroup);
|
||||||
editGroupForm.addEventListener('submit', updateGroup);
|
editGroupForm.addEventListener('submit', updateGroup);
|
||||||
calendarForm.addEventListener('submit', saveAssignment);
|
|
||||||
subgroupForm.addEventListener('submit', saveSubgroup);
|
// Модалка подгрупп
|
||||||
subgroupGroupSelect.addEventListener('change', renderSubgroups);
|
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||||||
subgroupResetButton.addEventListener('click', resetSubgroupForm);
|
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||||||
|
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||||
|
|
||||||
|
// Модалка календарей
|
||||||
|
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||||||
|
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
|
||||||
|
|
||||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||||
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
|
||||||
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick);
|
// Закрытие модалок
|
||||||
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
||||||
modalEditGroup.addEventListener('click', (event) => {
|
modalEditGroup.addEventListener('click', (event) => {
|
||||||
if (event.target === modalEditGroup) {
|
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
|
||||||
modalEditGroup.classList.remove('open');
|
});
|
||||||
}
|
|
||||||
|
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
|
||||||
|
modalManageSubgroups.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
|
||||||
|
modalManageCalendar.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +115,10 @@ export async function initGroups() {
|
|||||||
populateDepartmentSelects(departments);
|
populateDepartmentSelects(departments);
|
||||||
populateSpecialitySelects(specialties);
|
populateSpecialitySelects(specialties);
|
||||||
populateYearSelect();
|
populateYearSelect();
|
||||||
populateCalendarSelect();
|
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
await loadSubgroups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +134,6 @@ export async function initGroups() {
|
|||||||
try {
|
try {
|
||||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||||
applyGroupFilter();
|
applyGroupFilter();
|
||||||
populateGroupSelect();
|
|
||||||
populateSubgroupGroupSelect();
|
|
||||||
await loadAssignments();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
@@ -116,9 +142,8 @@ export async function initGroups() {
|
|||||||
async function loadSubgroups() {
|
async function loadSubgroups() {
|
||||||
try {
|
try {
|
||||||
subgroups = await api.get('/api/subgroups');
|
subgroups = await api.get('/api/subgroups');
|
||||||
renderSubgroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
console.error("Failed to load subgroups", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +163,7 @@ export async function initGroups() {
|
|||||||
groupsTbody.innerHTML = groups.map(group => `
|
groupsTbody.innerHTML = groups.map(group => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${group.id}</td>
|
<td>${group.id}</td>
|
||||||
<td>${escapeHtml(group.name)}</td>
|
<td><strong>${escapeHtml(group.name)}</strong></td>
|
||||||
<td>${escapeHtml(String(group.groupSize))}</td>
|
<td>${escapeHtml(String(group.groupSize))}</td>
|
||||||
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
||||||
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
||||||
@@ -146,11 +171,15 @@ export async function initGroups() {
|
|||||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||||
<td style="text-align: right;">
|
<td class="group-actions-cell">
|
||||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
<div class="group-actions">
|
||||||
${group.status === 'ARCHIVED'
|
<button class="btn-edit-classroom btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
|
||||||
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
<button class="btn-primary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
|
||||||
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
<button class="btn-primary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
|
||||||
|
${group.status === 'ARCHIVED'
|
||||||
|
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
|
||||||
|
: `<button class="btn-delete group-action-btn" data-id="${group.id}">Архив</button>`}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -225,43 +254,18 @@ export async function initGroups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateYearSelect() {
|
function populateYearSelect() {
|
||||||
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||||||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||||
if (!calendarYearSelect.value && academicYears.length) {
|
if (academicYears.length) {
|
||||||
calendarYearSelect.value = String(academicYears[0].id);
|
manageCalendarYearSelect.value = String(academicYears[0].id);
|
||||||
}
|
}
|
||||||
syncSelects(calendarYearSelect);
|
syncSelects(manageCalendarYearSelect);
|
||||||
}
|
|
||||||
|
|
||||||
function populateGroupSelect() {
|
|
||||||
const current = calendarGroupSelect.value;
|
|
||||||
const availableGroups = allGroups.filter(group => group.active);
|
|
||||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
|
||||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
|
||||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
|
||||||
calendarGroupSelect.value = current;
|
|
||||||
} else if (availableGroups.length) {
|
|
||||||
calendarGroupSelect.value = String(availableGroups[0].id);
|
|
||||||
}
|
|
||||||
syncSelects(calendarGroupSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateSubgroupGroupSelect() {
|
|
||||||
const current = subgroupGroupSelect.value;
|
|
||||||
const availableGroups = allGroups.filter(group => group.active);
|
|
||||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
|
||||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
|
||||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
|
||||||
subgroupGroupSelect.value = current;
|
|
||||||
} else if (availableGroups.length) {
|
|
||||||
subgroupGroupSelect.value = String(availableGroups[0].id);
|
|
||||||
}
|
|
||||||
syncSelects(subgroupGroupSelect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateCalendarSelect() {
|
function populateCalendarSelect() {
|
||||||
const group = selectedCalendarGroup();
|
const groupId = manageCalendarGroupIdInput.value;
|
||||||
const yearId = calendarYearSelect.value;
|
const group = allGroups.find(g => g.id == groupId);
|
||||||
|
const yearId = manageCalendarYearSelect.value;
|
||||||
const matching = group && yearId
|
const matching = group && yearId
|
||||||
? calendars.filter(calendar =>
|
? calendars.filter(calendar =>
|
||||||
String(calendar.academicYearId) === String(yearId)
|
String(calendar.academicYearId) === String(yearId)
|
||||||
@@ -270,12 +274,12 @@ export async function initGroups() {
|
|||||||
&& String(calendar.studyFormId) === String(group.educationFormId)
|
&& String(calendar.studyFormId) === String(group.educationFormId)
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
calendarSelect.innerHTML = matching.length
|
manageCalendarSelect.innerHTML = matching.length
|
||||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||||||
).join('')
|
).join('')
|
||||||
: '<option value="">Нет подходящих графиков</option>';
|
: '<option value="">Нет подходящих графиков</option>';
|
||||||
syncSelects(calendarSelect);
|
syncSelects(manageCalendarSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGroup(event) {
|
async function createGroup(event) {
|
||||||
@@ -290,7 +294,6 @@ export async function initGroups() {
|
|||||||
createGroupForm.reset();
|
createGroupForm.reset();
|
||||||
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
||||||
}
|
}
|
||||||
@@ -308,7 +311,6 @@ export async function initGroups() {
|
|||||||
modalEditGroup.classList.remove('open');
|
modalEditGroup.classList.remove('open');
|
||||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
||||||
}
|
}
|
||||||
@@ -348,13 +350,14 @@ export async function initGroups() {
|
|||||||
const btnDelete = event.target.closest('.btn-delete');
|
const btnDelete = event.target.closest('.btn-delete');
|
||||||
const btnEdit = event.target.closest('.btn-edit-group');
|
const btnEdit = event.target.closest('.btn-edit-group');
|
||||||
const btnRestore = event.target.closest('.btn-restore-group');
|
const btnRestore = event.target.closest('.btn-restore-group');
|
||||||
|
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
|
||||||
|
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||||||
|
|
||||||
if (btnDelete) {
|
if (btnDelete) {
|
||||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error.message || 'Ошибка архивирования');
|
alert(error.message || 'Ошибка архивирования');
|
||||||
}
|
}
|
||||||
@@ -364,7 +367,6 @@ export async function initGroups() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
await loadSubgroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error.message || 'Ошибка восстановления');
|
alert(error.message || 'Ошибка восстановления');
|
||||||
}
|
}
|
||||||
@@ -373,6 +375,18 @@ export async function initGroups() {
|
|||||||
if (btnEdit) {
|
if (btnEdit) {
|
||||||
openEditGroupModal(btnEdit.dataset.id);
|
openEditGroupModal(btnEdit.dataset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btnSubgroups) {
|
||||||
|
const groupId = btnSubgroups.dataset.id;
|
||||||
|
const group = allGroups.find(g => g.id == groupId);
|
||||||
|
if (group) openSubgroupsModal(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnCalendar) {
|
||||||
|
const groupId = btnCalendar.dataset.id;
|
||||||
|
const group = allGroups.find(g => g.id == groupId);
|
||||||
|
if (group) openCalendarModal(group);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditGroupModal(id) {
|
function openEditGroupModal(id) {
|
||||||
@@ -395,21 +409,59 @@ export async function initGroups() {
|
|||||||
modalEditGroup.classList.add('open');
|
modalEditGroup.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Управление подгруппами ---
|
||||||
|
function openSubgroupsModal(group) {
|
||||||
|
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||||||
|
manageSubgroupGroupIdInput.value = group.id;
|
||||||
|
resetSubgroupForm();
|
||||||
|
modalManageSubgroups.classList.add('open');
|
||||||
|
renderSubgroupsList(group.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubgroupsList(groupId) {
|
||||||
|
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
|
||||||
|
if (!list.length) {
|
||||||
|
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(sub.name)}</td>
|
||||||
|
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||||||
|
<button class="btn-delete btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSubgroupForm() {
|
||||||
|
manageSubgroupIdInput.value = '';
|
||||||
|
manageSubgroupNameInput.value = '';
|
||||||
|
manageSubgroupCapacityInput.value = '';
|
||||||
|
subgroupFormTitle.textContent = 'Создание подгруппы';
|
||||||
|
btnSaveSubgroup.textContent = 'Создать';
|
||||||
|
btnCancelSubgroupEdit.style.display = 'none';
|
||||||
|
hideAlert('manage-subgroup-alert');
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSubgroup(event) {
|
async function saveSubgroup(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
hideAlert('subgroup-alert');
|
hideAlert('manage-subgroup-alert');
|
||||||
const groupId = subgroupGroupSelect.value;
|
const groupId = manageSubgroupGroupIdInput.value;
|
||||||
const name = subgroupNameInput.value.trim();
|
const subgroupId = manageSubgroupIdInput.value;
|
||||||
const capacity = subgroupCapacityInput.value;
|
const name = manageSubgroupNameInput.value.trim();
|
||||||
const subgroupId = subgroupIdInput.value;
|
const capacity = manageSubgroupCapacityInput.value;
|
||||||
|
|
||||||
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
|
if (!name || !capacity) {
|
||||||
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
|
showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
|
||||||
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
studentCapacity: capacity ? Number(capacity) : null
|
studentCapacity: Number(capacity)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -417,124 +469,74 @@ export async function initGroups() {
|
|||||||
const saved = subgroupId
|
const saved = subgroupId
|
||||||
? await api.put(endpoint, payload)
|
? await api.put(endpoint, payload)
|
||||||
: await api.post(endpoint, payload);
|
: await api.post(endpoint, payload);
|
||||||
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||||
resetSubgroupForm();
|
resetSubgroupForm();
|
||||||
await loadSubgroups();
|
await loadSubgroups();
|
||||||
|
renderSubgroupsList(groupId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubgroups() {
|
|
||||||
const groupId = subgroupGroupSelect.value;
|
|
||||||
const visibleSubgroups = groupId
|
|
||||||
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId))
|
|
||||||
: subgroups;
|
|
||||||
if (!visibleSubgroups.length) {
|
|
||||||
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td>
|
|
||||||
<td>${escapeHtml(subgroup.name)}</td>
|
|
||||||
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button>
|
|
||||||
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubgroupTableClick(event) {
|
async function handleSubgroupTableClick(event) {
|
||||||
const editButton = event.target.closest('.btn-edit-subgroup');
|
const editButton = event.target.closest('.btn-edit-subgroup');
|
||||||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||||||
|
const groupId = manageSubgroupGroupIdInput.value;
|
||||||
|
|
||||||
if (editButton) {
|
if (editButton) {
|
||||||
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id));
|
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
|
||||||
if (!subgroup) return;
|
if (!subgroup) return;
|
||||||
subgroupIdInput.value = subgroup.id;
|
|
||||||
subgroupGroupSelect.value = subgroup.groupId;
|
manageSubgroupIdInput.value = subgroup.id;
|
||||||
subgroupGroupSelect.disabled = true;
|
manageSubgroupNameInput.value = subgroup.name || '';
|
||||||
subgroupNameInput.value = subgroup.name || '';
|
manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||||
subgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
subgroupFormTitle.textContent = 'Редактирование подгруппы';
|
||||||
subgroupSubmitButton.textContent = 'Сохранить подгруппу';
|
btnSaveSubgroup.textContent = 'Сохранить';
|
||||||
syncSelects(subgroupGroupSelect);
|
btnCancelSubgroupEdit.style.display = 'inline-block';
|
||||||
hideAlert('subgroup-alert');
|
hideAlert('manage-subgroup-alert');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteButton) {
|
if (deleteButton) {
|
||||||
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id));
|
if (!confirm('Удалить подгруппу?')) return;
|
||||||
if (!subgroup || !confirm('Удалить подгруппу?')) return;
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`);
|
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||||||
showAlert('subgroup-alert', 'Подгруппа удалена', 'success');
|
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||||||
resetSubgroupForm();
|
resetSubgroupForm();
|
||||||
await loadSubgroups();
|
await loadSubgroups();
|
||||||
|
renderSubgroupsList(groupId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSubgroupForm() {
|
// --- Управление календарными графиками ---
|
||||||
subgroupIdInput.value = '';
|
async function openCalendarModal(group) {
|
||||||
subgroupNameInput.value = '';
|
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
|
||||||
subgroupCapacityInput.value = '';
|
manageCalendarGroupIdInput.value = group.id;
|
||||||
subgroupGroupSelect.disabled = false;
|
hideAlert('manage-calendar-alert');
|
||||||
subgroupSubmitButton.textContent = 'Создать подгруппу';
|
populateCalendarSelect();
|
||||||
syncSelects(subgroupGroupSelect);
|
modalManageCalendar.classList.add('open');
|
||||||
hideAlert('subgroup-alert');
|
await loadAssignmentsList(group.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAssignment(event) {
|
async function loadAssignmentsList(groupId) {
|
||||||
event.preventDefault();
|
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||||
hideAlert('group-calendar-alert');
|
|
||||||
const groupId = calendarGroupSelect.value;
|
|
||||||
const academicYearId = calendarYearSelect.value;
|
|
||||||
const calendarId = calendarSelect.value;
|
|
||||||
|
|
||||||
if (!groupId || !academicYearId || !calendarId) {
|
|
||||||
showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
|
|
||||||
academicYearId: Number(academicYearId),
|
|
||||||
calendarId: Number(calendarId)
|
|
||||||
});
|
|
||||||
showAlert('group-calendar-alert', 'Календарный график назначен', 'success');
|
|
||||||
await loadAssignments();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAssignments() {
|
|
||||||
const groupId = calendarGroupSelect.value;
|
|
||||||
if (!groupId) {
|
|
||||||
currentAssignments = [];
|
|
||||||
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||||||
renderAssignments();
|
renderAssignmentsList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAssignments() {
|
function renderAssignmentsList() {
|
||||||
if (!currentAssignments.length) {
|
if (!currentAssignments.length) {
|
||||||
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
|
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
|
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
||||||
@@ -545,35 +547,53 @@ export async function initGroups() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAssignmentTableClick(event) {
|
async function saveAssignment(event) {
|
||||||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
event.preventDefault();
|
||||||
if (!deleteButton) return;
|
hideAlert('manage-calendar-alert');
|
||||||
if (!confirm('Удалить назначение графика?')) return;
|
const groupId = manageCalendarGroupIdInput.value;
|
||||||
|
const academicYearId = manageCalendarYearSelect.value;
|
||||||
|
const calendarId = manageCalendarSelect.value;
|
||||||
|
|
||||||
|
if (!academicYearId || !calendarId) {
|
||||||
|
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`);
|
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
|
||||||
showAlert('group-calendar-alert', 'Назначение удалено', 'success');
|
academicYearId: Number(academicYearId),
|
||||||
await loadAssignments();
|
calendarId: Number(calendarId)
|
||||||
|
});
|
||||||
|
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
|
||||||
|
await loadAssignmentsList(groupId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedCalendarGroup() {
|
manageCalendarTbody.addEventListener('click', async (event) => {
|
||||||
return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value));
|
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||||
}
|
if (!deleteButton) return;
|
||||||
|
if (!confirm('Удалить назначение графика?')) return;
|
||||||
|
const groupId = manageCalendarGroupIdInput.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
|
||||||
|
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
|
||||||
|
await loadAssignmentsList(groupId);
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Вспомогательные функции ---
|
||||||
function departmentLabel(departmentId) {
|
function departmentLabel(departmentId) {
|
||||||
const department = departments.find(item => item.id == departmentId);
|
const department = departments.find(item => item.id == departmentId);
|
||||||
if (!department) return departmentId || '-';
|
if (!department) return departmentId || '-';
|
||||||
return department.departmentName || department.name || departmentId;
|
return department.departmentName || department.name || departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupName(groupId) {
|
|
||||||
const group = allGroups.find(item => String(item.id) === String(groupId));
|
|
||||||
return group ? group.name : groupId || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function specialityLabel(specialityId) {
|
function specialityLabel(specialityId) {
|
||||||
const speciality = specialties.find(item => item.id == specialityId);
|
const speciality = specialties.find(item => item.id == specialityId);
|
||||||
if (!speciality) return specialityId || '-';
|
if (!speciality) return specialityId || '-';
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { api } from '../api.js';
|
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
|
||||||
|
|
||||||
export async function initProfiles() {
|
|
||||||
const createProfileForm = document.getElementById('create-profile-form');
|
|
||||||
const profileSpecialtySelect = document.getElementById('profile-specialty');
|
|
||||||
const profileNameInput = document.getElementById('profile-name');
|
|
||||||
const profileDescriptionInput = document.getElementById('profile-description');
|
|
||||||
const profilesTbody = document.getElementById('profiles-tbody');
|
|
||||||
const profilesContext = document.getElementById('profiles-context');
|
|
||||||
const refreshButton = document.getElementById('profiles-refresh');
|
|
||||||
const editProfileForm = document.getElementById('edit-profile-form');
|
|
||||||
const editProfileModal = document.getElementById('modal-edit-profile');
|
|
||||||
const editProfileClose = document.getElementById('modal-edit-profile-close');
|
|
||||||
|
|
||||||
let specialties = [];
|
|
||||||
let profiles = [];
|
|
||||||
|
|
||||||
await loadSpecialties();
|
|
||||||
|
|
||||||
profileSpecialtySelect.addEventListener('change', loadProfiles);
|
|
||||||
refreshButton.addEventListener('click', loadProfiles);
|
|
||||||
|
|
||||||
createProfileForm.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
hideAlert('create-profile-alert');
|
|
||||||
|
|
||||||
const specialtyId = profileSpecialtySelect.value;
|
|
||||||
const name = profileNameInput.value.trim();
|
|
||||||
const description = profileDescriptionInput.value.trim();
|
|
||||||
|
|
||||||
if (!specialtyId || !name) {
|
|
||||||
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
|
|
||||||
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
|
|
||||||
createProfileForm.reset();
|
|
||||||
profileSpecialtySelect.value = specialtyId;
|
|
||||||
syncSelect(profileSpecialtySelect);
|
|
||||||
await loadProfiles();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
profilesTbody.addEventListener('click', async (event) => {
|
|
||||||
const editButton = event.target.closest('.btn-edit-profile');
|
|
||||||
const deleteButton = event.target.closest('.btn-delete-profile');
|
|
||||||
|
|
||||||
if (editButton) {
|
|
||||||
const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id));
|
|
||||||
if (!profile) return;
|
|
||||||
|
|
||||||
document.getElementById('edit-profile-id').value = profile.id;
|
|
||||||
document.getElementById('edit-profile-name').value = profile.name || '';
|
|
||||||
document.getElementById('edit-profile-description').value = profile.description || '';
|
|
||||||
hideAlert('edit-profile-alert');
|
|
||||||
editProfileModal.classList.add('open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteButton) {
|
|
||||||
if (!confirm('Удалить профиль обучения?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`);
|
|
||||||
showAlert('create-profile-alert', 'Профиль удалён', 'success');
|
|
||||||
await loadProfiles();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editProfileForm.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
hideAlert('edit-profile-alert');
|
|
||||||
|
|
||||||
const specialtyId = profileSpecialtySelect.value;
|
|
||||||
const id = document.getElementById('edit-profile-id').value;
|
|
||||||
const name = document.getElementById('edit-profile-name').value.trim();
|
|
||||||
const description = document.getElementById('edit-profile-description').value.trim();
|
|
||||||
|
|
||||||
if (!specialtyId || !id || !name) {
|
|
||||||
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
|
|
||||||
editProfileModal.classList.remove('open');
|
|
||||||
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
|
|
||||||
await loadProfiles();
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
|
|
||||||
editProfileModal.addEventListener('click', (event) => {
|
|
||||||
if (event.target === editProfileModal) {
|
|
||||||
editProfileModal.classList.remove('open');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadSpecialties() {
|
|
||||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
|
||||||
try {
|
|
||||||
specialties = await api.get('/api/specialties');
|
|
||||||
renderSpecialtySelect();
|
|
||||||
await loadProfiles();
|
|
||||||
} catch (error) {
|
|
||||||
profileSpecialtySelect.innerHTML = '<option value="">Не удалось загрузить специальности</option>';
|
|
||||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
syncSelect(profileSpecialtySelect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSpecialtySelect() {
|
|
||||||
if (!specialties.length) {
|
|
||||||
profileSpecialtySelect.innerHTML = '<option value="">Сначала создайте специальность</option>';
|
|
||||||
syncSelect(profileSpecialtySelect);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = profileSpecialtySelect.value;
|
|
||||||
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
|
||||||
specialties.map(specialty => {
|
|
||||||
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
|
|
||||||
const name = specialty.specialityName || specialty.name || 'Без названия';
|
|
||||||
return `<option value="${specialty.id}">${escapeHtml(code)} — ${escapeHtml(name)}</option>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
if (current && specialties.some(specialty => String(specialty.id) === String(current))) {
|
|
||||||
profileSpecialtySelect.value = current;
|
|
||||||
} else {
|
|
||||||
profileSpecialtySelect.value = String(specialties[0].id);
|
|
||||||
}
|
|
||||||
syncSelect(profileSpecialtySelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProfiles() {
|
|
||||||
const specialtyId = profileSpecialtySelect.value;
|
|
||||||
const specialty = specialties.find(item => String(item.id) === String(specialtyId));
|
|
||||||
profilesContext.textContent = specialty ? specialtyLabel(specialty) : '';
|
|
||||||
|
|
||||||
if (!specialtyId) {
|
|
||||||
profiles = [];
|
|
||||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
|
||||||
try {
|
|
||||||
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
|
|
||||||
renderProfiles();
|
|
||||||
} catch (error) {
|
|
||||||
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProfiles() {
|
|
||||||
if (!profiles.length) {
|
|
||||||
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
profilesTbody.innerHTML = profiles.map(profile => `
|
|
||||||
<tr>
|
|
||||||
<td>${profile.id}</td>
|
|
||||||
<td>${escapeHtml(profile.name)}</td>
|
|
||||||
<td>${escapeHtml(profile.description || '-')}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
|
|
||||||
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function specialtyLabel(specialty) {
|
|
||||||
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
|
|
||||||
const name = specialty.specialityName || specialty.name || 'Без названия';
|
|
||||||
return `${code} · ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSelect(select) {
|
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -451,9 +451,6 @@ export async function initScheduleView() {
|
|||||||
<th class="room-day-cell">
|
<th class="room-day-cell">
|
||||||
<strong>${escapeHtml(dayShort(day))}</strong>
|
<strong>${escapeHtml(dayShort(day))}</strong>
|
||||||
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
|
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
|
||||||
<div class="room-day-parity-dates">
|
|
||||||
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')}
|
|
||||||
</div>
|
|
||||||
</th>
|
</th>
|
||||||
`).join('');
|
`).join('');
|
||||||
const rows = slots.map(slot => `
|
const rows = slots.map(slot => `
|
||||||
@@ -597,6 +594,40 @@ export async function initScheduleView() {
|
|||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLessonWeeksText(lesson) {
|
||||||
|
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const totalHours = lesson.lessonTypeAcademicHours;
|
||||||
|
const consumedBefore = lesson.consumedLessonTypeAcademicHoursBeforeLesson || 0;
|
||||||
|
|
||||||
|
// Каждое занятие - это 2 часа
|
||||||
|
const totalLessons = Math.ceil(totalHours / 2);
|
||||||
|
if (totalLessons <= 0) return '';
|
||||||
|
|
||||||
|
const parity = lesson.ruleParity || lesson.parity;
|
||||||
|
let startWeek, endWeek;
|
||||||
|
|
||||||
|
if (parity === 'EVEN' || parity === 'ODD') {
|
||||||
|
// Раз в две недели
|
||||||
|
startWeek = lesson.weekNumber - consumedBefore;
|
||||||
|
endWeek = startWeek + (totalLessons - 1) * 2;
|
||||||
|
} else {
|
||||||
|
// Каждую неделю
|
||||||
|
const steps = Math.floor(consumedBefore / 2);
|
||||||
|
startWeek = lesson.weekNumber - steps;
|
||||||
|
endWeek = startWeek + totalLessons - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startWeek <= 0) startWeek = 1;
|
||||||
|
if (endWeek < startWeek) endWeek = startWeek;
|
||||||
|
|
||||||
|
if (startWeek === endWeek) {
|
||||||
|
return `${startWeek} нед.`;
|
||||||
|
}
|
||||||
|
return `с ${startWeek} по ${endWeek} нед.`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderLessonCard(lesson) {
|
function renderLessonCard(lesson) {
|
||||||
const subgroupText = (lesson.subgroupNames || []).join(', ');
|
const subgroupText = (lesson.subgroupNames || []).join(', ');
|
||||||
const meta = [
|
const meta = [
|
||||||
@@ -605,9 +636,15 @@ export async function initScheduleView() {
|
|||||||
PARITY_LABELS[lesson.parity] || lesson.parity
|
PARITY_LABELS[lesson.parity] || lesson.parity
|
||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
const weeksText = getLessonWeeksText(lesson);
|
||||||
|
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="lesson-card schedule-view-lesson-card">
|
<div class="lesson-card schedule-view-lesson-card">
|
||||||
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
|
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
|
||||||
|
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
|
||||||
|
${weeksBadge}
|
||||||
|
</div>
|
||||||
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
||||||
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
|
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
|
||||||
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
||||||
@@ -895,26 +932,15 @@ function parityForDate(date, lessons) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function combinedWeekCaption(block) {
|
function combinedWeekCaption(block) {
|
||||||
return CELL_PARITY_LAYOUT
|
return 'Нечётная и чётная недели';
|
||||||
.map(({ parity, label }) => {
|
|
||||||
const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean);
|
|
||||||
if (!dates.length) return `${label}: дат нет`;
|
|
||||||
return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`;
|
|
||||||
})
|
|
||||||
.join('; ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mobileDayCaption(datesByParity) {
|
function mobileDayCaption(datesByParity) {
|
||||||
return CELL_PARITY_LAYOUT
|
return 'Нечётная / Чётная';
|
||||||
.map(({ parity, label }) => {
|
|
||||||
const date = datesByParity?.[parity];
|
|
||||||
return `${label}: ${date ? formatDate(date) : 'даты нет'}`;
|
|
||||||
})
|
|
||||||
.join(' · ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodCaption(range) {
|
function periodCaption(range) {
|
||||||
return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`;
|
return 'Учебное расписание';
|
||||||
}
|
}
|
||||||
|
|
||||||
function lessonSlotKey(lesson) {
|
function lessonSlotKey(lesson) {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const LESSON_TYPE_LIMITS = [
|
|||||||
startWeekInputId: 'schedule-rule-practice-start-week'
|
startWeekInputId: 'schedule-rule-practice-start-week'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const LESSON_TYPE_ORDER = new Map(LESSON_TYPE_LIMITS.map((config, index) => [config.key, index]));
|
||||||
|
|
||||||
export async function initSchedule() {
|
export async function initSchedule() {
|
||||||
const ruleForm = document.getElementById('schedule-rule-form');
|
const ruleForm = document.getElementById('schedule-rule-form');
|
||||||
@@ -186,6 +187,7 @@ export async function initSchedule() {
|
|||||||
api.get('/api/lesson-types'),
|
api.get('/api/lesson-types'),
|
||||||
api.get('/api/subgroups')
|
api.get('/api/subgroups')
|
||||||
]);
|
]);
|
||||||
|
lessonTypes = sortLessonTypes(lessonTypes);
|
||||||
populateSubjectSelects();
|
populateSubjectSelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,9 +418,7 @@ export async function initSchedule() {
|
|||||||
if (!lessons.length) return '';
|
if (!lessons.length) return '';
|
||||||
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
|
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
|
||||||
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
|
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
|
||||||
const shouldSplit = oddLessons.length > 0
|
const shouldSplit = lessons.some(l => l.parity !== 'BOTH');
|
||||||
&& evenLessons.length > 0
|
|
||||||
&& visualLessonSignature(oddLessons) !== visualLessonSignature(evenLessons);
|
|
||||||
|
|
||||||
if (!shouldSplit) {
|
if (!shouldSplit) {
|
||||||
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
|
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
|
||||||
@@ -1024,6 +1024,15 @@ export async function initSchedule() {
|
|||||||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortLessonTypes(items) {
|
||||||
|
return [...items].sort((first, second) => {
|
||||||
|
const firstOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(first)) ?? LESSON_TYPE_ORDER.size;
|
||||||
|
const secondOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(second)) ?? LESSON_TYPE_ORDER.size;
|
||||||
|
return firstOrder - secondOrder
|
||||||
|
|| naturalCompare(first.name || first.lessonType, second.name || second.lessonType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function lessonTypeKeyById(lessonTypeId) {
|
function lessonTypeKeyById(lessonTypeId) {
|
||||||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||||
@@ -1111,21 +1120,7 @@ export async function initSchedule() {
|
|||||||
function compactWeekRanges(weeks) {
|
function compactWeekRanges(weeks) {
|
||||||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||||||
if (!uniqueWeeks.length) return '';
|
if (!uniqueWeeks.length) return '';
|
||||||
const ranges = [];
|
return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`;
|
||||||
let start = uniqueWeeks[0];
|
|
||||||
let previous = uniqueWeeks[0];
|
|
||||||
for (let index = 1; index < uniqueWeeks.length; index += 1) {
|
|
||||||
const week = uniqueWeeks[index];
|
|
||||||
if (week === previous + 1) {
|
|
||||||
previous = week;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ranges.push(weekRangeText(start, previous));
|
|
||||||
start = week;
|
|
||||||
previous = week;
|
|
||||||
}
|
|
||||||
ranges.push(weekRangeText(start, previous));
|
|
||||||
return `(${ranges.join(', ')})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function weekRangeText(start, end) {
|
function weekRangeText(start, end) {
|
||||||
|
|||||||
@@ -4,109 +4,121 @@ import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
|||||||
export async function initSubjects() {
|
export async function initSubjects() {
|
||||||
const subjectsTbody = document.getElementById('subjects-tbody');
|
const subjectsTbody = document.getElementById('subjects-tbody');
|
||||||
const createSubjectForm = document.getElementById('create-subject-form');
|
const createSubjectForm = document.getElementById('create-subject-form');
|
||||||
|
const newSubjectDeptSelect = document.getElementById('new-subject-department');
|
||||||
|
|
||||||
const assignTeacherForm = document.getElementById('assign-teacher-form');
|
// Модалка управления преподавателями
|
||||||
const assignTeacherSelect = document.getElementById('assign-teacher-select');
|
const modalManageTeachers = document.getElementById('modal-manage-teachers');
|
||||||
const assignSubjectSelect = document.getElementById('assign-subject-select');
|
const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
|
||||||
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody');
|
const subjectTeachersContext = document.getElementById('subject-teachers-context');
|
||||||
|
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
|
||||||
|
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
|
||||||
|
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
|
||||||
|
const manageTsTbody = document.getElementById('manage-ts-tbody');
|
||||||
|
|
||||||
let allSubjects = [];
|
let allSubjects = [];
|
||||||
let allTeachers = [];
|
let allTeachers = [];
|
||||||
|
let allDepartments = [];
|
||||||
|
let teacherSubjects = [];
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
await loadInitialData();
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
createSubjectForm.addEventListener('submit', createSubject);
|
||||||
|
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
|
||||||
|
|
||||||
|
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
|
||||||
|
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
|
||||||
|
|
||||||
|
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
|
||||||
|
modalManageTeachers.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
await Promise.all([loadSubjects(), loadTeachers()]);
|
try {
|
||||||
await loadTeacherSubjects();
|
[allTeachers, allDepartments] = await Promise.all([
|
||||||
|
api.get('/api/users/teachers'),
|
||||||
|
api.get('/api/departments')
|
||||||
|
]);
|
||||||
|
populateDepartmentsSelect();
|
||||||
|
await loadSubjects();
|
||||||
|
} catch (e) {
|
||||||
|
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateDepartmentsSelect() {
|
||||||
|
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||||||
|
allDepartments.map(d => {
|
||||||
|
const name = d.departmentName || d.name || 'Без названия';
|
||||||
|
const code = d.departmentCode || d.code || d.id;
|
||||||
|
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSubjects() {
|
async function loadSubjects() {
|
||||||
|
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
allSubjects = await api.get('/api/subjects');
|
[allSubjects, teacherSubjects] = await Promise.all([
|
||||||
renderSubjects(allSubjects);
|
api.get('/api/subjects'),
|
||||||
populateSubjectSelect(allSubjects);
|
api.get('/api/teacher-subjects')
|
||||||
|
]);
|
||||||
|
renderSubjects();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubjects(subjects) {
|
function renderSubjects() {
|
||||||
if (!subjects || !subjects.length) {
|
if (!allSubjects || !allSubjects.length) {
|
||||||
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>';
|
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subjectsTbody.innerHTML = subjects.map(s => `
|
|
||||||
<tr>
|
// Группируем преподавателей по дисциплинам
|
||||||
<td>${s.id}</td>
|
const teachersBySubject = new Map();
|
||||||
<td>${escapeHtml(s.name)}</td>
|
teacherSubjects.forEach(ts => {
|
||||||
<td>${escapeHtml(s.code || '-')}</td>
|
if (!teachersBySubject.has(ts.subjectId)) {
|
||||||
<td>${s.departmentId || '-'}</td>
|
teachersBySubject.set(ts.subjectId, []);
|
||||||
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
|
}
|
||||||
</tr>`).join('');
|
teachersBySubject.get(ts.subjectId).push(ts);
|
||||||
|
});
|
||||||
|
|
||||||
|
subjectsTbody.innerHTML = allSubjects.map(s => {
|
||||||
|
const teachers = teachersBySubject.get(s.id) || [];
|
||||||
|
const teachersHtml = teachers.length
|
||||||
|
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
|
||||||
|
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${s.id}</td>
|
||||||
|
<td><strong>${escapeHtml(s.name)}</strong></td>
|
||||||
|
<td>${escapeHtml(s.code || '-')}</td>
|
||||||
|
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
|
||||||
|
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
|
||||||
|
<td style="text-align: right; white-space: nowrap;">
|
||||||
|
<button class="btn-primary btn-manage-teachers" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Преподаватели</button>
|
||||||
|
<button class="btn-delete btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSubjectSelect(subjects) {
|
async function createSubject(e) {
|
||||||
if (!assignSubjectSelect) return;
|
|
||||||
const currentVal = assignSubjectSelect.value;
|
|
||||||
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
|
||||||
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
|
||||||
if (currentVal && subjects.find(s => s.id == currentVal)) {
|
|
||||||
assignSubjectSelect.value = currentVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTeachers() {
|
|
||||||
try {
|
|
||||||
allTeachers = await api.get('/api/users/teachers');
|
|
||||||
populateTeacherSelect(allTeachers);
|
|
||||||
} catch (e) {
|
|
||||||
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateTeacherSelect(teachers) {
|
|
||||||
if (!assignTeacherSelect) return;
|
|
||||||
const currentVal = assignTeacherSelect.value;
|
|
||||||
if (!teachers || !teachers.length) {
|
|
||||||
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
|
||||||
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
|
|
||||||
if (currentVal && teachers.find(t => t.id == currentVal)) {
|
|
||||||
assignTeacherSelect.value = currentVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTeacherSubjects() {
|
|
||||||
try {
|
|
||||||
const tsData = await api.get('/api/teacher-subjects');
|
|
||||||
renderTeacherSubjects(tsData);
|
|
||||||
} catch (e) {
|
|
||||||
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTeacherSubjects(tsArray) {
|
|
||||||
if (!tsArray || !tsArray.length) {
|
|
||||||
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(ts.username)}</td>
|
|
||||||
<td>${escapeHtml(ts.subjectName)}</td>
|
|
||||||
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
createSubjectForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-subject-alert');
|
hideAlert('create-subject-alert');
|
||||||
const name = document.getElementById('new-subject-name').value.trim();
|
const name = document.getElementById('new-subject-name').value.trim();
|
||||||
const code = document.getElementById('new-subject-code').value.trim();
|
const code = document.getElementById('new-subject-code').value.trim();
|
||||||
const departmentId = document.getElementById('new-subject-department').value;
|
const departmentId = newSubjectDeptSelect.value;
|
||||||
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
|
|
||||||
if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; }
|
if (!name || !code || !departmentId) {
|
||||||
if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; }
|
showAlert('create-subject-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.post('/api/subjects', {
|
const data = await api.post('/api/subjects', {
|
||||||
@@ -114,50 +126,138 @@ export async function initSubjects() {
|
|||||||
code,
|
code,
|
||||||
departmentId: Number(departmentId)
|
departmentId: Number(departmentId)
|
||||||
});
|
});
|
||||||
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success');
|
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
|
||||||
createSubjectForm.reset();
|
createSubjectForm.reset();
|
||||||
loadSubjects();
|
await loadSubjects();
|
||||||
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
|
} catch (error) {
|
||||||
});
|
showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subjectsTbody.addEventListener('click', async (e) => {
|
async function handleSubjectsTableClick(e) {
|
||||||
const btn = e.target.closest('.btn-delete');
|
const btnDelete = e.target.closest('.btn-delete-subject');
|
||||||
if (!btn) return;
|
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||||||
if (!confirm('Удалить дисциплину?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/subjects/' + btn.dataset.id);
|
|
||||||
loadSubjects();
|
|
||||||
loadTeacherSubjects(); // May have deleted a subject that was assigned
|
|
||||||
} catch (e) { alert(e.message || 'Ошибка удаления'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
assignTeacherForm.addEventListener('submit', async (e) => {
|
if (btnDelete) {
|
||||||
|
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||||||
|
await loadSubjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnTeachers) {
|
||||||
|
const subjectId = btnTeachers.dataset.id;
|
||||||
|
const subject = allSubjects.find(s => s.id == subjectId);
|
||||||
|
if (subject) openTeachersModal(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Управление привязками преподавателей к дисциплине ---
|
||||||
|
function openTeachersModal(subject) {
|
||||||
|
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
|
||||||
|
manageTsSubjectIdInput.value = subject.id;
|
||||||
|
hideAlert('manage-ts-alert');
|
||||||
|
modalManageTeachers.classList.add('open');
|
||||||
|
renderSubjectTeachers(subject.id);
|
||||||
|
populateTeachersSelect(subject.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubjectTeachers(subjectId) {
|
||||||
|
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
|
||||||
|
if (!list.length) {
|
||||||
|
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
manageTsTbody.innerHTML = list.map(ts => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-delete btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTeachersSelect(subjectId) {
|
||||||
|
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
|
||||||
|
const assignedUserIds = new Set(
|
||||||
|
teacherSubjects
|
||||||
|
.filter(ts => String(ts.subjectId) === String(subjectId))
|
||||||
|
.map(ts => String(ts.userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
|
||||||
|
|
||||||
|
if (!availableTeachers.length) {
|
||||||
|
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
|
||||||
|
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTeacherAssignment(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('assign-teacher-alert');
|
hideAlert('manage-ts-alert');
|
||||||
const userId = assignTeacherSelect.value;
|
const subjectId = manageTsSubjectIdInput.value;
|
||||||
const subjectId = assignSubjectSelect.value;
|
const userId = manageTsTeacherSelect.value;
|
||||||
if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; }
|
|
||||||
if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; }
|
if (!userId) {
|
||||||
|
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) });
|
await api.post('/api/teacher-subjects', {
|
||||||
showAlert('assign-teacher-alert', 'Привязка создана', 'success');
|
userId: Number(userId),
|
||||||
loadTeacherSubjects();
|
subjectId: Number(subjectId)
|
||||||
} catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); }
|
});
|
||||||
});
|
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
|
||||||
|
|
||||||
|
// Перезагрузим привязки и обновим интерфейс
|
||||||
|
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||||
|
renderSubjectTeachers(subjectId);
|
||||||
|
populateTeachersSelect(subjectId);
|
||||||
|
|
||||||
|
// Также обновим главную таблицу
|
||||||
|
renderSubjects();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTeacherAssignmentTableClick(e) {
|
||||||
|
const btnDelete = e.target.closest('.btn-delete-ts');
|
||||||
|
if (!btnDelete) return;
|
||||||
|
if (!confirm('Удалить привязку преподавателя?')) return;
|
||||||
|
const subjectId = manageTsSubjectIdInput.value;
|
||||||
|
|
||||||
teacherSubjectsTbody.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('.btn-delete');
|
|
||||||
if (!btn) return;
|
|
||||||
if (!confirm('Удалить привязку?')) return;
|
|
||||||
try {
|
try {
|
||||||
await api.delete('/api/teacher-subjects', {
|
await api.delete('/api/teacher-subjects', {
|
||||||
userId: Number(btn.dataset.userId),
|
userId: Number(btnDelete.dataset.userId),
|
||||||
subjectId: Number(btn.dataset.subjectId)
|
subjectId: Number(btnDelete.dataset.subjectId)
|
||||||
});
|
});
|
||||||
loadTeacherSubjects();
|
|
||||||
} catch (e) { alert(e.message || 'Ошибка удаления'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
loadInitialData();
|
// Перезагрузим привязки
|
||||||
|
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||||
|
renderSubjectTeachers(subjectId);
|
||||||
|
populateTeachersSelect(subjectId);
|
||||||
|
|
||||||
|
// Обновим главную таблицу
|
||||||
|
renderSubjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Ошибка удаления привязки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Вспомогательные функции ---
|
||||||
|
function departmentLabel(departmentId) {
|
||||||
|
const department = allDepartments.find(item => item.id == departmentId);
|
||||||
|
if (!department) return departmentId || '-';
|
||||||
|
return department.departmentName || department.name || departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
549
frontend/admin/js/views/university-structure.js
Normal file
549
frontend/admin/js/views/university-structure.js
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
|
export async function initUniversityStructure() {
|
||||||
|
const deptTbody = document.getElementById('departments-tbody');
|
||||||
|
const specTbody = document.getElementById('specialties-tbody');
|
||||||
|
|
||||||
|
const createDeptForm = document.getElementById('create-department-form');
|
||||||
|
const createSpecForm = document.getElementById('create-specialty-form');
|
||||||
|
const editDeptForm = document.getElementById('edit-department-form');
|
||||||
|
const editSpecForm = document.getElementById('edit-specialty-form');
|
||||||
|
const editDeptModal = document.getElementById('modal-edit-department');
|
||||||
|
const editSpecModal = document.getElementById('modal-edit-specialty');
|
||||||
|
const editDeptClose = document.getElementById('modal-edit-department-close');
|
||||||
|
const editSpecClose = document.getElementById('modal-edit-specialty-close');
|
||||||
|
|
||||||
|
// Элементы вкладок
|
||||||
|
const tabDepartments = document.getElementById('btn-tab-departments');
|
||||||
|
const tabSpecialties = document.getElementById('btn-tab-specialties');
|
||||||
|
const tabProfiles = document.getElementById('btn-tab-profiles');
|
||||||
|
const contentDepartments = document.getElementById('tab-content-departments');
|
||||||
|
const contentSpecialties = document.getElementById('tab-content-specialties');
|
||||||
|
const contentProfiles = document.getElementById('tab-content-profiles');
|
||||||
|
|
||||||
|
// Элементы модалки профилей
|
||||||
|
const profilesModal = document.getElementById('modal-manage-profiles');
|
||||||
|
const profilesModalClose = document.getElementById('modal-manage-profiles-close');
|
||||||
|
const profilesSpecContext = document.getElementById('profiles-spec-context');
|
||||||
|
const profileFormTitle = document.getElementById('profile-form-title');
|
||||||
|
const manageProfileForm = document.getElementById('manage-profile-form');
|
||||||
|
const profileIdInput = document.getElementById('manage-profile-id');
|
||||||
|
const profileSpecIdInput = document.getElementById('manage-profile-spec-id');
|
||||||
|
const profileNameInput = document.getElementById('manage-profile-name');
|
||||||
|
const profileDescInput = document.getElementById('manage-profile-description');
|
||||||
|
const btnSaveProfile = document.getElementById('btn-save-profile');
|
||||||
|
const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit');
|
||||||
|
const profilesTbody = document.getElementById('manage-profiles-tbody');
|
||||||
|
|
||||||
|
// Элементы вкладки Профилей
|
||||||
|
const tabProfileTbody = document.getElementById('profiles-tbody');
|
||||||
|
const createProfileTabForm = document.getElementById('create-profile-tab-form');
|
||||||
|
const tabProfileIdInput = document.getElementById('tab-profile-id');
|
||||||
|
const tabProfileSpecSelect = document.getElementById('tab-profile-spec');
|
||||||
|
const tabProfileNameInput = document.getElementById('tab-profile-name');
|
||||||
|
const tabProfileDescInput = document.getElementById('tab-profile-description');
|
||||||
|
const mainProfileFormTitle = document.getElementById('main-profile-form-title');
|
||||||
|
const btnTabSaveProfile = document.getElementById('btn-tab-save-profile');
|
||||||
|
const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit');
|
||||||
|
|
||||||
|
let departments = [];
|
||||||
|
let specialties = [];
|
||||||
|
let currentProfiles = [];
|
||||||
|
let allProfiles = [];
|
||||||
|
|
||||||
|
// --- Логика переключения табов ---
|
||||||
|
function deactivateTabs() {
|
||||||
|
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => {
|
||||||
|
if (tab) {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
tab.style.borderBottomColor = 'transparent';
|
||||||
|
tab.style.color = 'var(--text-secondary)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
[contentDepartments, contentSpecialties, contentProfiles].forEach(content => {
|
||||||
|
if (content) content.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tabDepartments.addEventListener('click', () => {
|
||||||
|
deactivateTabs();
|
||||||
|
tabDepartments.classList.add('active');
|
||||||
|
tabDepartments.style.borderBottomColor = 'var(--primary)';
|
||||||
|
tabDepartments.style.color = 'var(--text-main)';
|
||||||
|
contentDepartments.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
tabSpecialties.addEventListener('click', () => {
|
||||||
|
deactivateTabs();
|
||||||
|
tabSpecialties.classList.add('active');
|
||||||
|
tabSpecialties.style.borderBottomColor = 'var(--primary)';
|
||||||
|
tabSpecialties.style.color = 'var(--text-main)';
|
||||||
|
contentSpecialties.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
tabProfiles.addEventListener('click', () => {
|
||||||
|
deactivateTabs();
|
||||||
|
tabProfiles.classList.add('active');
|
||||||
|
tabProfiles.style.borderBottomColor = 'var(--primary)';
|
||||||
|
tabProfiles.style.color = 'var(--text-main)';
|
||||||
|
contentProfiles.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Загрузка данных ---
|
||||||
|
async function loadData() {
|
||||||
|
// Загрузка кафедр
|
||||||
|
try {
|
||||||
|
departments = await api.get('/api/departments');
|
||||||
|
renderDepartments();
|
||||||
|
} catch (e) {
|
||||||
|
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка специальностей
|
||||||
|
try {
|
||||||
|
specialties = await api.get('/api/specialties');
|
||||||
|
renderSpecialties();
|
||||||
|
populateSpecialtiesDropdown();
|
||||||
|
} catch (e) {
|
||||||
|
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка всех профилей
|
||||||
|
try {
|
||||||
|
allProfiles = await api.get('/api/specialties/profiles');
|
||||||
|
renderAllProfiles();
|
||||||
|
} catch (e) {
|
||||||
|
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDepartments() {
|
||||||
|
if (!departments || !departments.length) {
|
||||||
|
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deptTbody.innerHTML = departments.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${d.id}</td>
|
||||||
|
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||||
|
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
|
||||||
|
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpecialties() {
|
||||||
|
if (!specialties || !specialties.length) {
|
||||||
|
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
specTbody.innerHTML = specialties.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${s.id}</td>
|
||||||
|
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||||
|
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
||||||
|
<button class="btn-primary btn-manage-profiles" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Профили</button>
|
||||||
|
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Кафедры: CRUD ---
|
||||||
|
createDeptForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('create-dept-alert');
|
||||||
|
const name = document.getElementById('dept-name').value.trim();
|
||||||
|
const code = document.getElementById('dept-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('create-dept-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
|
||||||
|
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
|
||||||
|
createDeptForm.reset();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deptTbody.addEventListener('click', async (e) => {
|
||||||
|
const editBtn = e.target.closest('.btn-edit-department');
|
||||||
|
const deleteBtn = e.target.closest('.btn-delete-department');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
const department = departments.find(item => item.id == editBtn.dataset.id);
|
||||||
|
if (!department) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-dept-id').value = department.id;
|
||||||
|
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
|
||||||
|
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
|
||||||
|
hideAlert('edit-dept-alert');
|
||||||
|
editDeptModal.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
if (!confirm('Удалить кафедру?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/departments/' + deleteBtn.dataset.id);
|
||||||
|
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editDeptForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('edit-dept-alert');
|
||||||
|
const id = document.getElementById('edit-dept-id').value;
|
||||||
|
const name = document.getElementById('edit-dept-name').value.trim();
|
||||||
|
const code = document.getElementById('edit-dept-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
|
||||||
|
editDeptModal.classList.remove('open');
|
||||||
|
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Специальности: CRUD ---
|
||||||
|
createSpecForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('create-spec-alert');
|
||||||
|
const name = document.getElementById('spec-name').value.trim();
|
||||||
|
const code = document.getElementById('spec-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('create-spec-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
|
||||||
|
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
|
||||||
|
createSpecForm.reset();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
specTbody.addEventListener('click', async (e) => {
|
||||||
|
const editBtn = e.target.closest('.btn-edit-specialty');
|
||||||
|
const deleteBtn = e.target.closest('.btn-delete-specialty');
|
||||||
|
const profilesBtn = e.target.closest('.btn-manage-profiles');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
|
||||||
|
if (!speciality) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-spec-id').value = speciality.id;
|
||||||
|
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
|
||||||
|
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
|
||||||
|
hideAlert('edit-spec-alert');
|
||||||
|
editSpecModal.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
if (!confirm('Удалить специальность?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
|
||||||
|
showAlert('create-spec-alert', 'Специальность удалена', 'success');
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profilesBtn) {
|
||||||
|
const specId = profilesBtn.dataset.id;
|
||||||
|
const speciality = specialties.find(item => item.id == specId);
|
||||||
|
if (!speciality) return;
|
||||||
|
|
||||||
|
openProfilesModal(speciality);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editSpecForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('edit-spec-alert');
|
||||||
|
const id = document.getElementById('edit-spec-id').value;
|
||||||
|
const name = document.getElementById('edit-spec-name').value.trim();
|
||||||
|
const code = document.getElementById('edit-spec-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
|
||||||
|
editSpecModal.classList.remove('open');
|
||||||
|
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Профили обучения: Логика ---
|
||||||
|
async function openProfilesModal(speciality) {
|
||||||
|
profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`;
|
||||||
|
profileSpecIdInput.value = speciality.id;
|
||||||
|
resetProfileForm();
|
||||||
|
profilesModal.classList.add('open');
|
||||||
|
await loadProfiles(speciality.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfiles(specId) {
|
||||||
|
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||||||
|
try {
|
||||||
|
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
|
||||||
|
renderProfilesTable();
|
||||||
|
} catch (e) {
|
||||||
|
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfilesTable() {
|
||||||
|
if (!currentProfiles || !currentProfiles.length) {
|
||||||
|
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profilesTbody.innerHTML = currentProfiles.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>${p.id}</td>
|
||||||
|
<td>${escapeHtml(p.name)}</td>
|
||||||
|
<td>${escapeHtml(p.description || '-')}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit-classroom btn-edit-profile" data-id="${p.id}">Изменить</button>
|
||||||
|
<button class="btn-delete btn-delete-profile" data-id="${p.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProfileForm() {
|
||||||
|
manageProfileForm.reset();
|
||||||
|
profileIdInput.value = '';
|
||||||
|
profileFormTitle.textContent = 'Создание профиля';
|
||||||
|
btnSaveProfile.textContent = 'Создать';
|
||||||
|
btnCancelProfileEdit.style.display = 'none';
|
||||||
|
hideAlert('manage-profile-alert');
|
||||||
|
}
|
||||||
|
|
||||||
|
manageProfileForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('manage-profile-alert');
|
||||||
|
|
||||||
|
const specId = profileSpecIdInput.value;
|
||||||
|
const profileId = profileIdInput.value;
|
||||||
|
const name = profileNameInput.value.trim();
|
||||||
|
const description = profileDescInput.value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showAlert('manage-profile-alert', 'Введите название профиля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (profileId) {
|
||||||
|
// Обновление
|
||||||
|
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
|
||||||
|
showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success');
|
||||||
|
} else {
|
||||||
|
// Создание
|
||||||
|
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
|
||||||
|
showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success');
|
||||||
|
}
|
||||||
|
resetProfileForm();
|
||||||
|
await loadProfiles(specId);
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
profilesTbody.addEventListener('click', async (e) => {
|
||||||
|
const editBtn = e.target.closest('.btn-edit-profile');
|
||||||
|
const deleteBtn = e.target.closest('.btn-delete-profile');
|
||||||
|
const specId = profileSpecIdInput.value;
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
const profile = currentProfiles.find(item => item.id == editBtn.dataset.id);
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
profileIdInput.value = profile.id;
|
||||||
|
profileNameInput.value = profile.name || '';
|
||||||
|
profileDescInput.value = profile.description || '';
|
||||||
|
profileFormTitle.textContent = 'Редактирование профиля';
|
||||||
|
btnSaveProfile.textContent = 'Сохранить';
|
||||||
|
btnCancelProfileEdit.style.display = 'inline-block';
|
||||||
|
hideAlert('manage-profile-alert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
if (!confirm('Удалить профиль обучения?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
|
||||||
|
showAlert('manage-profile-alert', 'Профиль удален', 'success');
|
||||||
|
await loadProfiles(specId);
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCancelProfileEdit.addEventListener('click', resetProfileForm);
|
||||||
|
|
||||||
|
// Закрытие модалок
|
||||||
|
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
|
||||||
|
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
|
||||||
|
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
|
||||||
|
|
||||||
|
editDeptModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
|
||||||
|
});
|
||||||
|
editSpecModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
|
||||||
|
});
|
||||||
|
profilesModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === profilesModal) profilesModal.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Дополнительные функции для вкладки Профилей ---
|
||||||
|
function populateSpecialtiesDropdown() {
|
||||||
|
const currentValue = tabProfileSpecSelect.value;
|
||||||
|
tabProfileSpecSelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||||||
|
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
|
||||||
|
tabProfileSpecSelect.value = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllProfiles() {
|
||||||
|
if (!allProfiles || !allProfiles.length) {
|
||||||
|
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabProfileTbody.innerHTML = allProfiles.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>${p.id}</td>
|
||||||
|
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||||
|
<td>${escapeHtml(p.name)}</td>
|
||||||
|
<td>${escapeHtml(p.description || '-')}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit-classroom btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
|
||||||
|
<button class="btn-delete btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTabProfileForm() {
|
||||||
|
createProfileTabForm.reset();
|
||||||
|
tabProfileIdInput.value = '';
|
||||||
|
tabProfileSpecSelect.disabled = false;
|
||||||
|
mainProfileFormTitle.textContent = 'Создание профиля';
|
||||||
|
btnTabSaveProfile.textContent = 'Создать';
|
||||||
|
btnTabCancelProfileEdit.style.display = 'none';
|
||||||
|
hideAlert('tab-profile-alert');
|
||||||
|
tabProfileSpecSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
createProfileTabForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('tab-profile-alert');
|
||||||
|
|
||||||
|
const specId = tabProfileSpecSelect.value;
|
||||||
|
const profileId = tabProfileIdInput.value;
|
||||||
|
const name = tabProfileNameInput.value.trim();
|
||||||
|
const description = tabProfileDescInput.value.trim();
|
||||||
|
|
||||||
|
if (!specId) {
|
||||||
|
showAlert('tab-profile-alert', 'Выберите специальность', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
showAlert('tab-profile-alert', 'Введите название профиля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (profileId) {
|
||||||
|
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
|
||||||
|
showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success');
|
||||||
|
} else {
|
||||||
|
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
|
||||||
|
showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success');
|
||||||
|
}
|
||||||
|
resetTabProfileForm();
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tabProfileTbody.addEventListener('click', async (e) => {
|
||||||
|
const editBtn = e.target.closest('.btn-edit-tab-profile');
|
||||||
|
const deleteBtn = e.target.closest('.btn-delete-tab-profile');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
const profile = allProfiles.find(item => item.id == editBtn.dataset.id);
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
tabProfileIdInput.value = profile.id;
|
||||||
|
tabProfileSpecSelect.value = profile.specialityId;
|
||||||
|
tabProfileSpecSelect.disabled = true;
|
||||||
|
tabProfileSpecSelect.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
tabProfileNameInput.value = profile.name || '';
|
||||||
|
tabProfileDescInput.value = profile.description || '';
|
||||||
|
|
||||||
|
mainProfileFormTitle.textContent = 'Редактирование профиля';
|
||||||
|
btnTabSaveProfile.textContent = 'Сохранить';
|
||||||
|
btnTabCancelProfileEdit.style.display = 'inline-block';
|
||||||
|
hideAlert('tab-profile-alert');
|
||||||
|
|
||||||
|
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
|
||||||
|
if (!profile) return;
|
||||||
|
if (!confirm('Удалить профиль обучения?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
|
||||||
|
showAlert('tab-profile-alert', 'Профиль удален', 'success');
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm);
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
@@ -444,8 +444,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-layer-active {
|
.select-layer-active {
|
||||||
position: relative;
|
position: relative !important;
|
||||||
z-index: 300;
|
z-index: 10001 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.open {
|
||||||
|
z-index: 10002 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-trigger {
|
.custom-select-trigger {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../css/components.css">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -56,6 +57,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Временные слоты</span>
|
<span>Временные слоты</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
<line x1="9" y1="7" x2="17" y2="7" />
|
||||||
|
<line x1="9" y1="11" x2="15" y2="11" />
|
||||||
|
</svg>
|
||||||
|
<span>Формы обучения</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="database">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
|
||||||
|
</svg>
|
||||||
|
<span>База данных</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<a href="/admin/" class="btn-back">
|
<a href="/admin/" class="btn-back">
|
||||||
|
|||||||
@@ -26,11 +26,27 @@ const ROUTES = {
|
|||||||
await module.initTimeSlotsSettings();
|
await module.initTimeSlotsSettings();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
database: {
|
||||||
|
title: 'База данных',
|
||||||
|
file: 'views/database.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/database.js');
|
||||||
|
await module.initDatabase();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'edu-forms': {
|
||||||
|
title: 'Формы обучения',
|
||||||
|
file: 'views/edu-forms.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/edu-forms.js');
|
||||||
|
await module.initEduForms();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_TABS = {
|
const ROLE_TABS = {
|
||||||
ADMIN: ['general', 'time-slots'],
|
ADMIN: ['general', 'time-slots', 'database', 'edu-forms'],
|
||||||
EDUCATION_OFFICE: ['time-slots']
|
EDUCATION_OFFICE: ['time-slots', 'edu-forms']
|
||||||
};
|
};
|
||||||
const allowedTabs = new Set(ROLE_TABS[role] || []);
|
const allowedTabs = new Set(ROLE_TABS[role] || []);
|
||||||
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
|
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
|
||||||
|
|||||||
4
frontend/admin/js/views/database.js → frontend/admin/settings/js/views/database.js
Executable file → Normal file
4
frontend/admin/js/views/database.js → frontend/admin/settings/js/views/database.js
Executable file → Normal file
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../../../js/api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
||||||
|
|
||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
const tenantsTbody = document.getElementById('tenants-tbody');
|
const tenantsTbody = document.getElementById('tenants-tbody');
|
||||||
4
frontend/admin/js/views/edu-forms.js → frontend/admin/settings/js/views/edu-forms.js
Executable file → Normal file
4
frontend/admin/js/views/edu-forms.js → frontend/admin/settings/js/views/edu-forms.js
Executable file → Normal file
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../../../js/api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
|
||||||
|
|
||||||
export let allEducationForms = [];
|
export let allEducationForms = [];
|
||||||
|
|
||||||
0
frontend/admin/views/database.html → frontend/admin/settings/views/database.html
Executable file → Normal file
0
frontend/admin/views/database.html → frontend/admin/settings/views/database.html
Executable file → Normal file
0
frontend/admin/views/edu-forms.html → frontend/admin/settings/views/edu-forms.html
Executable file → Normal file
0
frontend/admin/views/edu-forms.html → frontend/admin/settings/views/edu-forms.html
Executable file → Normal file
@@ -28,7 +28,7 @@
|
|||||||
<button class="btn-primary" id="academic-years-refresh">Обновить</button>
|
<button class="btn-primary" id="academic-years-refresh">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table class="academic-years-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="form-group workload-display-control">
|
<div class="form-group workload-display-control">
|
||||||
<label for="workload-display-select">Отображение</label>
|
<label for="workload-display-select">Отображение</label>
|
||||||
<select id="workload-display-select" class="workload-display-select" data-native-select="true">
|
<select id="workload-display-select" class="workload-display-select">
|
||||||
<option value="all">Все аудитории</option>
|
<option value="all">Все аудитории</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,18 +37,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group workload-overview-filter">
|
<div class="form-group workload-overview-filter">
|
||||||
<label>Вместимость</label>
|
<label for="workload-min-capacity">Мин. вместимость</label>
|
||||||
<div class="custom-multi-select">
|
<input type="number" id="workload-min-capacity" placeholder="0" min="0" style="width: 100px;">
|
||||||
<div class="select-box" id="capacity-box">
|
|
||||||
<span class="select-text" id="capacity-text">Любая вместимость</span>
|
|
||||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-menu" id="capacity-menu">
|
|
||||||
<div id="capacity-checkboxes" class="checkbox-group-vertical"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group workload-overview-filter">
|
<div class="form-group workload-overview-filter">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<!-- ===== Classrooms Tab ===== -->
|
<!-- ===== Classrooms Tab ===== -->
|
||||||
<div class="card create-card">
|
<div class="card create-card">
|
||||||
<h2>Новая аудитория</h2>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||||
|
<h2 style="margin: 0;">Новая аудитория</h2>
|
||||||
|
<button type="button" class="btn-primary" id="btn-manage-equipments">Справочник оборудования</button>
|
||||||
|
</div>
|
||||||
<form id="create-classroom-form">
|
<form id="create-classroom-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -107,3 +110,46 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно управления Оборудованием -->
|
||||||
|
<div class="modal-overlay" id="modal-manage-equipments">
|
||||||
|
<div class="modal-content card" style="max-width: 600px; width: 90%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<h2>Справочник оборудования</h2>
|
||||||
|
<button class="modal-close" id="modal-manage-equipments-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма добавления -->
|
||||||
|
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||||
|
<h3 style="margin-top: 0;">Добавить оборудование</h3>
|
||||||
|
<form id="manage-equipment-form">
|
||||||
|
<div class="form-row" style="align-items: flex-end;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="manage-equipment-name">Название оборудования</label>
|
||||||
|
<input type="text" id="manage-equipment-name" placeholder="Проектор" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="manage-equipment-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица оборудования -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="manage-equipments-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-equipments-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
145
frontend/admin/views/dashboard.html
Normal file
145
frontend/admin/views/dashboard.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!-- ===== Dashboard Page ===== -->
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Метрики (Информационные карточки) -->
|
||||||
|
<div class="dashboard-metrics-grid">
|
||||||
|
<div class="card dashboard-metric-card dashboard-metric-card--groups">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Активные группы</div>
|
||||||
|
<h3 id="metric-groups" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-metric-card dashboard-metric-card--students">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Всего студентов</div>
|
||||||
|
<h3 id="metric-students" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-metric-card dashboard-metric-card--teachers">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Преподаватели</div>
|
||||||
|
<h3 id="metric-teachers" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-metric-card dashboard-metric-card--classrooms">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Загрузка аудиторий сегодня</div>
|
||||||
|
<h3 id="metric-classrooms" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Мониторинг в реальном времени и Быстрый переход -->
|
||||||
|
<div class="dashboard-middle">
|
||||||
|
<!-- Идет сейчас -->
|
||||||
|
<div class="card dashboard-monitor-card">
|
||||||
|
<div class="dashboard-card-header">
|
||||||
|
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
|
||||||
|
<div id="current-time-slot" class="dashboard-time-slot">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap dashboard-monitor-table">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align: left; padding: 0.75rem;">Аудитория</th>
|
||||||
|
<th style="text-align: left; padding: 0.75rem;">Дисциплина</th>
|
||||||
|
<th style="text-align: left; padding: 0.75rem;">Преподаватель</th>
|
||||||
|
<th style="text-align: left; padding: 0.75rem;">Группа</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="current-lessons-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="loading-row" style="padding: 2rem; text-align: center; color: var(--text-secondary);">Определение текущего времени...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Свободные аудитории и Быстрые действия -->
|
||||||
|
<div class="dashboard-side-stack">
|
||||||
|
<!-- Свободные аудитории -->
|
||||||
|
<div class="card dashboard-compact-card">
|
||||||
|
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
|
||||||
|
<div id="free-classrooms-list" class="dashboard-badge-list">
|
||||||
|
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые действия -->
|
||||||
|
<div class="card dashboard-compact-card">
|
||||||
|
<h3 class="dashboard-card-title">Быстрый переход</h3>
|
||||||
|
<div class="dashboard-actions">
|
||||||
|
<a href="#schedule" class="btn-primary dashboard-action-link">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
|
</svg>
|
||||||
|
Конструктор расписания
|
||||||
|
</a>
|
||||||
|
<a href="#schedule-view" class="btn-secondary dashboard-action-link">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
Точечные изменения / Просмотр
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Центр предупреждений и конфликтов (Red Zone) -->
|
||||||
|
<div class="card red-zone-card" style="border-top: 4px solid var(--error, #ef4444);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
Центр предупреждений (Red Zone)
|
||||||
|
</h2>
|
||||||
|
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="btn-recheck-conflicts" style="font-size: 0.85rem; padding: 0.35rem 0.75rem;">Перепроверить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||||
|
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -25,26 +25,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="department-workspace-grid">
|
<div class="department-workspace-grid">
|
||||||
<div class="card create-card">
|
<div class="card create-card department-subject-import-card">
|
||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<h2>Загрузка дисциплин</h2>
|
<h2>Загрузка дисциплин</h2>
|
||||||
</div>
|
</div>
|
||||||
<form id="department-subject-import-form" class="stack-form">
|
<form id="department-subject-import-form" class="stack-form">
|
||||||
<div class="form-group">
|
<div class="subject-import-slots-panel">
|
||||||
<label for="department-subject-import-text">Строки в формате «код; название»</label>
|
<div class="card-header-row">
|
||||||
<textarea id="department-subject-import-text" rows="8" placeholder="DB201; Базы данных AI101; Искусственный интеллект"></textarea>
|
<label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
|
||||||
|
<button type="button" class="btn-edit-classroom" id="subject-import-add">Добавить строку</button>
|
||||||
|
</div>
|
||||||
|
<div id="subject-import-rows" class="subject-import-rows-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary">Загрузить</button>
|
<button type="submit" class="btn-primary">Загрузить</button>
|
||||||
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
|
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card department-teachers-card">
|
||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<h2>Преподаватели и нагрузка</h2>
|
<h2>Преподаватели и нагрузка</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
|
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
|
||||||
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
<!-- ===== Departments and Specialties Tab ===== -->
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Создание кафедры</h2>
|
|
||||||
<form id="create-department-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dept-name">Название кафедры</label>
|
|
||||||
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dept-code">Код кафедры</label>
|
|
||||||
<input type="number" id="dept-code" placeholder="Например: 1" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Создать</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="create-dept-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2>Кафедры</h2>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="departments-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Код</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="departments-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Создание специальности</h2>
|
|
||||||
<form id="create-specialty-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="spec-name">Название специальности</label>
|
|
||||||
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="spec-code">Код специальности</label>
|
|
||||||
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Создать</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="create-spec-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2>Специальности</h2>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="specialties-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Код специальности</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="specialties-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="modal-edit-department">
|
|
||||||
<div class="modal-content card">
|
|
||||||
<h2>Редактирование кафедры</h2>
|
|
||||||
<button class="modal-close" id="modal-edit-department-close">×</button>
|
|
||||||
<form id="edit-department-form">
|
|
||||||
<input type="hidden" id="edit-dept-id">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-dept-name">Название кафедры</label>
|
|
||||||
<input type="text" id="edit-dept-name" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-dept-code">Код кафедры</label>
|
|
||||||
<input type="number" id="edit-dept-code" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="modal-edit-specialty">
|
|
||||||
<div class="modal-content card">
|
|
||||||
<h2>Редактирование специальности</h2>
|
|
||||||
<button class="modal-close" id="modal-edit-specialty-close">×</button>
|
|
||||||
<form id="edit-specialty-form">
|
|
||||||
<input type="hidden" id="edit-spec-id">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-spec-name">Название специальности</label>
|
|
||||||
<input type="text" id="edit-spec-name" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-spec-code">Код специальности</label>
|
|
||||||
<input type="text" id="edit-spec-code" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<!-- ===== Equipments Tab ===== -->
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Новое оборудование</h2>
|
|
||||||
<form id="create-equipment-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="new-equipment-name">Название оборудования</label>
|
|
||||||
<input type="text" id="new-equipment-name" placeholder="Проектор" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Добавить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="create-equipment-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Справочник оборудования</h2>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="equipments-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="equipments-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -80,93 +80,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Подгруппы для лабораторных</h2>
|
|
||||||
<form id="subgroup-form">
|
|
||||||
<input type="hidden" id="subgroup-id">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="subgroup-group">Группа</label>
|
|
||||||
<select id="subgroup-group">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="subgroup-name">Название подгруппы</label>
|
|
||||||
<input type="text" id="subgroup-name" placeholder="Подгруппа 1" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="subgroup-capacity">Численность</label>
|
|
||||||
<input type="number" id="subgroup-capacity" min="0" placeholder="12">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary" id="subgroup-submit">Создать подгруппу</button>
|
|
||||||
<button type="button" class="btn-edit-classroom" id="subgroup-reset">Очистить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="subgroup-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
<div class="table-wrap schedule-inline-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Группа</th>
|
|
||||||
<th>Подгруппа</th>
|
|
||||||
<th>Численность</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="subgroups-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Назначение календарного графика</h2>
|
|
||||||
<form id="group-calendar-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="calendar-group">Группа</label>
|
|
||||||
<select id="calendar-group">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="calendar-year">Учебный год</label>
|
|
||||||
<select id="calendar-year">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="calendar-id">Календарный график</label>
|
|
||||||
<select id="calendar-id">
|
|
||||||
<option value="">Выберите группу и год</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Назначить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="group-calendar-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
<div class="table-wrap schedule-inline-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Учебный год</th>
|
|
||||||
<th>График</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="group-calendar-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="loading-row">Выберите группу</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Модальное окно редактирования группы -->
|
<!-- Модальное окно редактирования группы -->
|
||||||
<div class="modal-overlay" id="modal-edit-group">
|
<div class="modal-overlay" id="modal-edit-group">
|
||||||
<div class="modal-content card">
|
<div class="modal-content card">
|
||||||
@@ -221,3 +134,113 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно управления Подгруппами -->
|
||||||
|
<div class="modal-overlay" id="modal-manage-subgroups">
|
||||||
|
<div class="modal-content card" style="max-width: 700px; width: 90%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h2>Подгруппы группы</h2>
|
||||||
|
<div id="subgroups-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" id="modal-manage-subgroups-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма -->
|
||||||
|
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||||
|
<h3 id="subgroup-form-title" style="margin-top: 0;">Создание подгруппы</h3>
|
||||||
|
<form id="manage-subgroup-form">
|
||||||
|
<input type="hidden" id="manage-subgroup-id">
|
||||||
|
<input type="hidden" id="manage-subgroup-group-id">
|
||||||
|
<div class="form-row" style="align-items: flex-end;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="manage-subgroup-name">Название подгруппы</label>
|
||||||
|
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="manage-subgroup-capacity">Численность (чел.)</label>
|
||||||
|
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
|
||||||
|
<button type="submit" class="btn-primary" id="btn-save-subgroup">Создать</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="manage-subgroups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Подгруппа</th>
|
||||||
|
<th>Численность</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-subgroups-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно назначения Календарного графика -->
|
||||||
|
<div class="modal-overlay" id="modal-manage-calendar">
|
||||||
|
<div class="modal-content card" style="max-width: 800px; width: 90%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h2>Календарные графики группы</h2>
|
||||||
|
<div id="calendar-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" id="modal-manage-calendar-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма -->
|
||||||
|
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||||
|
<h3 style="margin-top: 0;">Назначить график</h3>
|
||||||
|
<form id="manage-calendar-form">
|
||||||
|
<input type="hidden" id="manage-calendar-group-id">
|
||||||
|
<div class="form-row" style="align-items: flex-end;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="manage-calendar-year">Учебный год</label>
|
||||||
|
<select id="manage-calendar-year" required>
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 2; margin-bottom: 0;">
|
||||||
|
<label for="manage-calendar-id">Календарный график</label>
|
||||||
|
<select id="manage-calendar-id" required>
|
||||||
|
<option value="">Выберите учебный год</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="manage-calendar-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="manage-calendar-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Учебный год</th>
|
||||||
|
<th>График</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-calendar-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<div class="card create-card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2>Создание профиля обучения</h2>
|
|
||||||
</div>
|
|
||||||
<form id="create-profile-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="profile-specialty">Специальность</label>
|
|
||||||
<select id="profile-specialty" required>
|
|
||||||
<option value="">Загрузка специальностей...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="profile-name">Название профиля</label>
|
|
||||||
<input type="text" id="profile-name" placeholder="Безопасность автоматизированных систем" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="profile-description">Описание</label>
|
|
||||||
<input type="text" id="profile-description" placeholder="Необязательно">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Создать профиль</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="create-profile-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2>Профили выбранной специальности</h2>
|
|
||||||
<button class="btn-primary" id="profiles-refresh" type="button">Обновить</button>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-context" id="profiles-context"></div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="profiles-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Профиль</th>
|
|
||||||
<th>Описание</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="profiles-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="modal-edit-profile">
|
|
||||||
<div class="modal-content card">
|
|
||||||
<h2>Редактирование профиля</h2>
|
|
||||||
<button class="modal-close" id="modal-edit-profile-close">×</button>
|
|
||||||
<form id="edit-profile-form">
|
|
||||||
<input type="hidden" id="edit-profile-id">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-profile-name">Название профиля</label>
|
|
||||||
<input type="text" id="edit-profile-name" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit-profile-description">Описание</label>
|
|
||||||
<input type="text" id="edit-profile-description">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="schedule-view-control-grid">
|
<div class="schedule-view-control-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-target">Что смотреть</label>
|
<label for="schedule-view-target">Что смотреть</label>
|
||||||
<select id="schedule-view-target" data-native-select="true">
|
<select id="schedule-view-target">
|
||||||
<option value="groups">Группа</option>
|
<option value="groups">Группа</option>
|
||||||
<option value="teachers">Преподаватель</option>
|
<option value="teachers">Преподаватель</option>
|
||||||
<option value="classrooms">Аудитория</option>
|
<option value="classrooms">Аудитория</option>
|
||||||
@@ -21,45 +21,33 @@
|
|||||||
|
|
||||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
|
||||||
<label for="schedule-view-group">Группа</label>
|
<label for="schedule-view-group">Группа</label>
|
||||||
<select id="schedule-view-group" data-native-select="true">
|
<select id="schedule-view-group">
|
||||||
<option value="">Все группы</option>
|
<option value="">Все группы</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
|
||||||
<label for="schedule-view-teacher">Преподаватель</label>
|
<label for="schedule-view-teacher">Преподаватель</label>
|
||||||
<select id="schedule-view-teacher" data-native-select="true">
|
<select id="schedule-view-teacher">
|
||||||
<option value="">Все преподаватели</option>
|
<option value="">Все преподаватели</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
|
||||||
<label for="schedule-view-classroom">Аудитория</label>
|
<label for="schedule-view-classroom">Аудитория</label>
|
||||||
<select id="schedule-view-classroom" data-native-select="true">
|
<select id="schedule-view-classroom">
|
||||||
<option value="">Все аудитории</option>
|
<option value="">Все аудитории</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
|
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
|
||||||
<label for="schedule-view-department">Кафедра</label>
|
<label for="schedule-view-department">Кафедра</label>
|
||||||
<select id="schedule-view-department" data-native-select="true">
|
<select id="schedule-view-department">
|
||||||
<option value="">Все кафедры</option>
|
<option value="">Все кафедры</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<input type="date" id="schedule-view-date" style="display: none;">
|
||||||
<label for="schedule-view-date">Дата в периоде</label>
|
|
||||||
<input type="date" id="schedule-view-date">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group schedule-view-period-control">
|
|
||||||
<span class="form-label">Переход</span>
|
|
||||||
<div class="schedule-view-period-buttons">
|
|
||||||
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период">‹</button>
|
|
||||||
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
|
|
||||||
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="schedule-view-extra-filters">
|
<details class="schedule-view-extra-filters">
|
||||||
@@ -67,19 +55,19 @@
|
|||||||
<div class="schedule-view-extra-grid">
|
<div class="schedule-view-extra-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-subject">Дисциплина</label>
|
<label for="schedule-view-subject">Дисциплина</label>
|
||||||
<select id="schedule-view-subject" data-native-select="true">
|
<select id="schedule-view-subject">
|
||||||
<option value="">Все дисциплины</option>
|
<option value="">Все дисциплины</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-lesson-type">Тип занятия</label>
|
<label for="schedule-view-lesson-type">Тип занятия</label>
|
||||||
<select id="schedule-view-lesson-type" data-native-select="true">
|
<select id="schedule-view-lesson-type">
|
||||||
<option value="">Все типы</option>
|
<option value="">Все типы</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-parity">Неделя</label>
|
<label for="schedule-view-parity">Неделя</label>
|
||||||
<select id="schedule-view-parity" data-native-select="true">
|
<select id="schedule-view-parity">
|
||||||
<option value="">Любая</option>
|
<option value="">Любая</option>
|
||||||
<option value="BOTH">Каждая</option>
|
<option value="BOTH">Каждая</option>
|
||||||
<option value="ODD">Нечётная</option>
|
<option value="ODD">Нечётная</option>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
|
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row schedule-rule-actions">
|
||||||
<button type="submit" class="btn-primary">Сохранить правило</button>
|
<button type="submit" class="btn-primary">Сохранить правило</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>
|
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
|
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new-subject-department">Идентификатор кафедры</label>
|
<label for="new-subject-department">Кафедра</label>
|
||||||
<input type="number" id="new-subject-department" placeholder="ID кафедры" required>
|
<select id="new-subject-department" required>
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary">Добавить</button>
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,28 +23,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card create-card">
|
|
||||||
<h2>Привязка преподавателя к дисциплине</h2>
|
|
||||||
<form id="assign-teacher-form">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="assign-teacher-select">Преподаватель</label>
|
|
||||||
<select id="assign-teacher-select">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="assign-subject-select">Дисциплина</label>
|
|
||||||
<select id="assign-subject-select">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Привязать</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="assign-teacher-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Все дисциплины</h2>
|
<h2>Все дисциплины</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -52,35 +32,64 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Код предмета</th>
|
<th>Код предмета</th>
|
||||||
<th>Кафедра (ID)</th>
|
<th>Кафедра</th>
|
||||||
<th>Действия</th>
|
<th>Преподаватели</th>
|
||||||
|
<th style="text-align: right;">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="subjects-tbody">
|
<tbody id="subjects-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- Модальное окно привязки Преподавателей -->
|
||||||
<h2>Привязки преподавателей</h2>
|
<div class="modal-overlay" id="modal-manage-teachers">
|
||||||
<div class="table-wrap">
|
<div class="modal-content card" style="max-width: 600px; width: 90%;">
|
||||||
<table id="teacher-subjects-table">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
<thead>
|
<div>
|
||||||
<tr>
|
<h2>Преподаватели дисциплины</h2>
|
||||||
<th>Преподаватель</th>
|
<div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||||
<th>Дисциплина</th>
|
</div>
|
||||||
<th>Действия</th>
|
<button class="modal-close" id="modal-manage-teachers-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">×</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody id="teacher-subjects-tbody">
|
<!-- Форма -->
|
||||||
<tr>
|
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
<h3 style="margin-top: 0;">Добавить привязку</h3>
|
||||||
</tr>
|
<form id="manage-teacher-subject-form">
|
||||||
</tbody>
|
<input type="hidden" id="manage-ts-subject-id">
|
||||||
</table>
|
<div class="form-row" style="align-items: flex-end;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="manage-ts-teacher-select">Преподаватель</label>
|
||||||
|
<select id="manage-ts-teacher-select" required>
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="manage-ts-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="manage-ts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Преподаватель</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-ts-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
252
frontend/admin/views/university-structure.html
Normal file
252
frontend/admin/views/university-structure.html
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tabs-header-buttons" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||||
|
<button class="btn-tab active" id="btn-tab-departments" style="background: none; border: none; border-bottom: 2px solid var(--primary); color: var(--text-main); font-weight: 600; padding: 0.5rem 1rem; cursor: pointer;">Кафедры</button>
|
||||||
|
<button class="btn-tab" id="btn-tab-specialties" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Специальности</button>
|
||||||
|
<button class="btn-tab" id="btn-tab-profiles" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Профили</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Раздел Кафедр -->
|
||||||
|
<div id="tab-content-departments" class="tab-content-section">
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Создание кафедры</h2>
|
||||||
|
<form id="create-department-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dept-name">Название кафедры</label>
|
||||||
|
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dept-code">Код кафедры</label>
|
||||||
|
<input type="number" id="dept-code" placeholder="Например: 1" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-dept-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Кафедры</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="departments-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="departments-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Раздел Специальностей -->
|
||||||
|
<div id="tab-content-specialties" class="tab-content-section" style="display: none;">
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Создание специальности</h2>
|
||||||
|
<form id="create-specialty-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spec-name">Название специальности</label>
|
||||||
|
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spec-code">Код специальности</label>
|
||||||
|
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-spec-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Специальности</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="specialties-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Код специальности</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="specialties-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Раздел Профилей -->
|
||||||
|
<div id="tab-content-profiles" class="tab-content-section" style="display: none;">
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2 id="main-profile-form-title">Создание профиля</h2>
|
||||||
|
<form id="create-profile-tab-form">
|
||||||
|
<input type="hidden" id="tab-profile-id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex: 1; min-width: 200px;">
|
||||||
|
<label for="tab-profile-spec">Специальность</label>
|
||||||
|
<select id="tab-profile-spec" required>
|
||||||
|
<option value="">Выберите специальность</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 1.5; min-width: 250px;">
|
||||||
|
<label for="tab-profile-name">Название профиля</label>
|
||||||
|
<input type="text" id="tab-profile-name" placeholder="Например: Искусственный интеллект" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 2; min-width: 250px;">
|
||||||
|
<label for="tab-profile-description">Описание</label>
|
||||||
|
<input type="text" id="tab-profile-description" placeholder="Необязательно">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||||
|
<button type="submit" class="btn-primary" id="btn-tab-save-profile">Создать</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="tab-profile-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Профили обучения</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="profiles-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Специальность</th>
|
||||||
|
<th>Название профиля</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="profiles-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно редактирования кафедры -->
|
||||||
|
<div class="modal-overlay" id="modal-edit-department">
|
||||||
|
<div class="modal-content card">
|
||||||
|
<h2>Редактирование кафедры</h2>
|
||||||
|
<button class="modal-close" id="modal-edit-department-close">×</button>
|
||||||
|
<form id="edit-department-form">
|
||||||
|
<input type="hidden" id="edit-dept-id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-dept-name">Название кафедры</label>
|
||||||
|
<input type="text" id="edit-dept-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-dept-code">Код кафедры</label>
|
||||||
|
<input type="number" id="edit-dept-code" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="edit-dept-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно редактирования специальности -->
|
||||||
|
<div class="modal-overlay" id="modal-edit-specialty">
|
||||||
|
<div class="modal-content card">
|
||||||
|
<h2>Редактирование специальности</h2>
|
||||||
|
<button class="modal-close" id="modal-edit-specialty-close">×</button>
|
||||||
|
<form id="edit-specialty-form">
|
||||||
|
<input type="hidden" id="edit-spec-id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-spec-name">Название специальности</label>
|
||||||
|
<input type="text" id="edit-spec-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-spec-code">Код специальности</label>
|
||||||
|
<input type="text" id="edit-spec-code" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="edit-spec-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно управления Профилями специальности -->
|
||||||
|
<div class="modal-overlay" id="modal-manage-profiles">
|
||||||
|
<div class="modal-content card" style="max-width: 800px; width: 90%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h2>Профили обучения</h2>
|
||||||
|
<div id="profiles-spec-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" id="modal-manage-profiles-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма создания/редактирования профиля -->
|
||||||
|
<div class="card create-card" style="margin-bottom: 1.5rem; background: var(--bg-secondary);">
|
||||||
|
<h3 id="profile-form-title">Создание профиля</h3>
|
||||||
|
<form id="manage-profile-form">
|
||||||
|
<input type="hidden" id="manage-profile-id">
|
||||||
|
<input type="hidden" id="manage-profile-spec-id">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manage-profile-name">Название профиля</label>
|
||||||
|
<input type="text" id="manage-profile-name" placeholder="Например: Искусственный интеллект" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manage-profile-description">Описание</label>
|
||||||
|
<input type="text" id="manage-profile-description" placeholder="Необязательно">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||||
|
<button type="submit" class="btn-primary" id="btn-save-profile">Создать</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="manage-profile-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица профилей -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="manage-profiles-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manage-profiles-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -155,6 +155,8 @@
|
|||||||
setTimeout(() => { window.location.href = redirect; }, 400);
|
setTimeout(() => { window.location.href = redirect; }, 400);
|
||||||
} else {
|
} else {
|
||||||
showAlert(data.message || 'Неверное имя пользователя или пароль', 'error');
|
showAlert(data.message || 'Неверное имя пользователя или пароль', 'error');
|
||||||
|
passwordInput.value = '';
|
||||||
|
passwordInput.focus();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert('Ошибка соединения с сервером', 'error');
|
showAlert('Ошибка соединения с сервером', 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user