9 Commits

51 changed files with 4539 additions and 1511 deletions

288
analysis_results.md Normal file
View 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. Добавить полноценный Дашборд и Центр предупреждений о конфликтах, что выведет аналитические возможности системы на уровень премиум-класса.

View File

@@ -4,9 +4,11 @@ import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectCommentDto; import com.magistr.app.dto.SubjectCommentDto;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.*; import com.magistr.app.model.*;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
@@ -14,6 +16,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -26,15 +29,18 @@ public class DepartmentWorkspaceController {
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final SubjectCommentRepository subjectCommentRepository; private final SubjectCommentRepository subjectCommentRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
public DepartmentWorkspaceController(SubjectRepository subjectRepository, public DepartmentWorkspaceController(SubjectRepository subjectRepository,
SubjectCommentRepository subjectCommentRepository, SubjectCommentRepository subjectCommentRepository,
UserRepository userRepository, UserRepository userRepository,
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
ScheduleQueryService scheduleQueryService) { ScheduleQueryService scheduleQueryService) {
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.subjectCommentRepository = subjectCommentRepository; this.subjectCommentRepository = subjectCommentRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
this.scheduleQueryService = scheduleQueryService; this.scheduleQueryService = scheduleQueryService;
} }
@@ -109,9 +115,25 @@ public class DepartmentWorkspaceController {
} }
@GetMapping("/teachers") @GetMapping("/teachers")
public List<?> getTeachers(@RequestParam(required = false) Long departmentId) { public List<UserResponse> getTeachers(@RequestParam(required = false) Long departmentId) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId); Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); if (effectiveDepartmentId == null) {
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
.map(this::toUserResponse)
.toList();
}
Map<Long, User> teachersById = new LinkedHashMap<>();
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
.stream()
.map(TeacherDepartmentAssignment::getTeacher)
.filter(User::isActiveRecord)
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
return teachersById.values().stream()
.map(this::toUserResponse)
.toList();
} }
@GetMapping("/schedule") @GetMapping("/schedule")
@@ -137,6 +159,19 @@ public class DepartmentWorkspaceController {
); );
} }
private UserResponse toUserResponse(User user) {
UserResponse response = new UserResponse(
user.getId(),
user.getUsername(),
user.getRole().name(),
user.getFullName(),
user.getJobTitle(),
user.getDepartmentId()
);
response.setStatus(user.getStatus());
return response;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) { private Long effectiveDepartmentId(Long requestedDepartmentId) {
var user = AuthContext.getCurrentUser(); var user = AuthContext.getCurrentUser();
if (user != null && user.role() == Role.DEPARTMENT) { if (user != null && user.role() == Role.DEPARTMENT) {

View File

@@ -193,6 +193,16 @@ public class SpecialityController {
)); ));
} }
@GetMapping("/profiles")
public List<SpecialtyProfileDto> getAllProfiles() {
logger.info("Получен запрос на получение всех профилей обучения");
return specialtyProfileRepository.findAll().stream()
.filter(profile -> profile.getSpeciality().isActiveRecord())
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
.map(this::toProfileDto)
.toList();
}
@GetMapping("/{specialtyId}/profiles") @GetMapping("/{specialtyId}/profiles")
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) { public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
if (!specialtiesRepository.existsById(specialtyId)) { if (!specialtiesRepository.existsById(specialtyId)) {

View File

@@ -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
) { ) {
} }

View File

@@ -406,7 +406,8 @@ public class ScheduleGeneratorService {
"Т", "Т",
lessonTypeAcademicHours, lessonTypeAcademicHours,
consumedBeforeLesson, consumedBeforeLesson,
remainingAfterLesson remainingAfterLesson,
slot.getParity()
); );
} }

View File

@@ -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()
); );
} }
} }

View File

@@ -2,15 +2,23 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser; import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.Subject; import com.magistr.app.model.Subject;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.User;
import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectCommentRepository;
import com.magistr.app.repository.SubjectRepository; import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.ScheduleQueryService; import com.magistr.app.service.ScheduleQueryService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -35,6 +43,7 @@ class DepartmentWorkspaceControllerTest {
subjectRepository, subjectRepository,
mock(SubjectCommentRepository.class), mock(SubjectCommentRepository.class),
mock(UserRepository.class), mock(UserRepository.class),
mock(TeacherDepartmentAssignmentRepository.class),
mock(ScheduleQueryService.class) mock(ScheduleQueryService.class)
); );
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L));
@@ -43,4 +52,48 @@ class DepartmentWorkspaceControllerTest {
assertThat(response.getStatusCode().value()).isEqualTo(404); assertThat(response.getStatusCode().value()).isEqualTo(404);
} }
@Test
void teachersUseAssignmentsAndFallbackWithoutDuplicates() {
UserRepository userRepository = mock(UserRepository.class);
TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class);
User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L);
User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L);
when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now()))
.thenReturn(List.of(assignment(assignedTeacher, 1L)));
when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED))
.thenReturn(List.of(directTeacher, assignedTeacher));
DepartmentWorkspaceController controller = new DepartmentWorkspaceController(
mock(SubjectRepository.class),
mock(SubjectCommentRepository.class),
userRepository,
assignmentRepository,
mock(ScheduleQueryService.class)
);
List<UserResponse> teachers = controller.getTeachers(1L);
assertThat(teachers).extracting(UserResponse::getFullName)
.containsExactly("Петров Препод Петрович", "Препод Тест Тестович");
}
private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) {
Department department = new Department(departmentId, "Кафедра", departmentId);
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
assignment.setTeacher(teacher);
assignment.setDepartment(department);
assignment.setValidFrom(LocalDate.now());
return assignment;
}
private User user(Long id, String username, String fullName, Long departmentId) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setRole(Role.TEACHER);
user.setFullName(fullName);
user.setJobTitle("Доцент");
user.setDepartmentId(departmentId);
return user;
}
} }

View File

@@ -541,6 +541,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
| `GET` | `/api/department/teachers` | Преподаватели кафедры | | `GET` | `/api/department/teachers` | Преподаватели кафедры |
| `GET` | `/api/department/schedule` | Расписание кафедры | | `GET` | `/api/department/schedule` | Расписание кафедры |
`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений.
--- ---
## Кафедры и специальности ## Кафедры и специальности

View File

@@ -138,7 +138,7 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` |
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` | | `database` | Тенанты | `/api/database` |
@@ -146,13 +146,15 @@ frontend/
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. Действия группы выводятся в `group-actions` с фиксированными колонками, чтобы активные и архивные строки не смещали кнопки. Кнопка восстановления архивной группы использует стиль `btn-restore-group`: размеры и поведение как у архивной кнопки, но с жёлтым warning-акцентом.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной компактной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Блоки загрузки дисциплин и преподавателей стоят в равных по высоте колонках; загрузка дисциплин использует фиксированную колонку кода и равномерные отступы до названия, а у карточки преподавателей задан стабильный зазор между заголовком и нагрузкой. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Типы занятий в селекте слотов сортируются в том же порядке, что и блоки часов: лекция, лабораторная, практика. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - В таблице учебных годов семестры показываются компактными отдельными плашками в горизонтальной сетке: осенний и весенний семестры стоят рядом, у каждого есть собственная кнопка редактирования, а название года, период и действия учебного года центрируются относительно строки семестров. После нажатия заголовок формы уточняет, какой семестр открыт. Периоды учебных годов и семестров отображаются в формате `дд-мм-гггг — дд-мм-гггг`.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -229,6 +231,8 @@ export const api = {
- `TEACHER``/teacher/` - `TEACHER``/teacher/`
- `STUDENT``/student/` - `STUDENT``/student/`
При неуспешной авторизации `script.js` оставляет введённый логин, очищает поле пароля и возвращает фокус в него.
### Проверка авторизации ### Проверка авторизации
На каждой странице проверяется наличие токена и роли: На каждой странице проверяется наличие токена и роли:
@@ -308,6 +312,10 @@ export function isAuthenticatedAsAdmin() {
4. **`modals.css`** — Модальные окна 4. **`modals.css`** — Модальные окна
5. **`departments-data.css`** — Стили создания кафедры/специальности 5. **`departments-data.css`** — Стили создания кафедры/специальности
Мультиселекты с чекбоксами используют общий паттерн `.checkbox-item` + `.checkmark` + `.checkbox-label`; вкладка `classrooms` применяет его для выбора оборудования в создании и редактировании аудитории.
Дашборд использует классы `dashboard-container`, `dashboard-metrics-grid`, `dashboard-middle` и `dashboard-side-stack`: средний ряд растягивается по высоте самого высокого столбца, а правые компактные карточки выравниваются одинаковыми строками.
### Темизация ### Темизация
CSS-переменные позволяют поддерживать светлую/тёмную тему: CSS-переменные позволяют поддерживать светлую/тёмную тему:
@@ -337,6 +345,8 @@ CSS-переменные позволяют поддерживать светл
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением - **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода - **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
- **Секционные подписи sidebar** (`Расписание`, `Ресурсы`, `Система`) оформляются в `layout.css`, а их контраст для светлой и тёмной темы задаётся переменными `--sidebar-section-title` и `--sidebar-section-divider` в `main.css`.
--- ---
## OpenTelemetry (`otel.js`) ## OpenTelemetry (`otel.js`)

View File

@@ -576,12 +576,31 @@
gap: 1rem; gap: 1rem;
} }
.schedule-view-control-card {
position: relative;
z-index: 10;
}
.schedule-view-results-card {
position: relative;
z-index: 5;
}
.schedule-view-table-card {
position: relative;
z-index: 1;
}
.schedule-view-control-card, .schedule-view-control-card,
.schedule-view-results-card, .schedule-view-results-card,
.schedule-view-table-card { .schedule-view-table-card {
animation-duration: 0.28s; animation-duration: 0.28s;
} }
.schedule-view-results-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schedule-view-control-header, .schedule-view-control-header,
.schedule-view-results-header, .schedule-view-results-header,
.schedule-view-table-header { .schedule-view-table-header {
@@ -589,6 +608,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.schedule-view-control-header h2, .schedule-view-control-header h2,
@@ -684,10 +704,11 @@
.schedule-view-result-tabs { .schedule-view-result-tabs {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.55rem; gap: 0.55rem;
margin-top: 1rem; margin-top: 0;
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
overflow-x: auto; overflow-x: visible;
} }
.schedule-view-result-tab { .schedule-view-result-tab {

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,21 @@
padding: 0.75rem; padding: 0.75rem;
} }
.sidebar-section-divider {
height: 1px;
background: var(--sidebar-section-divider);
margin: 0.5rem 1.5rem;
}
.sidebar-section-title {
padding: 0.5rem 1.25rem 0.25rem;
color: var(--sidebar-section-title);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -307,6 +322,14 @@
overflow: visible; overflow: visible;
} }
.sidebar.collapsed .sidebar-section-title {
display: none;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem;
}
.sidebar.collapsed .btn-settings { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
padding: 0.65rem 0; padding: 0.65rem 0;

View File

@@ -16,6 +16,8 @@
--bg-input: rgba(255, 255, 255, 0.04); --bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08); --bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06); --bg-hover: rgba(255, 255, 255, 0.06);
--sidebar-section-title: rgba(248, 250, 252, 0.45);
--sidebar-section-divider: rgba(229, 231, 235, 0.1);
/* Typography */ /* Typography */
--text-primary: #f8fafc; --text-primary: #f8fafc;
@@ -48,6 +50,8 @@
--bg-input: rgba(0, 0, 0, 0.03); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.06); --bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.05); --bg-hover: rgba(0, 0, 0, 0.05);
--sidebar-section-title: rgba(51, 65, 85, 0.78);
--sidebar-section-divider: rgba(148, 163, 184, 0.32);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;

View File

@@ -43,72 +43,17 @@
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="users"> <!-- Главная -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <a href="#" class="nav-item" data-tab="dashboard">
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a>
<a href="#" class="nav-item" data-tab="departments-data">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path> <rect x="3" y="3" width="7" height="9" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect> <rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg> </svg>
<span>Создание кафедры/специальности</span> <span>Дашборд</span>
</a>
<a href="#" class="nav-item" data-tab="profiles">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
<path d="M16 11.5 18 14l3-5"></path>
</svg>
<span>Профили обучения</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы</span>
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
<span>Формы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="equipments">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
<span>Оборудование</span>
</a>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудитории</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a> </a>
<a href="#" class="nav-item" data-tab="department-workspace"> <a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -120,14 +65,11 @@
</svg> </svg>
<span>Кабинет кафедры</span> <span>Кабинет кафедры</span>
</a> </a>
<a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <!-- Раздел: Расписание -->
stroke-linecap="round" stroke-linejoin="round"> <div class="sidebar-section-divider"></div>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /> <div class="sidebar-section-title">Расписание</div>
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="schedule"> <a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -135,7 +77,7 @@
<line x1="8" y1="2" x2="8" y2="6"></line> <line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line> <line x1="3" y1="10" x2="21" y2="10"></line>
</svg> </svg>
<span>Расписание занятий</span> <span>Конструктор правил</span>
</a> </a>
<a href="#" class="nav-item" data-tab="academic-calendar"> <a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -155,13 +97,63 @@
</svg> </svg>
<span>Загруженность</span> <span>Загруженность</span>
</a> </a>
<a href="#" class="nav-item" data-tab="database"> <a href="#" class="nav-item" data-tab="schedule-view">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse> stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path> <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path> <circle cx="12" cy="12" r="3" />
</svg> </svg>
<span>База данных</span> <span>Просмотр расписаний</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
<span>Аудиторный фонд</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Группы обучения</span>
</a>
<a href="#" class="nav-item" data-tab="university-structure">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
<span>Структура вуза</span>
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span>Дисциплины</span>
</a>
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Пользователи</span>
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">

View File

@@ -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);
}
}; };

View File

@@ -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) {

View File

@@ -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);
}
});

View File

@@ -217,12 +217,14 @@ export async function initAcademicCalendar() {
} }
academicYearsTbody.innerHTML = academicYears.map(year => ` academicYearsTbody.innerHTML = academicYears.map(year => `
<tr> <tr>
<td>${escapeHtml(year.title)}</td> <td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td> <td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
<td>${renderSemesterList(year.semesters || [])}</td> <td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
<td> <td class="academic-year-actions-cell">
<div class="academic-year-actions">
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button> <button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button> <button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -230,12 +232,19 @@ export async function initAcademicCalendar() {
function renderSemesterList(items) { function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>'; if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => ` return `<div class="academic-year-semesters">${items.map(semester => {
<div class="schedule-semester-line"> const title = semesterTypeTitle(semester);
${escapeHtml(semesterLabel(semester))} const period = formatDisplayDateRange(semester.startDate, semester.endDate);
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button> return `
<div class="academic-year-semester-item">
<div class="academic-year-semester-meta">
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
</div> </div>
`).join(''); <button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
</div>
`;
}).join('')}</div>`;
} }
async function saveAcademicYear(event) { async function saveAcademicYear(event) {
@@ -282,7 +291,7 @@ export async function initAcademicCalendar() {
if (editSemesterButton) { if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return; if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра'; semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
semesterIdInput.value = semester.id; semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || ''; semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
@@ -580,39 +589,56 @@ export async function initAcademicCalendar() {
function renderCourseSemesters(course, courseRows) { function renderCourseSemesters(course, courseRows) {
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
const maxWeekColumns = Math.max(
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
1
);
return semestersForCourse.map(block => ` return semestersForCourse.map(block => `
<section class="calendar-semester-block"> <section class="calendar-semester-block">
<div class="calendar-semester-header"> <div class="calendar-semester-header">
<h4>${block.semesterNumber} семестр</h4> <h4>${block.semesterNumber} семестр</h4>
<span>${escapeHtml(block.caption)}</span> <span>${escapeHtml(block.caption)}</span>
</div> </div>
${renderSemesterTable(block.rows)} ${renderSemesterTable(block.rows, maxWeekColumns)}
</section> </section>
`).join(''); `).join('');
} }
function renderSemesterTable(rows) { function renderSemesterTable(rows, targetWeekColumns = 0) {
if (!rows.length) { if (!rows.length) {
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>'; return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
} }
const rowsByWeek = groupBy(rows, row => row.weekNumber); const rowsByWeek = groupBy(rows, row => row.weekNumber);
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
const weekSlots = [...weeks];
while (weekSlots.length < targetWeekColumns) {
weekSlots.push(null);
}
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return ` return `
<div class="calendar-semester-table-wrap"> <div class="calendar-semester-table-wrap">
<table class="calendar-semester-table"> <table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
</colgroup>
<thead> <thead>
<tr> <tr>
<th class="calendar-day-head">День</th> <th class="calendar-day-head">День</th>
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')} ${weekSlots.map(week => week === null
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${DAY_OPTIONS.map(day => ` ${DAY_OPTIONS.map(day => `
<tr> <tr>
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th> <th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
${weeks.map(week => { ${weekSlots.map(week => {
if (week === null) {
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`); const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>'; return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
}).join('')} }).join('')}
@@ -624,6 +650,10 @@ export async function initAcademicCalendar() {
`; `;
} }
function uniqueWeekNumbers(rows) {
return Array.from(new Set(rows.map(row => row.weekNumber)));
}
function renderCalendarDay(row) { function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = shortDayLabel(row.dayOfWeek); const dayName = shortDayLabel(row.dayOfWeek);
@@ -870,7 +900,7 @@ export async function initAcademicCalendar() {
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
return ` return `
<div class="calendar-day-tooltip-head"> <div class="calendar-day-tooltip-head">
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}">${escapeHtml(code)}</span> <span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
<div> <div>
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong> <strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span> <span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
@@ -1143,7 +1173,13 @@ export async function initAcademicCalendar() {
function semesterLabel(semester) { function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType); const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
}
function semesterTypeTitle(semester) {
const type = normalizeSemesterType(semester.semesterType);
const label = SEMESTER_LABELS[type] || type || 'семестр';
return label.charAt(0).toUpperCase() + label.slice(1);
} }
function normalizeSemesterType(type) { function normalizeSemesterType(type) {
@@ -1174,6 +1210,16 @@ export async function initAcademicCalendar() {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
function formatDisplayDate(value) {
const [year, month, day] = String(value || '').split('-');
if (!year || !month || !day) return String(value || '');
return `${day}-${month}-${year}`;
}
function formatDisplayDateRange(startDate, endDate) {
return `${formatDisplayDate(startDate)}${formatDisplayDate(endDate)}`;
}
function dateRangeCaption(rows) { function dateRangeCaption(rows) {
if (!rows.length) return ''; if (!rows.length) return '';
const dates = rows.map(row => row.date).sort(); const dates = rows.map(row => row.date).sort();

View File

@@ -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))
); );

View File

@@ -1,6 +1,36 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js'; import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
const selected = new Set(selectedIds.map(id => String(id)));
if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
updateSelectText(containerId, selectTextId);
return;
}
container.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(eq.id)}" ${selected.has(String(eq.id)) ? 'checked' : ''}>
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label>
`).join('');
updateSelectText(containerId, selectTextId);
}
export async function initClassrooms() { export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody'); const classroomsTbody = document.getElementById('classrooms-tbody');
@@ -191,5 +221,82 @@ export async function initClassrooms() {
} }
}); });
// Элементы модалки оборудования
const btnManageEquipments = document.getElementById('btn-manage-equipments');
const modalManageEquipments = document.getElementById('modal-manage-equipments');
const modalManageEquipmentsClose = document.getElementById('modal-manage-equipments-close');
const manageEquipmentForm = document.getElementById('manage-equipment-form');
const manageEquipmentNameInput = document.getElementById('manage-equipment-name');
const manageEquipmentTbody = document.getElementById('manage-equipments-tbody');
btnManageEquipments.addEventListener('click', openManageEquipmentsModal);
modalManageEquipmentsClose.addEventListener('click', () => modalManageEquipments.classList.remove('open'));
modalManageEquipments.addEventListener('click', (e) => {
if (e.target === modalManageEquipments) modalManageEquipments.classList.remove('open');
});
async function openManageEquipmentsModal() {
hideAlert('manage-equipment-alert');
manageEquipmentForm.reset();
modalManageEquipments.classList.add('open');
await loadManageEquipments();
}
async function loadManageEquipments() {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
allEquipments = await fetchEquipments();
renderManageEquipmentsTable();
// Обновим чекбоксы на основной странице
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
} catch (e) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderManageEquipmentsTable() {
if (!allEquipments || !allEquipments.length) {
manageEquipmentTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
manageEquipmentTbody.innerHTML = allEquipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr>
`).join('');
}
manageEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-equipment-alert');
const name = manageEquipmentNameInput.value.trim();
if (!name) {
showAlert('manage-equipment-alert', 'Введите название', 'error');
return;
}
try {
const data = await api.post('/api/equipments', { name });
showAlert('manage-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
manageEquipmentForm.reset();
await loadManageEquipments();
} catch (err) {
showAlert('manage-equipment-alert', err.message || 'Ошибка добавления', 'error');
}
});
manageEquipmentTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete-equipment');
if (!btnDelete) return;
if (!confirm('Удалить оборудование из каталога?')) return;
try {
await api.delete('/api/equipments/' + btnDelete.dataset.id);
await loadManageEquipments();
} catch (err) {
alert(err.message || 'Ошибка удаления');
}
});
loadInitialData(); loadInitialData();
} }

View 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);
}

View File

@@ -12,6 +12,8 @@ export async function initDepartmentWorkspace() {
const endInput = document.getElementById('department-workspace-end'); const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh'); const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form'); const importForm = document.getElementById('department-subject-import-form');
const importRowsContainer = document.getElementById('subject-import-rows');
const importAddButton = document.getElementById('subject-import-add');
const subjectsTbody = document.getElementById('department-subjects-tbody'); const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count'); const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card'); const commentsCard = document.getElementById('department-comments-card');
@@ -21,13 +23,26 @@ export async function initDepartmentWorkspace() {
const commentsSubjectInput = document.getElementById('department-comment-subject-id'); const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text'); const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close'); const commentsCloseButton = document.getElementById('department-comments-close');
let lastTeachers = null;
let lastWorkload = null;
let teachersErrorMessage = '';
let workloadErrorMessage = '';
setDefaultDates(startInput, endInput); setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace); refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace); departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', loadWorkload); startInput?.addEventListener('change', handleStartDateChange);
endInput?.addEventListener('change', loadWorkload); endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects); importForm?.addEventListener('submit', importSubjects);
importAddButton?.addEventListener('click', () => renderImportRows([...readImportRows(), { code: '', name: '' }]));
importRowsContainer?.addEventListener('click', (event) => {
const removeBtn = event.target.closest('.subject-import-remove');
if (removeBtn) {
const rows = readImportRows();
rows.splice(Number(removeBtn.dataset.index), 1);
renderImportRows(rows.length ? rows : [{ code: '', name: '' }]);
}
});
commentsForm?.addEventListener('submit', saveComment); commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => { commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true; commentsCard.hidden = true;
@@ -46,6 +61,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); await loadWorkspace();
} catch (error) { } catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error'); showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
@@ -53,6 +69,11 @@ export async function initDepartmentWorkspace() {
async function loadWorkspace() { async function loadWorkspace() {
hideAlert('department-workspace-alert'); hideAlert('department-workspace-alert');
lastTeachers = null;
lastWorkload = null;
teachersErrorMessage = '';
workloadErrorMessage = '';
renderTeachers();
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
} }
@@ -87,38 +108,57 @@ export async function initDepartmentWorkspace() {
} }
} }
function renderImportRows(rows) {
if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}">
<div class="form-group subject-import-code-field">
<label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div>
<div class="form-group subject-import-name-field">
<label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div>
<button type="button" class="btn-delete subject-import-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readImportRows() {
if (!importRowsContainer) return [];
return Array.from(importRowsContainer.querySelectorAll('.subject-import-row')).map(row => ({
code: (row.querySelector('.subject-import-code')?.value || '').trim(),
name: (row.querySelector('.subject-import-name')?.value || '').trim()
}));
}
async function importSubjects(event) { async function importSubjects(event) {
event.preventDefault(); event.preventDefault();
const alertId = 'department-subject-import-alert'; const alertId = 'department-subject-import-alert';
hideAlert(alertId); hideAlert(alertId);
const departmentId = selectedDepartmentId(); const departmentId = selectedDepartmentId();
const rows = document.getElementById('department-subject-import-text').value const rows = readImportRows()
.split('\n') .filter(item => item.name)
.map(row => row.trim()) .map(item => ({
.filter(Boolean) code: item.code,
.map(row => { name: item.name,
const [code, ...nameParts] = row.split(';');
return {
code: (code || '').trim(),
name: nameParts.join(';').trim(),
departmentId: Number(departmentId) departmentId: Number(departmentId)
}; }));
})
.filter(item => item.name);
if (!departmentId) { if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error'); showAlert(alertId, 'Выберите кафедру', 'error');
return; return;
} }
if (!rows.length) { if (!rows.length) {
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error'); showAlert(alertId, 'Заполните название хотя бы одной дисциплины', 'error');
return; return;
} }
try { try {
const result = await api.post('/api/department/subjects/import', rows); const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success'); showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
document.getElementById('department-subject-import-text').value = ''; renderImportRows([{ code: '', name: '' }]);
await loadSubjects(); await loadSubjects();
} catch (error) { } catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error'); showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
@@ -172,25 +212,28 @@ export async function initDepartmentWorkspace() {
} }
async function loadTeachers() { async function loadTeachers() {
const container = document.getElementById('department-teachers-list'); lastTeachers = null;
container.textContent = 'Загрузка...'; teachersErrorMessage = '';
renderTeachers();
try { try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
container.innerHTML = teachers.length lastTeachers = Array.isArray(teachers) ? teachers : [];
? teachers.map(teacher => ` renderTeachers();
<article class="metric-card">
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`; lastTeachers = [];
teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей';
renderTeachers();
} }
} }
async function loadWorkload() { async function loadWorkload() {
const container = document.getElementById('department-workload-list'); if (!startInput.value || !endInput.value) {
lastWorkload = [];
workloadErrorMessage = 'Выберите начало и окончание периода';
renderTeachers();
return;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: startInput.value, startDate: startInput.value,
endDate: endInput.value endDate: endInput.value
@@ -198,23 +241,84 @@ export async function initDepartmentWorkspace() {
if (selectedDepartmentId()) { if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId()); params.set('departmentId', selectedDepartmentId());
} }
container.textContent = 'Загрузка...'; lastWorkload = null;
workloadErrorMessage = '';
renderTeachers();
try { try {
const workload = await api.get(`/api/workload/teachers?${params}`); const workload = await api.get(`/api/workload/teachers?${params}`);
container.innerHTML = workload.length lastWorkload = Array.isArray(workload) ? workload : [];
? workload.map(item => ` renderTeachers();
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
} catch (error) { } catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`; lastWorkload = [];
workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки';
renderTeachers();
} }
} }
async function handleStartDateChange() {
if (startInput.value && endInput.value && startInput.value > endInput.value) {
endInput.value = '';
}
await loadWorkload();
}
function renderTeachers() {
const container = document.getElementById('department-teachers-list');
if (!container) return;
if (lastTeachers === null || lastWorkload === null) {
container.textContent = 'Загрузка...';
return;
}
const teachers = Array.isArray(lastTeachers) ? lastTeachers : [];
const workload = Array.isArray(lastWorkload) ? lastWorkload : [];
const workloadByTeacher = new Map();
const usedWorkloadKeys = new Set();
workload
.filter(item => item && (item.id || item.name))
.forEach(item => {
workloadKeys(item).forEach(key => {
if (!workloadByTeacher.has(key)) {
workloadByTeacher.set(key, item);
}
});
});
const rows = teachers.map(teacher => {
const workloadItem = workloadKeys(teacher)
.map(key => workloadByTeacher.get(key))
.find(Boolean);
if (workloadItem) {
workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key));
}
return renderTeacherMetric({
name: teacher.fullName || teacher.username || '-',
meta: teacher.jobTitle || 'Должность не указана',
workload: workloadItem
}, workloadErrorMessage);
});
workload
.filter(item => item && (item.id || item.name))
.filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key)))
.forEach(item => {
rows.push(renderTeacherMetric({
name: item.name || '-',
meta: 'Есть занятия в расписании кафедры',
workload: item
}, workloadErrorMessage));
});
if (rows.length) {
container.innerHTML = rows.join('');
return;
}
container.innerHTML = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
function selectedDepartmentId() { function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
} }
@@ -261,3 +365,36 @@ function subjectCountLabel(count) {
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`; return `${count} дисциплин`;
} }
function renderTeacherMetric(item, workloadErrorMessage) {
return `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
function workloadLabel(item, workloadErrorMessage) {
if (workloadErrorMessage) {
return workloadErrorMessage;
}
if (!item) {
return '0 ак. ч., 0 занятий за период';
}
const hours = String(item.academicHours ?? 0);
const lessons = String(item.lessonCount ?? 0);
return `${hours} ак. ч., ${lessons} занятий за период`;
}
function workloadKeys(item) {
return [
item.id == null ? null : `id:${item.id}`,
normalizeTeacherName(item.fullName || item.name || item.username)
].filter(Boolean);
}
function normalizeTeacherName(value) {
return value ? `name:${String(value).trim().toLowerCase()}` : null;
}

View File

@@ -1,226 +0,0 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDepartmentsData() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
let departments = [];
let specialties = [];
async function loadData() {
// Load Departments
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
// Load Specialties
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
await loadData();
}

View File

@@ -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();
}

View File

@@ -1,6 +1,14 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { fetchEducationForms } from './edu-forms.js';
async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error("Failed to fetch education forms", e);
return [];
}
}
export async function initGroups() { export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody'); const groupsTbody = document.getElementById('groups-tbody');
@@ -10,6 +18,8 @@ export async function initGroups() {
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
const newGroupProfileSelect = document.getElementById('new-group-profile'); const newGroupProfileSelect = document.getElementById('new-group-profile');
const filterEfSelect = document.getElementById('filter-ef'); const filterEfSelect = document.getElementById('filter-ef');
// Модалка группы
const modalEditGroup = document.getElementById('modal-edit-group'); const modalEditGroup = document.getElementById('modal-edit-group');
const modalEditGroupClose = document.getElementById('modal-edit-group-close'); const modalEditGroupClose = document.getElementById('modal-edit-group-close');
const editGroupForm = document.getElementById('edit-group-form'); const editGroupForm = document.getElementById('edit-group-form');
@@ -17,19 +27,30 @@ export async function initGroups() {
const editGroupDepartmentSelect = document.getElementById('edit-group-department'); const editGroupDepartmentSelect = document.getElementById('edit-group-department');
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
const editGroupProfileSelect = document.getElementById('edit-group-profile'); const editGroupProfileSelect = document.getElementById('edit-group-profile');
const calendarForm = document.getElementById('group-calendar-form');
const calendarGroupSelect = document.getElementById('calendar-group'); // Модалка подгрупп
const calendarYearSelect = document.getElementById('calendar-year'); const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
const calendarSelect = document.getElementById('calendar-id'); const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); const subgroupsGroupContext = document.getElementById('subgroups-group-context');
const subgroupForm = document.getElementById('subgroup-form'); const subgroupFormTitle = document.getElementById('subgroup-form-title');
const subgroupIdInput = document.getElementById('subgroup-id'); const manageSubgroupForm = document.getElementById('manage-subgroup-form');
const subgroupGroupSelect = document.getElementById('subgroup-group'); const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
const subgroupNameInput = document.getElementById('subgroup-name'); const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
const subgroupCapacityInput = document.getElementById('subgroup-capacity'); const manageSubgroupNameInput = document.getElementById('manage-subgroup-name');
const subgroupSubmitButton = document.getElementById('subgroup-submit'); const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity');
const subgroupResetButton = document.getElementById('subgroup-reset'); const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
const subgroupsTbody = document.getElementById('subgroups-tbody'); const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
// Модалка календарей
const modalManageCalendar = document.getElementById('modal-manage-calendar');
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
const calendarGroupContext = document.getElementById('calendar-group-context');
const manageCalendarForm = document.getElementById('manage-calendar-form');
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
const manageCalendarSelect = document.getElementById('manage-calendar-id');
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
let allGroups = []; let allGroups = [];
let subgroups = []; let subgroups = [];
@@ -48,25 +69,35 @@ export async function initGroups() {
filterEfSelect.addEventListener('change', applyGroupFilter); filterEfSelect.addEventListener('change', applyGroupFilter);
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
calendarGroupSelect.addEventListener('change', () => {
populateCalendarSelect();
loadAssignments();
});
calendarYearSelect.addEventListener('change', populateCalendarSelect);
createGroupForm.addEventListener('submit', createGroup); createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup); editGroupForm.addEventListener('submit', updateGroup);
calendarForm.addEventListener('submit', saveAssignment);
subgroupForm.addEventListener('submit', saveSubgroup); // Модалка подгрупп
subgroupGroupSelect.addEventListener('change', renderSubgroups); manageSubgroupForm.addEventListener('submit', saveSubgroup);
subgroupResetButton.addEventListener('click', resetSubgroupForm); btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
// Модалка календарей
manageCalendarForm.addEventListener('submit', saveAssignment);
manageCalendarYearSelect.addEventListener('change', populateCalendarSelect);
groupsTbody.addEventListener('click', handleGroupTableClick); groupsTbody.addEventListener('click', handleGroupTableClick);
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); // Закрытие модалок
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => { modalEditGroup.addEventListener('click', (event) => {
if (event.target === modalEditGroup) { if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
modalEditGroup.classList.remove('open'); });
}
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
modalManageSubgroups.addEventListener('click', (event) => {
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
});
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
modalManageCalendar.addEventListener('click', (event) => {
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
}); });
} }
@@ -84,12 +115,10 @@ export async function initGroups() {
populateDepartmentSelects(departments); populateDepartmentSelects(departments);
populateSpecialitySelects(specialties); populateSpecialitySelects(specialties);
populateYearSelect(); populateYearSelect();
populateCalendarSelect();
await loadGroups(); await loadGroups();
await loadSubgroups(); await loadSubgroups();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -105,9 +134,6 @@ export async function initGroups() {
try { try {
allGroups = await api.get('/api/groups?includeArchived=true'); allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter(); applyGroupFilter();
populateGroupSelect();
populateSubgroupGroupSelect();
await loadAssignments();
} catch (error) { } catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
} }
@@ -116,9 +142,8 @@ export async function initGroups() {
async function loadSubgroups() { async function loadSubgroups() {
try { try {
subgroups = await api.get('/api/subgroups'); subgroups = await api.get('/api/subgroups');
renderSubgroups();
} catch (error) { } catch (error) {
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; console.error("Failed to load subgroups", error);
} }
} }
@@ -138,7 +163,7 @@ export async function initGroups() {
groupsTbody.innerHTML = groups.map(group => ` groupsTbody.innerHTML = groups.map(group => `
<tr> <tr>
<td>${group.id}</td> <td>${group.id}</td>
<td>${escapeHtml(group.name)}</td> <td><strong>${escapeHtml(group.name)}</strong></td>
<td>${escapeHtml(String(group.groupSize))}</td> <td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td> <td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td> <td>${escapeHtml(departmentLabel(group.departmentId))}</td>
@@ -146,11 +171,15 @@ export async function initGroups() {
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td> <td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td> <td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td> <td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td style="text-align: right;"> <td class="group-actions-cell">
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button> <div class="group-actions">
<button class="btn-edit-classroom btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
<button class="btn-primary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn-primary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</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 group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`} : `<button class="btn-delete group-action-btn" data-id="${group.id}">Архив</button>`}
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -225,43 +254,18 @@ export async function initGroups() {
} }
function populateYearSelect() { function populateYearSelect() {
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' + manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join(''); academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (!calendarYearSelect.value && academicYears.length) { if (academicYears.length) {
calendarYearSelect.value = String(academicYears[0].id); manageCalendarYearSelect.value = String(academicYears[0].id);
} }
syncSelects(calendarYearSelect); syncSelects(manageCalendarYearSelect);
}
function populateGroupSelect() {
const current = calendarGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && availableGroups.some(group => String(group.id) === String(current))) {
calendarGroupSelect.value = current;
} else if (availableGroups.length) {
calendarGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(calendarGroupSelect);
}
function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && availableGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current;
} else if (availableGroups.length) {
subgroupGroupSelect.value = String(availableGroups[0].id);
}
syncSelects(subgroupGroupSelect);
} }
function populateCalendarSelect() { function populateCalendarSelect() {
const group = selectedCalendarGroup(); const groupId = manageCalendarGroupIdInput.value;
const yearId = calendarYearSelect.value; const group = allGroups.find(g => g.id == groupId);
const yearId = manageCalendarYearSelect.value;
const matching = group && yearId const matching = group && yearId
? calendars.filter(calendar => ? calendars.filter(calendar =>
String(calendar.academicYearId) === String(yearId) String(calendar.academicYearId) === String(yearId)
@@ -270,12 +274,12 @@ export async function initGroups() {
&& String(calendar.studyFormId) === String(group.educationFormId) && String(calendar.studyFormId) === String(group.educationFormId)
) )
: []; : [];
calendarSelect.innerHTML = matching.length manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar => ? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('') ).join('')
: '<option value="">Нет подходящих графиков</option>'; : '<option value="">Нет подходящих графиков</option>';
syncSelects(calendarSelect); syncSelects(manageCalendarSelect);
} }
async function createGroup(event) { async function createGroup(event) {
@@ -290,7 +294,6 @@ export async function initGroups() {
createGroupForm.reset(); createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
} }
@@ -308,7 +311,6 @@ export async function initGroups() {
modalEditGroup.classList.remove('open'); modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
} }
@@ -348,13 +350,14 @@ export async function initGroups() {
const btnDelete = event.target.closest('.btn-delete'); const btnDelete = event.target.closest('.btn-delete');
const btnEdit = event.target.closest('.btn-edit-group'); const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group'); const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
const btnCalendar = event.target.closest('.btn-manage-calendar');
if (btnDelete) { if (btnDelete) {
if (!confirm('Архивировать группу? История расписания сохранится.')) return; if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try { try {
await api.delete('/api/groups/' + btnDelete.dataset.id); await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка архивирования'); alert(error.message || 'Ошибка архивирования');
} }
@@ -364,7 +367,6 @@ export async function initGroups() {
try { try {
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {}); await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка восстановления'); alert(error.message || 'Ошибка восстановления');
} }
@@ -373,6 +375,18 @@ export async function initGroups() {
if (btnEdit) { if (btnEdit) {
openEditGroupModal(btnEdit.dataset.id); openEditGroupModal(btnEdit.dataset.id);
} }
if (btnSubgroups) {
const groupId = btnSubgroups.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openSubgroupsModal(group);
}
if (btnCalendar) {
const groupId = btnCalendar.dataset.id;
const group = allGroups.find(g => g.id == groupId);
if (group) openCalendarModal(group);
}
} }
function openEditGroupModal(id) { function openEditGroupModal(id) {
@@ -395,21 +409,59 @@ export async function initGroups() {
modalEditGroup.classList.add('open'); modalEditGroup.classList.add('open');
} }
// --- Управление подгруппами ---
function openSubgroupsModal(group) {
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
manageSubgroupGroupIdInput.value = group.id;
resetSubgroupForm();
modalManageSubgroups.classList.add('open');
renderSubgroupsList(group.id);
}
function renderSubgroupsList(groupId) {
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
if (!list.length) {
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
manageSubgroupsTbody.innerHTML = list.map(sub => `
<tr>
<td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetSubgroupForm() {
manageSubgroupIdInput.value = '';
manageSubgroupNameInput.value = '';
manageSubgroupCapacityInput.value = '';
subgroupFormTitle.textContent = 'Создание подгруппы';
btnSaveSubgroup.textContent = 'Создать';
btnCancelSubgroupEdit.style.display = 'none';
hideAlert('manage-subgroup-alert');
}
async function saveSubgroup(event) { async function saveSubgroup(event) {
event.preventDefault(); event.preventDefault();
hideAlert('subgroup-alert'); hideAlert('manage-subgroup-alert');
const groupId = subgroupGroupSelect.value; const groupId = manageSubgroupGroupIdInput.value;
const name = subgroupNameInput.value.trim(); const subgroupId = manageSubgroupIdInput.value;
const capacity = subgroupCapacityInput.value; const name = manageSubgroupNameInput.value.trim();
const subgroupId = subgroupIdInput.value; const capacity = manageSubgroupCapacityInput.value;
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; } if (!name || !capacity) {
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; } showAlert('manage-subgroup-alert', 'Заполните все поля', 'error');
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; } return;
}
const payload = { const payload = {
name, name,
studentCapacity: capacity ? Number(capacity) : null studentCapacity: Number(capacity)
}; };
try { try {
@@ -417,124 +469,74 @@ export async function initGroups() {
const saved = subgroupId const saved = subgroupId
? await api.put(endpoint, payload) ? await api.put(endpoint, payload)
: await api.post(endpoint, payload); : await api.post(endpoint, payload);
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success'); showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm(); resetSubgroupForm();
await loadSubgroups(); await loadSubgroups();
renderSubgroupsList(groupId);
} catch (error) { } catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error'); showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
} }
} }
function renderSubgroups() {
const groupId = subgroupGroupSelect.value;
const visibleSubgroups = groupId
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId))
: subgroups;
if (!visibleSubgroups.length) {
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => `
<tr>
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td>
<td>${escapeHtml(subgroup.name)}</td>
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleSubgroupTableClick(event) { async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup'); const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup'); const deleteButton = event.target.closest('.btn-delete-subgroup');
const groupId = manageSubgroupGroupIdInput.value;
if (editButton) { if (editButton) {
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id)); const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
if (!subgroup) return; if (!subgroup) return;
subgroupIdInput.value = subgroup.id;
subgroupGroupSelect.value = subgroup.groupId; manageSubgroupIdInput.value = subgroup.id;
subgroupGroupSelect.disabled = true; manageSubgroupNameInput.value = subgroup.name || '';
subgroupNameInput.value = subgroup.name || ''; manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? '';
subgroupCapacityInput.value = subgroup.studentCapacity ?? ''; subgroupFormTitle.textContent = 'Редактирование подгруппы';
subgroupSubmitButton.textContent = 'Сохранить подгруппу'; btnSaveSubgroup.textContent = 'Сохранить';
syncSelects(subgroupGroupSelect); btnCancelSubgroupEdit.style.display = 'inline-block';
hideAlert('subgroup-alert'); hideAlert('manage-subgroup-alert');
return; return;
} }
if (deleteButton) { if (deleteButton) {
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id)); if (!confirm('Удалить подгруппу?')) return;
if (!subgroup || !confirm('Удалить подгруппу?')) return;
try { try {
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`); await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
showAlert('subgroup-alert', 'Подгруппа удалена', 'success'); showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm(); resetSubgroupForm();
await loadSubgroups(); await loadSubgroups();
renderSubgroupsList(groupId);
} catch (error) { } catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error'); showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
} }
} }
} }
function resetSubgroupForm() { // --- Управление календарными графиками ---
subgroupIdInput.value = ''; async function openCalendarModal(group) {
subgroupNameInput.value = ''; calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
subgroupCapacityInput.value = ''; manageCalendarGroupIdInput.value = group.id;
subgroupGroupSelect.disabled = false; hideAlert('manage-calendar-alert');
subgroupSubmitButton.textContent = 'Создать подгруппу'; populateCalendarSelect();
syncSelects(subgroupGroupSelect); modalManageCalendar.classList.add('open');
hideAlert('subgroup-alert'); await loadAssignmentsList(group.id);
}
async function saveAssignment(event) {
event.preventDefault();
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;
} }
async function loadAssignmentsList(groupId) {
manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try { try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignments(); renderAssignmentsList();
} catch (error) { } catch (error) {
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; manageCalendarTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
function renderAssignments() { function renderAssignmentsList() {
if (!currentAssignments.length) { if (!currentAssignments.length) {
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>'; manageCalendarTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Календарные графики не назначены</td></tr>';
return; return;
} }
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => ` manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr> <tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td> <td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td> <td>${escapeHtml(assignment.calendarTitle)}</td>
@@ -545,35 +547,53 @@ export async function initGroups() {
`).join(''); `).join('');
} }
async function handleAssignmentTableClick(event) { async function saveAssignment(event) {
event.preventDefault();
hideAlert('manage-calendar-alert');
const groupId = manageCalendarGroupIdInput.value;
const academicYearId = manageCalendarYearSelect.value;
const calendarId = manageCalendarSelect.value;
if (!academicYearId || !calendarId) {
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
return;
}
try {
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
academicYearId: Number(academicYearId),
calendarId: Number(calendarId)
});
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
await loadAssignmentsList(groupId);
} catch (error) {
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
}
}
manageCalendarTbody.addEventListener('click', async (event) => {
const deleteButton = event.target.closest('.btn-delete-assignment'); const deleteButton = event.target.closest('.btn-delete-assignment');
if (!deleteButton) return; if (!deleteButton) return;
if (!confirm('Удалить назначение графика?')) return; if (!confirm('Удалить назначение графика?')) return;
const groupId = manageCalendarGroupIdInput.value;
try { try {
await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
showAlert('group-calendar-alert', 'Назначение удалено', 'success'); showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
await loadAssignments(); await loadAssignmentsList(groupId);
} catch (error) { } catch (error) {
showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
}
} }
});
function selectedCalendarGroup() {
return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value));
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) { 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 || '-';

View File

@@ -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 }));
}
}

View File

@@ -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) {

View File

@@ -17,12 +17,18 @@ const PARITY_LABELS = {
EVEN: 'чётная' EVEN: 'чётная'
}; };
const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' }
];
const SEMESTER_LABELS = { const SEMESTER_LABELS = {
autumn: 'осенний', autumn: 'осенний',
spring: 'весенний' spring: 'весенний'
}; };
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;
const LESSON_TYPE_LIMITS = [ const LESSON_TYPE_LIMITS = [
{ {
@@ -50,6 +56,7 @@ const LESSON_TYPE_LIMITS = [
startWeekInputId: 'schedule-rule-practice-start-week' startWeekInputId: 'schedule-rule-practice-start-week'
} }
]; ];
const LESSON_TYPE_ORDER = new Map(LESSON_TYPE_LIMITS.map((config, index) => [config.key, index]));
export async function initSchedule() { export async function initSchedule() {
const ruleForm = document.getElementById('schedule-rule-form'); const ruleForm = document.getElementById('schedule-rule-form');
@@ -70,6 +77,15 @@ export async function initSchedule() {
const slotAddButton = document.getElementById('schedule-slot-add'); const slotAddButton = document.getElementById('schedule-slot-add');
const rulesTbody = document.getElementById('schedule-tbody'); const rulesTbody = document.getElementById('schedule-tbody');
const refreshRulesButton = document.getElementById('schedule-refresh'); const refreshRulesButton = document.getElementById('schedule-refresh');
const visualToggleButton = document.getElementById('schedule-visual-toggle');
const visualCloseButton = document.getElementById('schedule-visual-close');
const visualDrawer = document.getElementById('schedule-visual-drawer');
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
const visualStage = document.getElementById('schedule-visual-stage');
const visualSummary = document.getElementById('schedule-visual-summary');
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
let rules = []; let rules = [];
let subjects = []; let subjects = [];
@@ -80,6 +96,8 @@ export async function initSchedule() {
let subgroups = []; let subgroups = [];
let timeSlots = []; let timeSlots = [];
let semesters = []; let semesters = [];
let selectedVisualGroupIds = new Set();
let visualGroupFilterTouched = false;
bindEvents(); bindEvents();
@@ -96,6 +114,23 @@ export async function initSchedule() {
refreshRulesButton?.addEventListener('click', loadRules); refreshRulesButton?.addEventListener('click', loadRules);
ruleResetButton?.addEventListener('click', resetRuleForm); ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualUseFormButton?.addEventListener('click', () => {
visualGroupFilterTouched = false;
selectedVisualGroupIds = new Set();
renderVisualSchedule();
});
visualSelectAllButton?.addEventListener('click', () => {
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
visualGroupFilterTouched = true;
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer();
});
ruleSlotsContainer.addEventListener('click', (event) => { ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove'); const removeButton = event.target.closest('.schedule-slot-remove');
@@ -152,6 +187,7 @@ export async function initSchedule() {
api.get('/api/lesson-types'), api.get('/api/lesson-types'),
api.get('/api/subgroups') api.get('/api/subgroups')
]); ]);
lessonTypes = sortLessonTypes(lessonTypes);
populateSubjectSelects(); populateSubjectSelects();
} }
@@ -166,14 +202,17 @@ export async function initSchedule() {
try { try {
rules = await api.get('/api/admin/schedule-rules'); rules = await api.get('/api/admin/schedule-rules');
renderRules(); renderRules();
renderVisualSchedule();
} catch (error) { } catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания');
} }
} }
function renderRules() { function renderRules() {
if (!rules.length) { if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
renderVisualSchedule();
return; return;
} }
rulesTbody.innerHTML = rules.map(rule => ` rulesTbody.innerHTML = rules.map(rule => `
@@ -192,6 +231,324 @@ export async function initSchedule() {
`).join(''); `).join('');
} }
function toggleVisualDrawer() {
if (!visualDrawer) return;
if (visualDrawer.classList.contains('open')) {
closeVisualDrawer();
} else {
openVisualDrawer();
}
}
function openVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.add('open');
visualDrawer.setAttribute('aria-hidden', 'false');
visualToggleButton?.classList.add('open');
visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = false;
}
function closeVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open');
visualToggleButton?.setAttribute('aria-expanded', 'false');
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = true;
}
function renderVisualSchedule() {
if (!visualStage || !visualSummary) return;
if (!rules.length) {
visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return;
}
const allGroups = collectVisualGroups();
syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules();
if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий');
return;
}
if (!columns.length) {
visualSummary.textContent = 'Выберите группы для просмотра';
setVisualState('Отметьте одну или несколько групп в фильтре');
return;
}
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) {
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
return;
}
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = `
<div class="schedule-visual-table-wrap">
<table class="schedule-visual-table">
<thead>
<tr>
<th class="schedule-visual-day-head">День</th>
<th class="schedule-visual-time-head">Время</th>
${columns.map(group => `<th>${escapeHtml(group.name)}</th>`).join('')}
</tr>
</thead>
<tbody>${rows.map(renderVisualRow).join('')}</tbody>
</table>
</div>
`;
}
function buildVisualRows(columns, slots, lessonsByCell) {
const rows = [];
DAY_OPTIONS.forEach(day => {
const dayRows = slots
.map(slot => ({
day,
slot,
cells: columns.map(group => ({
group,
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
}))
}))
.filter(row => row.cells.some(cell => cell.lessons.length));
dayRows.forEach((row, index) => {
rows.push({
...row,
dayRowspan: index === 0 ? dayRows.length : 0
});
});
});
return rows;
}
function renderVisualRow(row) {
return `
<tr>
${row.dayRowspan ? `<td class="schedule-visual-day-cell" rowspan="${row.dayRowspan}">${escapeHtml(row.day.label)}</td>` : ''}
<td class="schedule-visual-time-cell">
<strong>${escapeHtml(row.slot.orderLabel)}</strong>
<span>${escapeHtml(row.slot.timeLabel)}</span>
</td>
${row.cells.map(cell => {
const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
return `<td class="schedule-visual-lesson-cell${emptyClass}">${renderVisualCellContent(cell.lessons)}</td>`;
}).join('')}
</tr>
`;
}
function buildVisualLessonsByCell(allowedGroupIds) {
const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot();
rules.forEach(rule => {
(rule.slots || []).forEach((slot, index) => {
const slotKey = visualRuleSlotKey(slot, index);
(rule.groupIds || []).forEach((groupId, groupIndex) => {
if (!allowedGroupIds.has(String(groupId))) return;
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
if (!weeks.length) return;
const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`,
parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '',
classroomName: slot.classroomName || '',
lessonTypeName: slot.lessonTypeName || '',
lessonFormat: slot.lessonFormat || '',
subgroupNames: slotSubgroupNames(slot),
weekText: compactWeekRanges(weeks),
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
};
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
});
grouped.forEach(items => items.sort(compareVisualLessons));
return grouped;
}
function buildActiveWeeksByRuleSlot() {
const activeWeeks = new Map();
rules.forEach(rule => {
const maxWeeks = maxWeeksForRule(rule);
(rule.groupIds || []).forEach(groupId => {
LESSON_TYPE_LIMITS.forEach(config => {
const totalHours = Number(rule[config.hoursField] || 0);
if (totalHours <= 0) return;
const categorySlots = (rule.slots || [])
.map((slot, index) => ({ slot, index }))
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
.sort(compareSlotItems);
if (!categorySlots.length) return;
let consumedHours = 0;
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
for (const item of categorySlots) {
if (consumedHours >= totalHours) break;
if (!slotAppliesToWeek(item.slot, week)) continue;
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
activeWeeks.get(key).push(week);
consumedHours += ACADEMIC_HOURS_PER_SLOT;
}
}
});
});
});
return activeWeeks;
}
function renderVisualCellContent(lessons) {
if (!lessons.length) return '';
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
const shouldSplit = lessons.some(l => l.parity !== 'BOTH');
if (!shouldSplit) {
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
}
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
return `
<div class="schedule-visual-half schedule-visual-half-${parity.toLowerCase()}">
<span class="schedule-visual-half-label">${escapeHtml(label)}</span>
${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
</div>
`;
}).join('');
}
function uniqueVisualLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
unique.set(visualLessonSignature([lesson]), lesson);
});
return Array.from(unique.values()).sort(compareVisualLessons);
}
function renderVisualLesson(lesson) {
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
lesson.teacherName,
lesson.classroomName
].filter(Boolean).join(' · ');
const subgroupText = lesson.subgroupNames.join(', ');
return `
<article class="schedule-visual-lesson">
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
</article>
`;
}
function collectVisualGroups() {
const byId = new Map();
rules.forEach(rule => {
(rule.groupIds || []).forEach((id, index) => {
if (!byId.has(String(id))) {
byId.set(String(id), {
id,
name: (rule.groupNames || [])[index] || groupNameById(id)
});
}
});
});
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
}
function collectVisualSlotsFromRules() {
const byKey = new Map();
rules.flatMap(rule => rule.slots || []).forEach(slot => {
const key = visualSlotKey(slot);
if (!byKey.has(key)) {
byKey.set(key, {
key,
orderNumber: slot.timeSlotOrder,
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
timeLabel: slot.timeSlotLabel || 'Время не указано'
});
}
});
return Array.from(byKey.values()).sort(compareVisualSlots);
}
function visualSlotFromTimeSlot(slot) {
return {
key: String(slot.id),
orderNumber: slot.orderNumber,
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
};
}
function setVisualState(message) {
if (!visualStage) return;
visualStage.innerHTML = `<div class="loading-row schedule-visual-state">${escapeHtml(message)}</div>`;
}
function handleVisualGroupFilterChange(event) {
if (!event.target.matches('input[type="checkbox"]')) return;
selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => String(input.value)));
visualGroupFilterTouched = true;
renderVisualSchedule();
}
function syncVisualGroupFilter(allGroups) {
ensureVisualGroupSelection(allGroups);
if (!visualGroupFilter) return;
if (!allGroups.length) {
visualGroupFilter.innerHTML = '<span class="muted">Нет групп в правилах</span>';
return;
}
visualGroupFilter.innerHTML = allGroups.map(group => {
const id = String(group.id);
return `
<label class="schedule-visual-group-chip">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedVisualGroupIds.has(id) ? 'checked' : ''}>
<span>${escapeHtml(group.name)}</span>
</label>
`;
}).join('');
}
function ensureVisualGroupSelection(allGroups) {
const availableIds = new Set(allGroups.map(group => String(group.id)));
selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
if (visualGroupFilterTouched) return;
const formIds = selectedRuleGroupIds()
.map(String)
.filter(id => availableIds.has(id));
if (!visualGroupFilterTouched && formIds.length) {
selectedVisualGroupIds = new Set(formIds);
return;
}
if (!selectedVisualGroupIds.size && allGroups.length) {
selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
}
}
function renderRuleSlotsSummary(slots) { function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>'; if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
return slots.map(slot => { return slots.map(slot => {
@@ -667,6 +1024,15 @@ export async function initSchedule() {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
} }
function sortLessonTypes(items) {
return [...items].sort((first, second) => {
const firstOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(first)) ?? LESSON_TYPE_ORDER.size;
const secondOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(second)) ?? LESSON_TYPE_ORDER.size;
return firstOrder - secondOrder
|| naturalCompare(first.name || first.lessonType, second.name || second.lessonType);
});
}
function lessonTypeKeyById(lessonTypeId) { function lessonTypeKeyById(lessonTypeId) {
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
return lessonType ? lessonTypeKey(lessonType) : null; return lessonType ? lessonTypeKey(lessonType) : null;
@@ -712,4 +1078,131 @@ export async function initSchedule() {
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
}); });
} }
function visualCellKey(groupId, dayOfWeek, slotKey) {
return `${groupId}:${dayOfWeek}:${slotKey}`;
}
function visualSlotKey(slot) {
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
}
function visualRuleSlotKey(slot, index) {
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
}
function activeWeekKey(ruleId, groupId, slotKey) {
return `${ruleId}:${groupId}:${slotKey}`;
}
function slotAppliesToWeek(slot, week) {
if (!slot.parity || slot.parity === 'BOTH') return true;
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
return slot.parity === weekParity;
}
function maxWeeksForRule(rule) {
const semester = semesters.find(item => String(item.id) === String(rule.semesterId));
if (semester?.startDate && semester?.endDate) {
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
return 18;
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function compactWeekRanges(weeks) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return '';
return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`;
}
function weekRangeText(start, end) {
return start === end
? `${start} нед.`
: `с ${start} по ${end} нед.`;
}
function compareSlotItems(first, second) {
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|| Number(first.index || 0) - Number(second.index || 0);
}
function slotDescriptor(slot) {
return {
key: visualSlotKey(slot),
orderNumber: slot.timeSlotOrder,
timeLabel: slot.timeSlotLabel
};
}
function compareVisualSlots(first, second) {
const firstOrder = Number(first.orderNumber || first.key);
const secondOrder = Number(second.orderNumber || second.key);
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
return firstOrder - secondOrder;
}
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
}
function compareVisualLessons(first, second) {
return naturalCompare(first.subjectName, second.subjectName)
|| naturalCompare(first.weekText, second.weekText)
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|| naturalCompare(first.teacherName, second.teacherName);
}
function visualLessonSignature(lessons) {
return lessons
.map(lesson => [
lesson.id,
lesson.subjectName,
lesson.weekText,
lesson.lessonTypeName,
lesson.teacherName,
lesson.classroomName,
lesson.subgroupNames.join(',')
].join(':'))
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
function groupNameById(id) {
return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
function ruleCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'правил';
if (count % 10 === 1) return 'правило';
if ([2, 3, 4].includes(count % 10)) return 'правила';
return 'правил';
}
function groupCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'групп';
if (count % 10 === 1) return 'группа';
if ([2, 3, 4].includes(count % 10)) return 'группы';
return 'групп';
}
function rowCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'строк';
if (count % 10 === 1) return 'строка';
if ([2, 3, 4].includes(count % 10)) return 'строки';
return 'строк';
}
} }

View File

@@ -4,109 +4,121 @@ import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initSubjects() { export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody'); const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form'); const createSubjectForm = document.getElementById('create-subject-form');
const newSubjectDeptSelect = document.getElementById('new-subject-department');
const assignTeacherForm = document.getElementById('assign-teacher-form'); // Модалка управления преподавателями
const assignTeacherSelect = document.getElementById('assign-teacher-select'); const modalManageTeachers = document.getElementById('modal-manage-teachers');
const assignSubjectSelect = document.getElementById('assign-subject-select'); const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody'); const subjectTeachersContext = document.getElementById('subject-teachers-context');
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
const manageTsTbody = document.getElementById('manage-ts-tbody');
let allSubjects = []; let allSubjects = [];
let allTeachers = []; let allTeachers = [];
let allDepartments = [];
let teacherSubjects = [];
bindEvents();
await loadInitialData();
function bindEvents() {
createSubjectForm.addEventListener('submit', createSubject);
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
modalManageTeachers.addEventListener('click', (e) => {
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
});
}
async function loadInitialData() { async function loadInitialData() {
await Promise.all([loadSubjects(), loadTeachers()]); try {
await loadTeacherSubjects(); [allTeachers, allDepartments] = await Promise.all([
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
populateDepartmentsSelect();
await loadSubjects();
} catch (e) {
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
}
}
function populateDepartmentsSelect() {
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
allDepartments.map(d => {
const name = d.departmentName || d.name || 'Без названия';
const code = d.departmentCode || d.code || d.id;
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).join('');
} }
async function loadSubjects() { async function loadSubjects() {
subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try { try {
allSubjects = await api.get('/api/subjects'); [allSubjects, teacherSubjects] = await Promise.all([
renderSubjects(allSubjects); api.get('/api/subjects'),
populateSubjectSelect(allSubjects); api.get('/api/teacher-subjects')
]);
renderSubjects();
} catch (e) { } catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>'; subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
} }
} }
function renderSubjects(subjects) { function renderSubjects() {
if (!subjects || !subjects.length) { if (!allSubjects || !allSubjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>'; subjectsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет дисциплин</td></tr>';
return; return;
} }
subjectsTbody.innerHTML = subjects.map(s => `
// Группируем преподавателей по дисциплинам
const teachersBySubject = new Map();
teacherSubjects.forEach(ts => {
if (!teachersBySubject.has(ts.subjectId)) {
teachersBySubject.set(ts.subjectId, []);
}
teachersBySubject.get(ts.subjectId).push(ts);
});
subjectsTbody.innerHTML = allSubjects.map(s => {
const teachers = teachersBySubject.get(s.id) || [];
const teachersHtml = teachers.length
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
return `
<tr> <tr>
<td>${s.id}</td> <td>${s.id}</td>
<td>${escapeHtml(s.name)}</td> <td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.code || '-')}</td> <td>${escapeHtml(s.code || '-')}</td>
<td>${s.departmentId || '-'}</td> <td>${escapeHtml(departmentLabel(s.departmentId))}</td>
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td> <td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
</tr>`).join(''); <td style="text-align: right; white-space: nowrap;">
<button class="btn-primary btn-manage-teachers" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Преподаватели</button>
<button class="btn-delete btn-delete-subject" data-id="${s.id}">Удалить</button>
</td>
</tr>
`;
}).join('');
} }
function populateSubjectSelect(subjects) { async function createSubject(e) {
if (!assignSubjectSelect) return;
const currentVal = assignSubjectSelect.value;
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (currentVal && subjects.find(s => s.id == currentVal)) {
assignSubjectSelect.value = currentVal;
}
}
async function loadTeachers() {
try {
allTeachers = await api.get('/api/users/teachers');
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers || !teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const tsData = await api.get('/api/teacher-subjects');
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray || !tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
hideAlert('create-subject-alert'); hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim(); const name = document.getElementById('new-subject-name').value.trim();
const code = document.getElementById('new-subject-code').value.trim(); const code = document.getElementById('new-subject-code').value.trim();
const departmentId = document.getElementById('new-subject-department').value; const departmentId = newSubjectDeptSelect.value;
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; } if (!name || !code || !departmentId) {
if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; } showAlert('create-subject-alert', 'Заполните все поля', 'error');
return;
}
try { try {
const data = await api.post('/api/subjects', { const data = await api.post('/api/subjects', {
@@ -114,50 +126,138 @@ export async function initSubjects() {
code, code,
departmentId: Number(departmentId) departmentId: Number(departmentId)
}); });
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success'); showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
createSubjectForm.reset(); createSubjectForm.reset();
loadSubjects(); await loadSubjects();
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); } } catch (error) {
}); showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
}
}
subjectsTbody.addEventListener('click', async (e) => { async function handleSubjectsTableClick(e) {
const btn = e.target.closest('.btn-delete'); const btnDelete = e.target.closest('.btn-delete-subject');
if (!btn) return; const btnTeachers = e.target.closest('.btn-manage-teachers');
if (!confirm('Удалить дисциплину?')) return;
if (btnDelete) {
if (!confirm('Удалить дисциплину? Это удалит её привязки к преподавателям.')) return;
try { try {
await api.delete('/api/subjects/' + btn.dataset.id); await api.delete('/api/subjects/' + btnDelete.dataset.id);
loadSubjects(); await loadSubjects();
loadTeacherSubjects(); // May have deleted a subject that was assigned } catch (error) {
} catch (e) { alert(e.message || 'Ошибка удаления'); } alert(error.message || 'Ошибка удаления');
}); }
}
assignTeacherForm.addEventListener('submit', async (e) => { if (btnTeachers) {
const subjectId = btnTeachers.dataset.id;
const subject = allSubjects.find(s => s.id == subjectId);
if (subject) openTeachersModal(subject);
}
}
// --- Управление привязками преподавателей к дисциплине ---
function openTeachersModal(subject) {
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
manageTsSubjectIdInput.value = subject.id;
hideAlert('manage-ts-alert');
modalManageTeachers.classList.add('open');
renderSubjectTeachers(subject.id);
populateTeachersSelect(subject.id);
}
function renderSubjectTeachers(subjectId) {
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
if (!list.length) {
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
return;
}
manageTsTbody.innerHTML = list.map(ts => `
<tr>
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
<td>
<button class="btn-delete btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
</td>
</tr>
`).join('');
}
function populateTeachersSelect(subjectId) {
// Показываем только тех преподавателей, которые еще не привязаны к этой дисциплине
const assignedUserIds = new Set(
teacherSubjects
.filter(ts => String(ts.subjectId) === String(subjectId))
.map(ts => String(ts.userId))
);
const availableTeachers = allTeachers.filter(t => !assignedUserIds.has(String(t.id)));
if (!availableTeachers.length) {
manageTsTeacherSelect.innerHTML = '<option value="">Все преподаватели привязаны</option>';
return;
}
manageTsTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
availableTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)} (${escapeHtml(t.username)})</option>`).join('');
}
async function saveTeacherAssignment(e) {
e.preventDefault(); e.preventDefault();
hideAlert('assign-teacher-alert'); hideAlert('manage-ts-alert');
const userId = assignTeacherSelect.value; const subjectId = manageTsSubjectIdInput.value;
const subjectId = assignSubjectSelect.value; const userId = manageTsTeacherSelect.value;
if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; } if (!userId) {
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
return;
}
try { try {
await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) }); await api.post('/api/teacher-subjects', {
showAlert('assign-teacher-alert', 'Привязка создана', 'success'); userId: Number(userId),
loadTeacherSubjects(); subjectId: Number(subjectId)
} catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); }
}); });
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
// Перезагрузим привязки и обновим интерфейс
teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Также обновим главную таблицу
renderSubjects();
} catch (error) {
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
}
}
async function handleTeacherAssignmentTableClick(e) {
const btnDelete = e.target.closest('.btn-delete-ts');
if (!btnDelete) return;
if (!confirm('Удалить привязку преподавателя?')) return;
const subjectId = manageTsSubjectIdInput.value;
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try { try {
await api.delete('/api/teacher-subjects', { await api.delete('/api/teacher-subjects', {
userId: Number(btn.dataset.userId), userId: Number(btnDelete.dataset.userId),
subjectId: Number(btn.dataset.subjectId) subjectId: Number(btnDelete.dataset.subjectId)
});
loadTeacherSubjects();
} catch (e) { alert(e.message || 'Ошибка удаления'); }
}); });
loadInitialData(); // Перезагрузим привязки
teacherSubjects = await api.get('/api/teacher-subjects');
renderSubjectTeachers(subjectId);
populateTeachersSelect(subjectId);
// Обновим главную таблицу
renderSubjects();
} catch (error) {
alert(error.message || 'Ошибка удаления привязки');
}
}
// --- Вспомогательные функции ---
function departmentLabel(departmentId) {
const department = allDepartments.find(item => item.id == departmentId);
if (!department) return departmentId || '-';
return department.departmentName || department.name || departmentId;
}
} }

View File

@@ -0,0 +1,549 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initUniversityStructure() {
const deptTbody = document.getElementById('departments-tbody');
const specTbody = document.getElementById('specialties-tbody');
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
// Элементы вкладок
const tabDepartments = document.getElementById('btn-tab-departments');
const tabSpecialties = document.getElementById('btn-tab-specialties');
const tabProfiles = document.getElementById('btn-tab-profiles');
const contentDepartments = document.getElementById('tab-content-departments');
const contentSpecialties = document.getElementById('tab-content-specialties');
const contentProfiles = document.getElementById('tab-content-profiles');
// Элементы модалки профилей
const profilesModal = document.getElementById('modal-manage-profiles');
const profilesModalClose = document.getElementById('modal-manage-profiles-close');
const profilesSpecContext = document.getElementById('profiles-spec-context');
const profileFormTitle = document.getElementById('profile-form-title');
const manageProfileForm = document.getElementById('manage-profile-form');
const profileIdInput = document.getElementById('manage-profile-id');
const profileSpecIdInput = document.getElementById('manage-profile-spec-id');
const profileNameInput = document.getElementById('manage-profile-name');
const profileDescInput = document.getElementById('manage-profile-description');
const btnSaveProfile = document.getElementById('btn-save-profile');
const btnCancelProfileEdit = document.getElementById('btn-cancel-profile-edit');
const profilesTbody = document.getElementById('manage-profiles-tbody');
// Элементы вкладки Профилей
const tabProfileTbody = document.getElementById('profiles-tbody');
const createProfileTabForm = document.getElementById('create-profile-tab-form');
const tabProfileIdInput = document.getElementById('tab-profile-id');
const tabProfileSpecSelect = document.getElementById('tab-profile-spec');
const tabProfileNameInput = document.getElementById('tab-profile-name');
const tabProfileDescInput = document.getElementById('tab-profile-description');
const mainProfileFormTitle = document.getElementById('main-profile-form-title');
const btnTabSaveProfile = document.getElementById('btn-tab-save-profile');
const btnTabCancelProfileEdit = document.getElementById('btn-tab-cancel-profile-edit');
let departments = [];
let specialties = [];
let currentProfiles = [];
let allProfiles = [];
// --- Логика переключения табов ---
function deactivateTabs() {
[tabDepartments, tabSpecialties, tabProfiles].forEach(tab => {
if (tab) {
tab.classList.remove('active');
tab.style.borderBottomColor = 'transparent';
tab.style.color = 'var(--text-secondary)';
}
});
[contentDepartments, contentSpecialties, contentProfiles].forEach(content => {
if (content) content.style.display = 'none';
});
}
tabDepartments.addEventListener('click', () => {
deactivateTabs();
tabDepartments.classList.add('active');
tabDepartments.style.borderBottomColor = 'var(--primary)';
tabDepartments.style.color = 'var(--text-main)';
contentDepartments.style.display = 'block';
});
tabSpecialties.addEventListener('click', () => {
deactivateTabs();
tabSpecialties.classList.add('active');
tabSpecialties.style.borderBottomColor = 'var(--primary)';
tabSpecialties.style.color = 'var(--text-main)';
contentSpecialties.style.display = 'block';
});
tabProfiles.addEventListener('click', () => {
deactivateTabs();
tabProfiles.classList.add('active');
tabProfiles.style.borderBottomColor = 'var(--primary)';
tabProfiles.style.color = 'var(--text-main)';
contentProfiles.style.display = 'block';
});
// --- Загрузка данных ---
async function loadData() {
// Загрузка кафедр
try {
departments = await api.get('/api/departments');
renderDepartments();
} catch (e) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка специальностей
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
populateSpecialtiesDropdown();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
// Загрузка всех профилей
try {
allProfiles = await api.get('/api/specialties/profiles');
renderAllProfiles();
} catch (e) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td>
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td>
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn-primary btn-manage-profiles" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Профили</button>
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td>
</tr>
`).join('');
}
// --- Кафедры: CRUD ---
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
const name = document.getElementById('dept-name').value.trim();
const code = document.getElementById('dept-code').value.trim();
if (!name || !code) {
showAlert('create-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
createDeptForm.reset();
loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
}
});
deptTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-department');
const deleteBtn = e.target.closest('.btn-delete-department');
if (editBtn) {
const department = departments.find(item => item.id == editBtn.dataset.id);
if (!department) return;
document.getElementById('edit-dept-id').value = department.id;
document.getElementById('edit-dept-name').value = department.departmentName || department.name || '';
document.getElementById('edit-dept-code').value = department.departmentCode || department.code || '';
hideAlert('edit-dept-alert');
editDeptModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить кафедру?')) return;
try {
await api.delete('/api/departments/' + deleteBtn.dataset.id);
showAlert('create-dept-alert', 'Кафедра удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-dept-alert', error.message || 'Ошибка удаления кафедры', 'error');
}
}
});
editDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-dept-alert');
const id = document.getElementById('edit-dept-id').value;
const name = document.getElementById('edit-dept-name').value.trim();
const code = document.getElementById('edit-dept-code').value.trim();
if (!name || !code) {
showAlert('edit-dept-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/departments/' + id, { departmentName: name, departmentCode: Number(code) });
editDeptModal.classList.remove('open');
showAlert('create-dept-alert', `Кафедра "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-dept-alert', error.message || 'Ошибка обновления кафедры', 'error');
}
});
// --- Специальности: CRUD ---
createSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-spec-alert');
const name = document.getElementById('spec-name').value.trim();
const code = document.getElementById('spec-code').value.trim();
if (!name || !code) {
showAlert('create-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
createSpecForm.reset();
loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
const profilesBtn = e.target.closest('.btn-manage-profiles');
if (editBtn) {
const speciality = specialties.find(item => item.id == editBtn.dataset.id);
if (!speciality) return;
document.getElementById('edit-spec-id').value = speciality.id;
document.getElementById('edit-spec-name').value = speciality.specialityName || speciality.name || '';
document.getElementById('edit-spec-code').value = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || '';
hideAlert('edit-spec-alert');
editSpecModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить специальность?')) return;
try {
await api.delete('/api/specialties/' + deleteBtn.dataset.id);
showAlert('create-spec-alert', 'Специальность удалена', 'success');
await loadData();
} catch (error) {
showAlert('create-spec-alert', error.message || 'Ошибка удаления специальности', 'error');
}
}
if (profilesBtn) {
const specId = profilesBtn.dataset.id;
const speciality = specialties.find(item => item.id == specId);
if (!speciality) return;
openProfilesModal(speciality);
}
});
editSpecForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-spec-alert');
const id = document.getElementById('edit-spec-id').value;
const name = document.getElementById('edit-spec-name').value.trim();
const code = document.getElementById('edit-spec-code').value.trim();
if (!name || !code) {
showAlert('edit-spec-alert', 'Заполните все поля', 'error');
return;
}
try {
await api.put('/api/specialties/' + id, { specialityName: name, specialityCode: code });
editSpecModal.classList.remove('open');
showAlert('create-spec-alert', `Специальность "${name}" обновлена`, 'success');
await loadData();
} catch (error) {
showAlert('edit-spec-alert', error.message || 'Ошибка обновления специальности', 'error');
}
});
// --- Профили обучения: Логика ---
async function openProfilesModal(speciality) {
profilesSpecContext.textContent = `${speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code} · ${speciality.specialityName || speciality.name}`;
profileSpecIdInput.value = speciality.id;
resetProfileForm();
profilesModal.classList.add('open');
await loadProfiles(speciality.id);
}
async function loadProfiles(specId) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
currentProfiles = await api.get(`/api/specialties/${specId}/profiles`);
renderProfilesTable();
} catch (e) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(e.message)}</td></tr>`;
}
}
function renderProfilesTable() {
if (!currentProfiles || !currentProfiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = currentProfiles.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${p.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetProfileForm() {
manageProfileForm.reset();
profileIdInput.value = '';
profileFormTitle.textContent = 'Создание профиля';
btnSaveProfile.textContent = 'Создать';
btnCancelProfileEdit.style.display = 'none';
hideAlert('manage-profile-alert');
}
manageProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('manage-profile-alert');
const specId = profileSpecIdInput.value;
const profileId = profileIdInput.value;
const name = profileNameInput.value.trim();
const description = profileDescInput.value.trim();
if (!name) {
showAlert('manage-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
// Обновление
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('manage-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
// Создание
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('manage-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetProfileForm();
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-profile');
const deleteBtn = e.target.closest('.btn-delete-profile');
const specId = profileSpecIdInput.value;
if (editBtn) {
const profile = currentProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
profileIdInput.value = profile.id;
profileNameInput.value = profile.name || '';
profileDescInput.value = profile.description || '';
profileFormTitle.textContent = 'Редактирование профиля';
btnSaveProfile.textContent = 'Сохранить';
btnCancelProfileEdit.style.display = 'inline-block';
hideAlert('manage-profile-alert');
return;
}
if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${specId}/profiles/${deleteBtn.dataset.id}`);
showAlert('manage-profile-alert', 'Профиль удален', 'success');
await loadProfiles(specId);
} catch (error) {
showAlert('manage-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnCancelProfileEdit.addEventListener('click', resetProfileForm);
// Закрытие модалок
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
profilesModalClose.addEventListener('click', () => profilesModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
profilesModal.addEventListener('click', (e) => {
if (e.target === profilesModal) profilesModal.classList.remove('open');
});
// --- Дополнительные функции для вкладки Профилей ---
function populateSpecialtiesDropdown() {
const currentValue = tabProfileSpecSelect.value;
tabProfileSpecSelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode)} · ${escapeHtml(s.specialityName || s.name)}</option>`).join('');
tabProfileSpecSelect.value = currentValue;
}
function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
return;
}
tabProfileTbody.innerHTML = allProfiles.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
<td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
<button class="btn-delete btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function resetTabProfileForm() {
createProfileTabForm.reset();
tabProfileIdInput.value = '';
tabProfileSpecSelect.disabled = false;
mainProfileFormTitle.textContent = 'Создание профиля';
btnTabSaveProfile.textContent = 'Создать';
btnTabCancelProfileEdit.style.display = 'none';
hideAlert('tab-profile-alert');
tabProfileSpecSelect.dispatchEvent(new Event('change'));
}
createProfileTabForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('tab-profile-alert');
const specId = tabProfileSpecSelect.value;
const profileId = tabProfileIdInput.value;
const name = tabProfileNameInput.value.trim();
const description = tabProfileDescInput.value.trim();
if (!specId) {
showAlert('tab-profile-alert', 'Выберите специальность', 'error');
return;
}
if (!name) {
showAlert('tab-profile-alert', 'Введите название профиля', 'error');
return;
}
try {
if (profileId) {
await api.put(`/api/specialties/${specId}/profiles/${profileId}`, { id: Number(profileId), name, description });
showAlert('tab-profile-alert', `Профиль "${name}" обновлен`, 'success');
} else {
await api.post(`/api/specialties/${specId}/profiles`, { name, description });
showAlert('tab-profile-alert', `Профиль "${name}" создан`, 'success');
}
resetTabProfileForm();
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка сохранения профиля', 'error');
}
});
tabProfileTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-tab-profile');
const deleteBtn = e.target.closest('.btn-delete-tab-profile');
if (editBtn) {
const profile = allProfiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
tabProfileIdInput.value = profile.id;
tabProfileSpecSelect.value = profile.specialityId;
tabProfileSpecSelect.disabled = true;
tabProfileSpecSelect.dispatchEvent(new Event('change'));
tabProfileNameInput.value = profile.name || '';
tabProfileDescInput.value = profile.description || '';
mainProfileFormTitle.textContent = 'Редактирование профиля';
btnTabSaveProfile.textContent = 'Сохранить';
btnTabCancelProfileEdit.style.display = 'inline-block';
hideAlert('tab-profile-alert');
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
if (deleteBtn) {
const profile = allProfiles.find(item => item.id == deleteBtn.dataset.id);
if (!profile) return;
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profile.specialityId}/profiles/${profile.id}`);
showAlert('tab-profile-alert', 'Профиль удален', 'success');
await loadData();
} catch (error) {
showAlert('tab-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
btnTabCancelProfileEdit.addEventListener('click', resetTabProfileForm);
await loadData();
}

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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';

View 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');

View 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 = [];

View File

@@ -28,7 +28,7 @@
<button class="btn-primary" id="academic-years-refresh">Обновить</button> <button class="btn-primary" id="academic-years-refresh">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table class="academic-years-table">
<thead> <thead>
<tr> <tr>
<th>Название</th> <th>Название</th>

View File

@@ -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">

View File

@@ -1,6 +1,9 @@
<!-- ===== Classrooms Tab ===== --> <!-- ===== Classrooms Tab ===== -->
<div class="card create-card"> <div class="card create-card">
<h2>Новая аудитория</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Новая аудитория</h2>
<button type="button" class="btn-primary" id="btn-manage-equipments">Справочник оборудования</button>
</div>
<form id="create-classroom-form"> <form id="create-classroom-form">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@@ -107,3 +110,46 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Модальное окно управления Оборудованием -->
<div class="modal-overlay" id="modal-manage-equipments">
<div class="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Справочник оборудования</h2>
<button class="modal-close" id="modal-manage-equipments-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</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>

View File

@@ -0,0 +1,145 @@
<!-- ===== Dashboard Page ===== -->
<div class="dashboard-container">
<!-- Метрики (Информационные карточки) -->
<div class="dashboard-metrics-grid">
<div class="card dashboard-metric-card dashboard-metric-card--groups">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Активные группы</div>
<h3 id="metric-groups" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;"></h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--students">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Всего студентов</div>
<h3 id="metric-students" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;"></h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--teachers">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Преподаватели</div>
<h3 id="metric-teachers" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;"></h3>
</div>
</div>
<div class="card dashboard-metric-card dashboard-metric-card--classrooms">
<div class="metric-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
</div>
<div>
<div style="font-size: 0.85rem; color: var(--text-secondary); font-weight: 500;">Загрузка аудиторий сегодня</div>
<h3 id="metric-classrooms" style="margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700;"></h3>
</div>
</div>
</div>
<!-- Мониторинг в реальном времени и Быстрый переход -->
<div class="dashboard-middle">
<!-- Идет сейчас -->
<div class="card dashboard-monitor-card">
<div class="dashboard-card-header">
<h2 style="margin: 0;">Мониторинг пар (Идет сейчас)</h2>
<div id="current-time-slot" class="dashboard-time-slot"></div>
</div>
<div class="table-wrap dashboard-monitor-table">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem;">Аудитория</th>
<th style="text-align: left; padding: 0.75rem;">Дисциплина</th>
<th style="text-align: left; padding: 0.75rem;">Преподаватель</th>
<th style="text-align: left; padding: 0.75rem;">Группа</th>
</tr>
</thead>
<tbody id="current-lessons-tbody">
<tr>
<td colspan="4" class="loading-row" style="padding: 2rem; text-align: center; color: var(--text-secondary);">Определение текущего времени...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Свободные аудитории и Быстрые действия -->
<div class="dashboard-side-stack">
<!-- Свободные аудитории -->
<div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div id="free-classrooms-list" class="dashboard-badge-list">
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
</div>
</div>
<!-- Быстрые действия -->
<div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Быстрый переход</h3>
<div class="dashboard-actions">
<a href="#schedule" class="btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Конструктор расписания
</a>
<a href="#schedule-view" class="btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
Точечные изменения / Просмотр
</a>
</div>
</div>
</div>
</div>
<!-- Центр предупреждений и конфликтов (Red Zone) -->
<div class="card red-zone-card" style="border-top: 4px solid var(--error, #ef4444);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div>
<h2 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Центр предупреждений (Red Zone)
</h2>
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
</div>
<button class="btn-primary" id="btn-recheck-conflicts" style="font-size: 0.85rem; padding: 0.35rem 0.75rem;">Перепроверить</button>
</div>
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
</div>
</div>
</div>

View File

@@ -25,26 +25,28 @@
</div> </div>
<div class="department-workspace-grid"> <div class="department-workspace-grid">
<div class="card create-card"> <div class="card create-card department-subject-import-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Загрузка дисциплин</h2> <h2>Загрузка дисциплин</h2>
</div> </div>
<form id="department-subject-import-form" class="stack-form"> <form id="department-subject-import-form" class="stack-form">
<div class="form-group"> <div class="subject-import-slots-panel">
<label for="department-subject-import-text">Строки в формате «код; название»</label> <div class="card-header-row">
<textarea id="department-subject-import-text" rows="8" placeholder="DB201; Базы данных&#10;AI101; Искусственный интеллект"></textarea> <label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
<button type="button" class="btn-edit-classroom" id="subject-import-add">Добавить строку</button>
</div>
<div id="subject-import-rows" class="subject-import-rows-list"></div>
</div> </div>
<button type="submit" class="btn-primary">Загрузить</button> <button type="submit" class="btn-primary">Загрузить</button>
<div class="form-alert" id="department-subject-import-alert" role="alert"></div> <div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form> </form>
</div> </div>
<div class="card"> <div class="card department-teachers-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Преподаватели и нагрузка</h2> <h2>Преподаватели и нагрузка</h2>
</div> </div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div> <div class="metric-grid" id="department-teachers-list">Загрузка...</div>
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
</div> </div>
</div> </div>

View File

@@ -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">&times;</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">&times;</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>

View File

@@ -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>

View File

@@ -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);">&times;</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);">&times;</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>

View File

@@ -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">&times;</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>

View File

@@ -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>

View File

@@ -84,7 +84,7 @@
<div id="schedule-rule-slots" class="schedule-slots-list"></div> <div id="schedule-rule-slots" class="schedule-slots-list"></div>
</div> </div>
<div class="form-row"> <div class="form-row schedule-rule-actions">
<button type="submit" class="btn-primary">Сохранить правило</button> <button type="submit" class="btn-primary">Сохранить правило</button>
</div> </div>
<div class="form-alert" id="schedule-rule-alert" role="alert"></div> <div class="form-alert" id="schedule-rule-alert" role="alert"></div>
@@ -117,3 +117,38 @@
</table> </table>
</div> </div>
</div> </div>
<button type="button"
class="schedule-visual-handle"
id="schedule-visual-toggle"
aria-label="Показать визуальное расписание"
aria-expanded="false"></button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
<div class="schedule-visual-header">
<div class="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
</div>
<div class="schedule-visual-toolbar">
<div class="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn-edit-classroom" id="schedule-visual-select-all">Все</button>
</div>
</div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
<span class="muted">Группы появятся после загрузки правил</span>
</div>
</div>
</div>
<div class="schedule-visual-stage" id="schedule-visual-stage">
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>

View File

@@ -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="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2>Преподаватели дисциплины</h2>
<div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<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);">&times;</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-teacher-subject-form">
<input type="hidden" id="manage-ts-subject-id">
<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"> <div class="table-wrap">
<table id="teacher-subjects-table"> <table id="manage-ts-table">
<thead> <thead>
<tr> <tr>
<th>Преподаватель</th> <th>Преподаватель</th>
<th>Дисциплина</th>
<th>Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody id="teacher-subjects-tbody"> <tbody id="manage-ts-tbody">
<tr> <tr>
<td colspan="3" class="loading-row">Загрузка...</td> <td colspan="2" class="loading-row">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div> </div>

View File

@@ -0,0 +1,252 @@
<!-- ===== University Structure Tab (Departments, Specialties & Profiles) ===== -->
<div class="tab-container">
<div class="tabs-header-buttons" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<button class="btn-tab active" id="btn-tab-departments" style="background: none; border: none; border-bottom: 2px solid var(--primary); color: var(--text-main); font-weight: 600; padding: 0.5rem 1rem; cursor: pointer;">Кафедры</button>
<button class="btn-tab" id="btn-tab-specialties" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Специальности</button>
<button class="btn-tab" id="btn-tab-profiles" style="background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-weight: 500; padding: 0.5rem 1rem; cursor: pointer;">Профили</button>
</div>
<!-- Раздел Кафедр -->
<div id="tab-content-departments" class="tab-content-section">
<div class="card create-card">
<h2>Создание кафедры</h2>
<form id="create-department-form">
<div class="form-row">
<div class="form-group">
<label for="dept-name">Название кафедры</label>
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
</div>
<div class="form-group">
<label for="dept-code">Код кафедры</label>
<input type="number" id="dept-code" placeholder="Например: 1" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-dept-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Кафедры</h2>
</div>
<div class="table-wrap">
<table id="departments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="departments-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Раздел Специальностей -->
<div id="tab-content-specialties" class="tab-content-section" style="display: none;">
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
<div class="form-row">
<div class="form-group">
<label for="spec-name">Название специальности</label>
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
</div>
<div class="form-group">
<label for="spec-code">Код специальности</label>
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-spec-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Специальности</h2>
</div>
<div class="table-wrap">
<table id="specialties-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код специальности</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="specialties-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Раздел Профилей -->
<div id="tab-content-profiles" class="tab-content-section" style="display: none;">
<div class="card create-card">
<h2 id="main-profile-form-title">Создание профиля</h2>
<form id="create-profile-tab-form">
<input type="hidden" id="tab-profile-id">
<div class="form-row">
<div class="form-group" style="flex: 1; min-width: 200px;">
<label for="tab-profile-spec">Специальность</label>
<select id="tab-profile-spec" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group" style="flex: 1.5; min-width: 250px;">
<label for="tab-profile-name">Название профиля</label>
<input type="text" id="tab-profile-name" placeholder="Например: Искусственный интеллект" required>
</div>
<div class="form-group" style="flex: 2; min-width: 250px;">
<label for="tab-profile-description">Описание</label>
<input type="text" id="tab-profile-description" placeholder="Необязательно">
</div>
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<button type="submit" class="btn-primary" id="btn-tab-save-profile">Создать</button>
<button type="button" class="btn-secondary" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
</div>
</div>
<div class="form-alert" id="tab-profile-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Профили обучения</h2>
</div>
<div class="table-wrap">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Специальность</th>
<th>Название профиля</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="5" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Модальное окно редактирования кафедры -->
<div class="modal-overlay" id="modal-edit-department">
<div class="modal-content card">
<h2>Редактирование кафедры</h2>
<button class="modal-close" id="modal-edit-department-close">&times;</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">&times;</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);">&times;</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>

View File

@@ -155,6 +155,8 @@
setTimeout(() => { window.location.href = redirect; }, 400); setTimeout(() => { window.location.href = redirect; }, 400);
} else { } else {
showAlert(data.message || 'Неверное имя пользователя или пароль', 'error'); showAlert(data.message || 'Неверное имя пользователя или пароль', 'error');
passwordInput.value = '';
passwordInput.focus();
} }
} catch (err) { } catch (err) {
showAlert('Ошибка соединения с сервером', 'error'); showAlert('Ошибка соединения с сервером', 'error');