обновил дизайн, добавил дашборд
This commit is contained in:
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. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.
|
||||||
@@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,19 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1317,8 +1317,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 {
|
||||||
@@ -1338,9 +1342,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 {
|
||||||
@@ -1416,6 +1422,30 @@ 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;
|
||||||
@@ -1517,7 +1547,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 {
|
||||||
@@ -1530,12 +1561,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2238,4 +2272,16 @@ tbody tr:hover {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,19 @@
|
|||||||
<link rel="stylesheet" href="css/modals.css">
|
<link rel="stylesheet" href="css/modals.css">
|
||||||
<link rel="stylesheet" href="css/departments-data.css">
|
<link rel="stylesheet" href="css/departments-data.css">
|
||||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||||
|
<style>
|
||||||
|
.sidebar.collapsed .sidebar-section-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.sidebar-section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(229, 231, 235, 0.1);
|
||||||
|
margin: 0.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-section-divider {
|
||||||
|
margin: 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -43,72 +56,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 +78,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" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
|
||||||
<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 +90,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 +110,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" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
|
||||||
|
|
||||||
|
<a href="#" class="nav-item" data-tab="classrooms">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
|
</svg>
|
||||||
|
<span>Аудиторный фонд</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="groups">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
<span>Группы обучения</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="university-structure">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||||
|
</svg>
|
||||||
|
<span>Структура вуза</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="subjects">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Дисциплины</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Раздел: Система -->
|
||||||
|
<div class="sidebar-section-divider"></div>
|
||||||
|
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
|
||||||
|
|
||||||
|
<a href="#" class="nav-item" data-tab="users">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
<span>Пользователи</span>
|
||||||
</a>
|
</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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,34 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
if (!equipments || !equipments.length) {
|
||||||
|
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
|
||||||
|
updateSelectText(containerId, selectTextId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = equipments.map(eq => `
|
||||||
|
<label class="checkbox-container">
|
||||||
|
<input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
|
||||||
|
<span>${escapeHtml(eq.name)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
updateSelectText(containerId, selectTextId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function initClassrooms() {
|
export async function initClassrooms() {
|
||||||
const classroomsTbody = document.getElementById('classrooms-tbody');
|
const classroomsTbody = document.getElementById('classrooms-tbody');
|
||||||
@@ -191,5 +219,82 @@ export async function initClassrooms() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Элементы модалки оборудования
|
||||||
|
const btnManageEquipments = document.getElementById('btn-manage-equipments');
|
||||||
|
const modalManageEquipments = document.getElementById('modal-manage-equipments');
|
||||||
|
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
|
||||||
|
const manageEquipmentForm = document.getElementById('manage-equipment-form');
|
||||||
|
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
|
||||||
|
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
|
||||||
|
|
||||||
|
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
|
||||||
|
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
|
||||||
|
modalManageEquipments.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openManageEquipmentsModal() {
|
||||||
|
hideAlert('manage-equipment-alert');
|
||||||
|
manageEquipmentForm.reset();
|
||||||
|
modalManageEquipments.classList.add('open');
|
||||||
|
await loadManageEquipments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManageEquipments() {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||||
|
try {
|
||||||
|
allEquipments = await fetchEquipments();
|
||||||
|
renderManageEquipmentsTable();
|
||||||
|
// Обновим чекбоксы на основной странице
|
||||||
|
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
|
||||||
|
} catch (e) {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManageEquipmentsTable() {
|
||||||
|
if (!allEquipments || !allEquipments.length) {
|
||||||
|
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
|
||||||
|
<tr>
|
||||||
|
<td>${eq.id}</td>
|
||||||
|
<td>${escapeHtml(eq.name)}</td>
|
||||||
|
<td><button class="btn-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);
|
||||||
|
}
|
||||||
@@ -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,13 @@ 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 style="text-align: right; white-space: nowrap;">
|
||||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||||
|
<button class="btn-primary btn-manage-subgroups" data-id="${group.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Подгруппы</button>
|
||||||
|
<button class="btn-primary btn-manage-calendar" data-id="${group.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Календарь</button>
|
||||||
${group.status === 'ARCHIVED'
|
${group.status === 'ARCHIVED'
|
||||||
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||||
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архив</button>`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -225,43 +252,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 +272,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 +292,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 +309,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 +348,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 +365,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 +373,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 +407,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 +467,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 +545,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) {
|
||||||
|
|||||||
@@ -4,160 +4,260 @@ 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', {
|
||||||
name,
|
name,
|
||||||
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 || 'Ошибка удаления'); }
|
// Перезагрузим привязки
|
||||||
});
|
teacherSubjects = await api.get('/api/teacher-subjects');
|
||||||
|
renderSubjectTeachers(subjectId);
|
||||||
|
populateTeachersSelect(subjectId);
|
||||||
|
|
||||||
|
// Обновим главную таблицу
|
||||||
|
renderSubjects();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Ошибка удаления привязки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadInitialData();
|
// --- Вспомогательные функции ---
|
||||||
|
function departmentLabel(departmentId) {
|
||||||
|
const department = allDepartments.find(item => item.id == departmentId);
|
||||||
|
if (!department) return departmentId || '-';
|
||||||
|
return department.departmentName || department.name || departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
export async function initDepartmentsData() {
|
export async function initUniversityStructure() {
|
||||||
const deptTbody = document.getElementById('departments-tbody');
|
const deptTbody = document.getElementById('departments-tbody');
|
||||||
const specTbody = document.getElementById('specialties-tbody');
|
const specTbody = document.getElementById('specialties-tbody');
|
||||||
|
|
||||||
@@ -14,30 +14,75 @@ export async function initDepartmentsData() {
|
|||||||
const editDeptClose = document.getElementById('modal-edit-department-close');
|
const editDeptClose = document.getElementById('modal-edit-department-close');
|
||||||
const editSpecClose = document.getElementById('modal-edit-specialty-close');
|
const editSpecClose = document.getElementById('modal-edit-specialty-close');
|
||||||
|
|
||||||
|
// Элементы вкладок
|
||||||
|
const tabDepartments = document.getElementById('btn-tab-departments');
|
||||||
|
const tabSpecialties = document.getElementById('btn-tab-specialties');
|
||||||
|
const contentDepartments = document.getElementById('tab-content-departments');
|
||||||
|
const contentSpecialties = document.getElementById('tab-content-specialties');
|
||||||
|
|
||||||
|
// Элементы модалки профилей
|
||||||
|
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');
|
||||||
|
|
||||||
let departments = [];
|
let departments = [];
|
||||||
let specialties = [];
|
let specialties = [];
|
||||||
|
let currentProfiles = [];
|
||||||
|
|
||||||
|
// --- Логика переключения табов ---
|
||||||
|
tabDepartments.addEventListener('click', () => {
|
||||||
|
tabDepartments.classList.add('active');
|
||||||
|
tabDepartments.style.borderBottomColor = 'var(--primary)';
|
||||||
|
tabDepartments.style.color = 'var(--text-main)';
|
||||||
|
tabSpecialties.classList.remove('active');
|
||||||
|
tabSpecialties.style.borderBottomColor = 'transparent';
|
||||||
|
tabSpecialties.style.color = 'var(--text-secondary)';
|
||||||
|
contentDepartments.style.display = 'block';
|
||||||
|
contentSpecialties.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
tabSpecialties.addEventListener('click', () => {
|
||||||
|
tabSpecialties.classList.add('active');
|
||||||
|
tabSpecialties.style.borderBottomColor = 'var(--primary)';
|
||||||
|
tabSpecialties.style.color = 'var(--text-main)';
|
||||||
|
tabDepartments.classList.remove('active');
|
||||||
|
tabDepartments.style.borderBottomColor = 'transparent';
|
||||||
|
tabDepartments.style.color = 'var(--text-secondary)';
|
||||||
|
contentSpecialties.style.display = 'block';
|
||||||
|
contentDepartments.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Загрузка данных ---
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
// Load Departments
|
// Загрузка кафедр
|
||||||
try {
|
try {
|
||||||
departments = await api.get('/api/departments');
|
departments = await api.get('/api/departments');
|
||||||
renderDepartments();
|
renderDepartments();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Specialties
|
// Загрузка специальностей
|
||||||
try {
|
try {
|
||||||
specialties = await api.get('/api/specialties');
|
specialties = await api.get('/api/specialties');
|
||||||
renderSpecialties();
|
renderSpecialties();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDepartments() {
|
function renderDepartments() {
|
||||||
if (!departments || !departments.length) {
|
if (!departments || !departments.length) {
|
||||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deptTbody.innerHTML = departments.map(d => `
|
deptTbody.innerHTML = departments.map(d => `
|
||||||
@@ -55,7 +100,7 @@ export async function initDepartmentsData() {
|
|||||||
|
|
||||||
function renderSpecialties() {
|
function renderSpecialties() {
|
||||||
if (!specialties || !specialties.length) {
|
if (!specialties || !specialties.length) {
|
||||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
|
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
specTbody.innerHTML = specialties.map(s => `
|
specTbody.innerHTML = specialties.map(s => `
|
||||||
@@ -65,12 +110,14 @@ export async function initDepartmentsData() {
|
|||||||
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
|
<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>
|
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Кафедры: CRUD ---
|
||||||
createDeptForm.addEventListener('submit', async (e) => {
|
createDeptForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-dept-alert');
|
hideAlert('create-dept-alert');
|
||||||
@@ -120,6 +167,29 @@ export async function initDepartmentsData() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
createSpecForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-spec-alert');
|
hideAlert('create-spec-alert');
|
||||||
@@ -144,6 +214,7 @@ export async function initDepartmentsData() {
|
|||||||
specTbody.addEventListener('click', async (e) => {
|
specTbody.addEventListener('click', async (e) => {
|
||||||
const editBtn = e.target.closest('.btn-edit-specialty');
|
const editBtn = e.target.closest('.btn-edit-specialty');
|
||||||
const deleteBtn = e.target.closest('.btn-delete-specialty');
|
const deleteBtn = e.target.closest('.btn-delete-specialty');
|
||||||
|
const profilesBtn = e.target.closest('.btn-manage-profiles');
|
||||||
|
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
|
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
|
||||||
@@ -167,27 +238,13 @@ export async function initDepartmentsData() {
|
|||||||
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
|
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
editDeptForm.addEventListener('submit', async (e) => {
|
if (profilesBtn) {
|
||||||
e.preventDefault();
|
const specId = profilesBtn.dataset.id;
|
||||||
hideAlert('edit-dept-alert');
|
const speciality = specialties.find(item => item.id == specId);
|
||||||
const id = document.getElementById('edit-dept-id').value;
|
if (!speciality) return;
|
||||||
const name = document.getElementById('edit-dept-name').value.trim();
|
|
||||||
const code = document.getElementById('edit-dept-code').value.trim();
|
|
||||||
|
|
||||||
if (!name || !code) {
|
openProfilesModal(speciality);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,14 +270,130 @@ export async function initDepartmentsData() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Профили обучения: Логика ---
|
||||||
|
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'));
|
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
|
||||||
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
|
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
|
||||||
|
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
|
||||||
|
|
||||||
editDeptModal.addEventListener('click', (e) => {
|
editDeptModal.addEventListener('click', (e) => {
|
||||||
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
|
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
|
||||||
});
|
});
|
||||||
editSpecModal.addEventListener('click', (e) => {
|
editSpecModal.addEventListener('click', (e) => {
|
||||||
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
|
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
profilesModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === profilesModal) profilesModal.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
await loadData();
|
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
2
frontend/admin/views/edu-forms.html → frontend/admin/settings/views/edu-forms.html
Executable file → Normal file
2
frontend/admin/views/edu-forms.html → frontend/admin/settings/views/edu-forms.html
Executable file → Normal file
@@ -31,4 +31,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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">
|
||||||
@@ -106,4 +109,47 @@
|
|||||||
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
|
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
</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>
|
</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" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||||
|
<!-- Метрики (Информационные карточки) -->
|
||||||
|
<div class="metrics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem;">
|
||||||
|
<div class="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid var(--primary);">
|
||||||
|
<div class="metric-icon" style="background: rgba(99, 102, 241, 0.1); color: var(--primary); padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<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="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #10b981;">
|
||||||
|
<div class="metric-icon" style="background: rgba(16, 185, 129, 0.1); color: #10b981; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<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="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #f59e0b;">
|
||||||
|
<div class="metric-icon" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<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="metric-card card" style="display: flex; align-items: center; gap: 1rem; padding: 1.25rem; border-left: 4px solid #3b82f6;">
|
||||||
|
<div class="metric-icon" style="background: rgba(59, 130, 246, 0.1); color: #3b82f6; padding: 0.75rem; border-radius: 12px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<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" style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.25rem; align-items: start;">
|
||||||
|
<!-- Идет сейчас -->
|
||||||
|
<div class="card" style="min-height: 250px; display: flex; flex-direction: column;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
|
||||||
|
<div id="current-time-slot" style="background: var(--bg-secondary); padding: 0.35rem 0.75rem; border-radius: 20px; font-size: 0.85rem; font-weight: 600; color: var(--primary);">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" style="flex: 1;">
|
||||||
|
<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 style="display: flex; flex-direction: column; gap: 1.25rem;">
|
||||||
|
<!-- Свободные аудитории -->
|
||||||
|
<div class="card" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600;">Свободные аудитории (ближайшие)</h3>
|
||||||
|
<div id="free-classrooms-list" style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые действия -->
|
||||||
|
<div class="card" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600;">Быстрый переход</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<a href="#schedule" class="btn-primary" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; text-align: center; font-size: 0.9rem; padding: 0.6rem;">
|
||||||
|
<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" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; text-decoration: none; text-align: center; font-size: 0.9rem; padding: 0.6rem;">
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
197
frontend/admin/views/university-structure.html
Normal file
197
frontend/admin/views/university-structure.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<!-- ===== 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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user