14 Commits

Author SHA1 Message Date
Zuev
e82ed69639 тестовая реализация подсчёта курса и семестра 2026-03-31 13:54:53 +03:00
ProstoDenya01
cd6cc6f5f7 Поправил создание кафедрального файла 2026-03-31 13:20:31 +03:00
2be2534a1e Merge remote-tracking branch 'origin/department_dev' into department_dev 2026-03-31 00:49:45 +03:00
b14d937062 Изменил страницу "Кафедра",добавлена модалка с полями для создания записи в Кафедральный файлик 2026-03-31 00:49:37 +03:00
9d06c99d06 Поправил документацию по API 2026-03-30 22:06:16 +03:00
Zuev
522bc97b8c добавил всплывающий текст у свёрнутой боковой панели 2026-03-28 14:28:13 +03:00
Zuev
d0a8148fa0 исправил боковую панель. теперь на десктопе она сворачивается не полностью 2026-03-28 14:20:46 +03:00
ProstoDenya01
0b9d063266 Поправил ответ для пользователей, чтобы приходило название кафедры, а не ID 2026-03-27 19:02:27 +03:00
ProstoDenya01
6f33e23e17 Добавил методы на создание и удаление данных в кафедральном файле 2026-03-27 18:24:08 +03:00
Zuev
bfdcb58c7d изменил дизайн выпадающих списков 2026-03-27 16:08:44 +03:00
Zuev
e015758caf обновил документацию 2026-03-27 15:24:29 +03:00
Zuev
6be8db0cd0 сделал кнопку настроек, вкладку настроек и сворачивание боковой панели 2026-03-27 15:03:52 +03:00
ProstoDenya01
7a2c385257 Реализовал метод на получение данных для расписания по нужным критериям. Обновил БД 2026-03-26 20:08:17 +03:00
f7483e7aeb Изменил страницу "Кафедра", добавлена фильтрация и добавление блоков 2026-03-26 00:37:31 +03:00
48 changed files with 3513 additions and 799 deletions

View File

@@ -1,6 +0,0 @@
---
trigger: always_on
glob:
description:
---

View File

@@ -1,29 +0,0 @@
Контекст проекта:
Backend: Java 17, Spring Framework. Учитывай возможности этой версии языка и стандарты фреймворка.
Frontend: HTML, CSS, JavaScript.
Правила написания кода и комментариев:
Проверка: Перед написанием кода изучи проект, его структуру и используемые технологии. Не предлагай решения, которые не соответствуют текущей архитектуре или стеку.
Язык: Все комментарии и объяснения должны быть строго на русском языке.
Комментирование кода: Оставляй комментарии, объясняющие, за что отвечает та или иная часть кода. Перед крупными или смысловыми блоками обязательно ставь поясняющие метки (например: ``, /* таблица subjects */, // логика обработки subjects).
Обоснование решений: При написании нового кода кратко и максимально понятно объясняй, почему мы используем именно это решение, а не другое.
Современные подходы: На фронтенде используй самые современные и актуальные подходы (например, Flexbox, CSS Grid, семантические теги).
Правила работы с ошибками (Обучающий режим):
Если ты находишь ошибку в моем коде, не пиши сразу готовый исправленный код.
Дай мне точную подсказку, чтобы я мог сам найти и исправить баг (например: "У тебя не закрыт тег в 15 строке", "Ты забыл поставить аннотацию в контроллере Spring" или "Проверь отступы в таком-то классе"). Моя цель — научиться.
Правила работы с дизайном (UI/UX):
Перед добавлением новых стилей всегда сначала изучай, какие стили уже используются в проекте, чтобы сохранять единообразие.
Если ты видишь, что текущий дизайн откровенно плох, нелогичен или устарел — смело предлагай свои идеи по улучшению (цветовая палитра, отступы, шрифты). Я открыт к предложениям по улучшению визуала.

View File

@@ -30,6 +30,7 @@ description: Автоматическое обновление документ
| `utils/*.java` | `docs/BUSINESS_LOGIC.md` | | `utils/*.java` | `docs/BUSINESS_LOGIC.md` |
| `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` | | `frontend/admin/js/views/*.js` | `docs/FRONTEND.md` |
| `frontend/admin/css/*.css` | `docs/FRONTEND.md` | | `frontend/admin/css/*.css` | `docs/FRONTEND.md` |
| `frontend/admin/settings/**` | `docs/FRONTEND.md` |
| `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` | | `compose.yaml`, `Dockerfile` | `docs/INFRASTRUCTURE.md` |
| `application.properties` | `docs/ARCHITECTURE.md` | | `application.properties` | `docs/ARCHITECTURE.md` |

View File

@@ -26,6 +26,7 @@ magistr/
│ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД) │ └── src/main/resources/db/migration/ # Flyway SQL миграции (версионирование схемы БД)
├── frontend/ # Статические файлы ├── frontend/ # Статические файлы
│ ├── admin/ # Интерфейс администратора │ ├── admin/ # Интерфейс администратора
│ │ └── settings/ # Страница настроек (отдельный SPA)
│ ├── teacher/ # Интерфейс преподавателя │ ├── teacher/ # Интерфейс преподавателя
│ └── student/ # Интерфейс студента │ └── student/ # Интерфейс студента
├── docs/ # 📖 Документация проекта ├── docs/ # 📖 Документация проекта
@@ -85,3 +86,4 @@ docker compose logs -f backend
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта | | [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Code Style, соглашения, пошаговое создание нового эндпоинта |
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность | | [`docs/FRONTEND.md`](docs/FRONTEND.md) | Frontend архитектура, SPA-маршрутизация, CSS, адаптивность |
| [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz | | [`docs/LOGGING.md`](docs/LOGGING.md) | Логирование: SLF4J + Logback, MDC, OpenTelemetry → SigNoz |
| [`docs/UI_COMPONENTS.md`](docs/UI_COMPONENTS.md) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |

View File

@@ -1,77 +0,0 @@
# Руководство Backend-разработчика Magistr
Добро пожаловать в проект Magistr! Этот бэкенд построен на **Spring Boot** и имеет сложную **мультитенантную архитектуру**, где одно приложение обслуживает множество независимых университетов, каждый со своей базой данных. В проекте также есть интеграция с Kubernetes для "горячего" управления этими тенантами.
Здесь описано, как тут всё устроено, чтобы вы ничего не сломали.
---
## 1. Архитектура мультитенантности
Мы используем подход **Separate Database per Tenant** (Отдельная БД для каждого клиента).
- **Как приложение понимает, к какой базе обращаться?**
Все запросы с фронтенда приходят с заголовком `Host` (например, `swsu.zuev.company`).
В классе `TenantInterceptor` (находится в `config/tenant/TenantInterceptor.java`) мы перехватываем этот запрос ДО того, как он дойдёт до контроллеров, вытаскиваем поддомен (`swsu`) и сохраняем его в `ThreadLocal` переменную через класс `TenantContext`.
- **Как переключаются базы данных?**
Класс `TenantRoutingDataSource` наследуется от спринговского `AbstractRoutingDataSource`. Перед каждым запросом в базу (любой `findById` или `save` из репозитория) Spring спрашивает этот класс: *"Какой сейчас ключ тенанта?"*. Класс берёт имя из `TenantContext` и переключает коннект на нужную БД на лету.
> **Важно:** Вся логика переключения абсолютно прозрачна для бизнес-кода. В контроллерах и сервисах вы пишете обычный код (`userRepository.findAll()`), и он сам выполнится в нужной базе.
---
## 2. Динамическое управление тенантами (Kubernetes / ConfigMap)
Бэкенд спроектирован для работы в **Kubernetes с несколькими репликами (replicas: 2+)**.
Список тенантов не зашит в код:
- В K8s он лежит в специальном `ConfigMap`, который монтируется внутрь пода как файл `tenants.json`.
- В классе `DatabaseController` находится API для добавления нового тенанта из админки.
- Чтобы изменения применились ко **всем подам** без перезагрузки, `DatabaseController` вызывает `ConfigMapUpdater`. Этот класс обращается напрямую к **Kubernetes API** (используя ServiceAccount токен пода) и патчит `ConfigMap`.
- В фоне работает планировщик `TenantConfigWatcher` (каждые 30 секунд). Он следит за изменениями `tenants.json` и, если видит нового тенанта, на лету поднимает для него новый `HikariCP` пул соединений и добавляет в маршрутизатор баз данных.
---
## 3. Базы данных и Миграции (Flyway)
Мы **НЕ используем** автоматическую генерацию таблиц через Hibernate (`spring.jpa.hibernate.ddl-auto=none`).
Структурой баз данных правит **Flyway**.
Поскольку баз данных много (они создаются динамически), стандартный Spring Boot Flyway отключён. Вместо этого `TenantConfigWatcher` вызывает Flyway **программно** в момент первого подключения нового тенанта.
### 🛑 ПРАВИЛА ИЗМЕНЕНИЯ СТРУКТУРЫ БД:
Если вам нужно добавить новую таблицу, колонку или изменить тип поля:
1. **Запрещено трогать старые файлы миграций!**
Запомните: файл `V1__init.sql` (и любые другие V-файлы, которые уже попали в коммит) — **СВЯЩЕНЕН**. Если вы его измените, бэкенд не запустится на сервере с ошибкой `Migration checksum mismatch`.
2. **Как правильно добавить таблицу?**
- Зайдите в папку `src/main/resources/db/migration/`.
- Создайте новый файл. Название **строго** по формату: `V<Номер>__<Описание>.sql`. Например: `V2__add_student_rating_table.sql`.
- Напишите в нём ваш SQL (`CREATE TABLE ...`, `ALTER TABLE ...`).
- Сохраните и запустите проект. Flyway **сам** пройдёт по всем базам данных тенантов и накатит этот скрипт.
3. **Что если локально я накосячил в V2?**
Пока файл `V2_...` не залит в Git и крутится только у вас на локалке, вы можете его переписывать. Но для этого вам нужно зайти в вашу локальную БД (через DBeaver/pgAdmin), вручную откатить свои кривые изменения (удалить таблицу) и **удалить запись из истории Flyway**:
`DELETE FROM flyway_schema_history WHERE version = '2';`
Либо, что проще: удалите контейнер с локальной БД (`docker compose down -v`) и поднимите заново пустую.
---
## 4. Как запускать проект локально
В корневой папке репозитория (где лежит `docker-compose.yaml`) поднимите инфраструктуру:
```bash
docker compose up -d
```
Соберется и запустится:
- Фронтенд
- Бэкенд
- Ваша локальная тестовая PostgreSQL-база данных (на порту 5432, имя базы `app_db`, юзер `myuser`, логин/пароль см. в compose файле).
Файл `backend/tenants.json` нужен для локальной разработки. Если вы запускаете бэкенд в Docker Compose, вы можете указать URL `jdbc:postgresql://db:5432/app_db` (где `db` — имя контейнера в compose сети).
Либо, если вы тестируете взаимодействие бэкенда с вашим текущим IP-адресом (например, `192.168.1.87`), вы можете использовать этот IP. Оба варианта рабочие! Проект сразу подхватит настройки и накатит таблицы через Flyway.
Контроллеры и бизнес-логику пишите как в обычном Spring Boot проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!

View File

@@ -38,14 +38,15 @@ public class AuthController {
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) { !passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
return ResponseEntity return ResponseEntity
.status(401) .status(401)
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null)); .body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
} }
User user = userOpt.get(); User user = userOpt.get();
String token = UUID.randomUUID().toString(); String token = UUID.randomUUID().toString();
String roleName = user.getRole().name(); String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect)); return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId));
} }
} }

View File

@@ -46,7 +46,10 @@ public class GroupController {
g.getEducationForm().getId(), g.getEducationForm().getId(),
g.getEducationForm().getName(), g.getEducationForm().getName(),
g.getDepartmentId(), g.getDepartmentId(),
g.getCourse() g.getEnrollmentYear(),
g.getCourse(),
g.getSemester(),
g.getSpecialityCode()
)) ))
.toList(); .toList();
logger.info("Получено {} групп", response.size()); logger.info("Получено {} групп", response.size());
@@ -81,8 +84,8 @@ public class GroupController {
@PostMapping @PostMapping
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) { public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, course = {}", logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, enrollmentYear = {}",
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getCourse()); request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getEnrollmentYear());
try { try {
if (request.getName() == null || request.getName().isBlank()) { if (request.getName() == null || request.getName().isBlank()) {
String errorMessage = "Название группы обязательно"; String errorMessage = "Название группы обязательно";
@@ -109,8 +112,13 @@ public class GroupController {
logger.error("Ошибка валидации: {}", errorMessage); logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} }
if (request.getCourse() == null || request.getCourse() == 0) { if (request.getEnrollmentYear() == null || request.getEnrollmentYear() == 0) {
String errorMessage = "Курс обязателен"; String errorMessage = "Год начала обучения обязателен";
logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) {
String errorMessage = "Код специальности обязателен";
logger.error("Ошибка валидации: {}", errorMessage); logger.error("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} }
@@ -125,7 +133,8 @@ public class GroupController {
group.setGroupSize(request.getGroupSize()); group.setGroupSize(request.getGroupSize());
group.setEducationForm(efOpt.get()); group.setEducationForm(efOpt.get());
group.setDepartmentId(request.getDepartmentId()); group.setDepartmentId(request.getDepartmentId());
group.setCourse(request.getCourse()); group.setEnrollmentYear(request.getEnrollmentYear());
group.setSpecialityCode(request.getSpecialityCode());
groupRepository.save(group); groupRepository.save(group);
logger.info("Группа успешно создана с ID - {}", group.getId()); logger.info("Группа успешно создана с ID - {}", group.getId());
@@ -137,7 +146,10 @@ public class GroupController {
group.getEducationForm().getId(), group.getEducationForm().getId(),
group.getEducationForm().getName(), group.getEducationForm().getName(),
group.getDepartmentId(), group.getDepartmentId(),
group.getCourse())); group.getEnrollmentYear(),
group.getCourse(),
group.getSemester(),
group.getSpecialityCode()));
} catch (Exception e ) { } catch (Exception e ) {
logger.error("Ошибка при создании группы: {}", e.getMessage(), e); logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)

View File

@@ -1,30 +1,44 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.model.Department; import com.magistr.app.dto.CreateScheduleDataRequest;
import com.magistr.app.model.ScheduleData; import com.magistr.app.dto.ScheduleResponse;
import com.magistr.app.repository.DepartmentRepository; import com.magistr.app.model.*;
import com.magistr.app.repository.ScheduleDataRepository; import com.magistr.app.repository.*;
import com.magistr.app.utils.SemesterTypeValidator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/scheduledata") @RequestMapping("/api/department/schedule")
public class ScheduleDataController { public class ScheduleDataController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class); private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class);
private final ScheduleDataRepository scheduleDataRepository; private final ScheduleDataRepository scheduleDataRepository;
private final GroupRepository groupRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SubjectRepository subjectRepository;
private final LessonTypesRepository lessonTypesRepository;
private final UserRepository userRepository;
public ScheduleDataController(ScheduleDataRepository scheduleDataRepository) { public ScheduleDataController(ScheduleDataRepository scheduleDataRepository, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) {
this.scheduleDataRepository = scheduleDataRepository; this.scheduleDataRepository = scheduleDataRepository;
this.groupRepository = groupRepository;
this.specialtiesRepository = specialtiesRepository;
this.subjectRepository = subjectRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.userRepository = userRepository;
} }
@GetMapping @GetMapping("/allList")
public List<ScheduleData> getAllScheduleDataList() { public List<ScheduleData> getAllScheduleDataList() {
logger.info("Получен запрос на получение списка данных расписаний"); logger.info("Получен запрос на получение списка данных расписаний");
try { try {
@@ -51,4 +65,224 @@ public class ScheduleDataController {
throw e; throw e;
} }
} }
@GetMapping
public ResponseEntity<?> getSingleScheduleData(
@RequestParam Long departmentId,
@RequestParam SemesterType semesterType,
@RequestParam String period
) {
logger.info("Получен запрос на получение списка данных расписания по конкретным данным: departmentId = {}, semester = {}, period = {}",
departmentId, semesterType, period);
try {
List<ScheduleData> scheduleData = scheduleDataRepository.findByDepartmentIdAndSemesterTypeAndPeriod(departmentId, semesterType, period );
if(scheduleData.isEmpty()){
logger.info("По параметрам: departmentId = {}, semester = {}, period = {} не найдено записей", departmentId, semesterType, period);
return ResponseEntity.ok(Map.of(
"message", "Записей не найдено"
));
}
List<ScheduleResponse> response = scheduleData.stream()
.map( s -> {
String groupName = groupRepository.findById(s.getGroupId())
.map(StudentGroup::getName)
.orElse("Неизвестно");
Integer groupCourse = groupRepository.findById(s.getGroupId())
.map(StudentGroup::getCourse)
.orElse(null);
String specialityCode = "Неизвестно";
StudentGroup group = groupRepository.findById(s.getGroupId()).orElse(null);
if (group != null) {
Long specialityId = group.getSpecialityCode();
specialityCode = specialtiesRepository.findById(specialityId).
map(Speciality::getSpecialityCode)
.orElse("Неизвестно");
}
String subjectName = subjectRepository.findById(s.getSubjectsId())
.map(Subject::getName)
.orElse("Неизвестно");
String lessonType = lessonTypesRepository.findById(s.getLessonTypeId())
.map(LessonType::getLessonType)
.orElse("Неизвестно");
String teacherName = userRepository.findById(s.getTeacherId())
.map(User::getFullName)
.orElse("Неизвестно");
String teacherjobTitle = userRepository.findById(s.getTeacherId())
.map(User::getJobTitle)
.orElse("Неизвестно");
return new ScheduleResponse(
s.getId(),
s.getDepartmentId(),
specialityCode,
s.getSemester(),
groupName,
groupCourse,
subjectName,
lessonType,
s.getNumberOfHours(),
s.getDivision(),
teacherName,
teacherjobTitle,
s.getSemesterType(),
s.getPeriod());
}
)
.toList();
logger.info("Получено {} записей для кафедры с ID - {}", response.size(), departmentId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Ошибка при получении списка данных расписаний для кафедры с ID - {}, semester - {}, period - {}: {}", departmentId, semesterType, period, e.getMessage(), e);
throw e;
}
}
// Доделать проверки получаемых полей!!!
@PostMapping("/create")
public ResponseEntity<?> createScheduleData(@RequestBody CreateScheduleDataRequest request) {
logger.info("Получен запрос на создание записи данных для расписаний: departmentId={}, semester={}, groupId={}, subjectsId={}, lessonTypeId={}, numberOfHours={}, division={}, teacherId={}, semesterType={}, period={}",
request.getDepartmentId(), request.getSemester(), request.getGroupId(), request.getSubjectsId(), request.getLessonTypeId(), request.getNumberOfHours(), request.getDivision(), request.getTeacherId(), request.getSemesterType(), request.getPeriod());
try {
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
String errorMessage = "ID кафедры обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(!scheduleDataRepository.existsById(request.getDepartmentId())) {
String errorMessage = "Кафедра не найдена";
logger.info("Кафедра не найдена");
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSemester() == null || request.getSemester() == 0) {
String errorMessage = "Семестр обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if(request.getSemester() > 12) {
String errorMessage = "Семестр должен быть меньше или равен 12";
logger.info("Семестр должен быть меньше или равен 12");
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getGroupId() == null || request.getGroupId() == 0) {
String errorMessage = "ID группы обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSubjectsId() == null || request.getSubjectsId() == 0) {
String errorMessage = "ID дисциплины обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) {
String errorMessage = "ID типа занятия обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getNumberOfHours() == null) {
request.setNumberOfHours(0L);
}
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
String errorMessage = "ID преподавателя обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getSemesterType() == null) {
String errorMessage = "Семестр обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
} else if (!SemesterTypeValidator.isValidTypeSemester(request.getSemesterType().toString())) {
String errorMessage = "Некорректный формат семестра. Допустимые форматы: " + SemesterTypeValidator.getValidTypes();
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
if (request.getPeriod() == null || request.getPeriod().isBlank()) {
String errorMessage = "Период обязателен";
logger.info("Ошибка валидации: {}", errorMessage);
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
}
boolean existsRecord = scheduleDataRepository.existsByDepartmentIdAndSemesterAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
request.getDepartmentId(),
request.getSemester(),
request.getGroupId(),
request.getSubjectsId(),
request.getLessonTypeId(),
request.getNumberOfHours(),
request.getDivision(),
request.getTeacherId(),
request.getSemesterType(),
request.getPeriod()
);
if(existsRecord) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
}
ScheduleData scheduleData = new ScheduleData();
scheduleData.setDepartmentId(request.getDepartmentId());
scheduleData.setSemester(request.getSemester());
scheduleData.setGroupId(request.getGroupId());
scheduleData.setSubjectsId(request.getSubjectsId());
scheduleData.setLessonTypeId(request.getLessonTypeId());
scheduleData.setNumberOfHours(request.getNumberOfHours());
scheduleData.setDivision(request.getDivision());
scheduleData.setTeacherId(request.getTeacherId());
scheduleData.setSemesterType(request.getSemesterType());
scheduleData.setPeriod(request.getPeriod());
ScheduleData savedSchedule = scheduleDataRepository.save(scheduleData);
Map<String, Object> response = new LinkedHashMap<>();
response.put("id", savedSchedule.getId());
response.put("departmentId", savedSchedule.getDepartmentId());
response.put("semester", savedSchedule.getSemester());
response.put("groupId", savedSchedule.getGroupId());
response.put("subjectId", savedSchedule.getSubjectsId());
response.put("lessonTypeId", savedSchedule.getLessonTypeId());
response.put("numberOfHours", savedSchedule.getNumberOfHours());
response.put("isDivision", savedSchedule.getDivision());
response.put("teacherId", savedSchedule.getTeacherId());
response.put("semesterType", savedSchedule.getSemesterType());
response.put("period", savedSchedule.getPeriod());
logger.info("Запись успешно создана с ID: {}", savedSchedule.getId());
return ResponseEntity.ok(response);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("message", "Такая запись уже существует"));
} catch (Exception e) {
logger.error("Ошибка при создании записи: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Произошла ошибка при создании записи: " + e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteById(@PathVariable Long id) {
logger.info("Получен запрос на удаление записи с ID: {}", id);
if(!scheduleDataRepository.existsById(id)) {
logger.info("Запись с ID - {} не найдена", id);
return ResponseEntity.notFound().build();
}
scheduleDataRepository.deleteById(id);
logger.info("Запись с ID - {} успешно удалена", id);
return ResponseEntity.ok(Map.of("message", "Запись удалена"));
}
} }

View File

@@ -2,8 +2,10 @@ package com.magistr.app.controller;
import com.magistr.app.dto.CreateUserRequest; import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.UserResponse; import com.magistr.app.dto.UserResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -22,11 +24,13 @@ public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class); private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { public UserController(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, DepartmentRepository departmentRepository) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.departmentRepository = departmentRepository;
} }
@GetMapping @GetMapping
@@ -36,14 +40,19 @@ public class UserController {
List<User> users = userRepository.findAll(); List<User> users = userRepository.findAll();
List<UserResponse> response = users.stream() List<UserResponse> response = users.stream()
.map(u -> new UserResponse( .map(u -> {
u.getId(), String departmentName = departmentRepository.findById(u.getDepartmentId())
u.getUsername(), .map(Department::getDepartmentName)
u.getRole().name(), .orElse("Неизвестно");
u.getFullName(),
u.getJobTitle(), return new UserResponse(
u.getDepartmentId() u.getId(),
)) u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList(); .toList();
logger.info("Получено {} пользователей", response.size()); logger.info("Получено {} пользователей", response.size());
return response; return response;
@@ -62,14 +71,19 @@ public class UserController {
List<User> users = userRepository.findByRole(Role.TEACHER); List<User> users = userRepository.findByRole(Role.TEACHER);
List<UserResponse> response = users.stream() List<UserResponse> response = users.stream()
.map(u -> new UserResponse( .map(u -> {
u.getId(), String departmentName = departmentRepository.findById(u.getDepartmentId())
u.getUsername(), .map(Department::getDepartmentName)
u.getRole().name(), .orElse("Неизвестно");
u.getFullName(),
u.getJobTitle(), return new UserResponse(
u.getDepartmentId() u.getId(),
)) u.getUsername(),
u.getRole().name(),
u.getFullName(),
u.getJobTitle(),
departmentName);
})
.toList(); .toList();
logger.info("Получено {} преподавателей", response.size()); logger.info("Получено {} преподавателей", response.size());
return response; return response;

View File

@@ -6,7 +6,8 @@ public class CreateGroupRequest {
private Long groupSize; private Long groupSize;
private Long educationFormId; private Long educationFormId;
private Long departmentId; private Long departmentId;
private Integer course; private Integer enrollmentYear;
private Long specialityCode;
public String getName() { public String getName() {
return name; return name;
@@ -40,11 +41,19 @@ public class CreateGroupRequest {
this.departmentId = departmentId; this.departmentId = departmentId;
} }
public Integer getCourse() { public Integer getEnrollmentYear() {
return course; return enrollmentYear;
} }
public void setCourse(Integer course) { public void setEnrollmentYear(Integer enrollmentYear) {
this.course = course; this.enrollmentYear = enrollmentYear;
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
} }
} }

View File

@@ -1,5 +1,7 @@
package com.magistr.app.dto; package com.magistr.app.dto;
import com.magistr.app.model.SemesterType;
public class CreateScheduleDataRequest { public class CreateScheduleDataRequest {
private Long id; private Long id;
private Long departmentId; private Long departmentId;
@@ -8,9 +10,9 @@ public class CreateScheduleDataRequest {
private Long subjectsId; private Long subjectsId;
private Long lessonTypeId; private Long lessonTypeId;
private Long numberOfHours; private Long numberOfHours;
private Boolean isDivision; private Boolean division;
private Long teacherId; private Long teacherId;
private String semesterType; private SemesterType semesterType;
private String period; private String period;
public Long getId() { public Long getId() {
@@ -70,11 +72,11 @@ public class CreateScheduleDataRequest {
} }
public Boolean getDivision() { public Boolean getDivision() {
return isDivision; return division;
} }
public void setDivision(Boolean division) { public void setDivision(Boolean division) {
isDivision = division; this.division = division;
} }
public Long getTeacherId() { public Long getTeacherId() {
@@ -85,11 +87,11 @@ public class CreateScheduleDataRequest {
this.teacherId = teacherId; this.teacherId = teacherId;
} }
public String getSemesterType() { public SemesterType getSemesterType() {
return semesterType; return semesterType;
} }
public void setSemesterType(String semesterType) { public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType; this.semesterType = semesterType;
} }

View File

@@ -8,16 +8,25 @@ public class GroupResponse {
private Long educationFormId; private Long educationFormId;
private String educationFormName; private String educationFormName;
private Long departmentId; private Long departmentId;
private Integer enrollmentYear;
private Integer course; private Integer course;
private Integer semester;
private Long specialityCode;
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course) { public GroupResponse(Long id, String name, Long groupSize, Long educationFormId,
String educationFormName, Long departmentId,
Integer enrollmentYear, Integer course, Integer semester,
Long specialityCode) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.groupSize = groupSize; this.groupSize = groupSize;
this.educationFormId = educationFormId; this.educationFormId = educationFormId;
this.educationFormName = educationFormName; this.educationFormName = educationFormName;
this.departmentId = departmentId; this.departmentId = departmentId;
this.enrollmentYear = enrollmentYear;
this.course = course; this.course = course;
this.semester = semester;
this.specialityCode = specialityCode;
} }
public Long getId() { public Long getId() {
@@ -44,7 +53,19 @@ public class GroupResponse {
return departmentId; return departmentId;
} }
public Integer getEnrollmentYear() {
return enrollmentYear;
}
public Integer getCourse() { public Integer getCourse() {
return course; return course;
} }
public Integer getSemester() {
return semester;
}
public Long getSpecialityCode() {
return specialityCode;
}
} }

View File

@@ -7,16 +7,18 @@ public class LoginResponse {
private String token; private String token;
private String role; private String role;
private String redirect; private String redirect;
private Long departmentId;
public LoginResponse() { public LoginResponse() {
} }
public LoginResponse(boolean success, String message, String token, String role, String redirect) { public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) {
this.success = success; this.success = success;
this.message = message; this.message = message;
this.token = token; this.token = token;
this.role = role; this.role = role;
this.redirect = redirect; this.redirect = redirect;
this.departmentId = departmentId;
} }
public boolean isSuccess() { public boolean isSuccess() {
@@ -58,4 +60,12 @@ public class LoginResponse {
public void setRedirect(String redirect) { public void setRedirect(String redirect) {
this.redirect = redirect; this.redirect = redirect;
} }
public Long getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
} }

View File

@@ -1,22 +1,30 @@
package com.magistr.app.dto; package com.magistr.app.dto;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.magistr.app.model.SemesterType;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class ScheduleResponse { public class ScheduleResponse {
private Long id; private Long id;
private String specialityCode;
private Long departmentId; private Long departmentId;
private Long semester; private Long semester;
private Long groupId; private Long groupId;
private String groupName;
private Integer groupCourse;
private Long subjectsId; private Long subjectsId;
private String subjectName;
private Long lessonTypeId; private Long lessonTypeId;
private String lessonType;
private Long numberOfHours; private Long numberOfHours;
private Boolean isDivision; private Boolean division;
private Long teacherId; private Long teacherId;
private String semesterType; private String teacherName;
private String teacherJobTitle;
private SemesterType semesterType;
private String period; private String period;
public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { public ScheduleResponse(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, String lessonType, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id; this.id = id;
this.departmentId = departmentId; this.departmentId = departmentId;
this.semester = semester; this.semester = semester;
@@ -24,97 +32,98 @@ public class ScheduleResponse {
this.subjectsId = subjectsId; this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId; this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours; this.numberOfHours = numberOfHours;
this.isDivision = isDivision; this.division = division;
this.teacherId = teacherId; this.teacherId = teacherId;
this.semesterType = semesterType; this.semesterType = semesterType;
this.period = period; this.period = period;
} }
public ScheduleResponse(Long id, Long departmentId, String specialityCode, Long semester, String groupName, Integer groupCourse, String subjectName, String lessonType, Long numberOfHours, Boolean division, String teacherName, String teacherJobTitle, SemesterType semesterType, String period) {
this.id = id;
this.departmentId = departmentId;
this.specialityCode = specialityCode;
this.semester = semester;
this.groupName = groupName;
this.groupCourse = groupCourse;
this.subjectName = subjectName;
this.lessonType = lessonType;
this.numberOfHours = numberOfHours;
this.division = division;
this.teacherName = teacherName;
this.teacherJobTitle = teacherJobTitle;
this.semesterType = semesterType;
this.period = period;
}
public Long getId() { public Long getId() {
return id; return id;
} }
public void setId(Long id) { public String getSpecialityCode() {
this.id = id; return specialityCode;
} }
public Long getDepartmentId() { public Long getDepartmentId() {
return departmentId; return departmentId;
} }
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
public Long getSemester() { public Long getSemester() {
return semester; return semester;
} }
public void setSemester(Long semester) {
this.semester = semester;
}
public Long getGroupId() { public Long getGroupId() {
return groupId; return groupId;
} }
public void setGroupId(Long groupId) { public String getGroupName() {
this.groupId = groupId; return groupName;
}
public Integer getGroupCourse() {
return groupCourse;
} }
public Long getSubjectsId() { public Long getSubjectsId() {
return subjectsId; return subjectsId;
} }
public void setSubjectsId(Long subjectsId) { public String getSubjectName() {
this.subjectsId = subjectsId; return subjectName;
} }
public Long getLessonTypeId() { public Long getLessonTypeId() {
return lessonTypeId; return lessonTypeId;
} }
public void setLessonTypeId(Long lessonTypeId) { public String getLessonType() {
this.lessonTypeId = lessonTypeId; return lessonType;
} }
public Long getNumberOfHours() { public Long getNumberOfHours() {
return numberOfHours; return numberOfHours;
} }
public void setNumberOfHours(Long numberOfHours) {
this.numberOfHours = numberOfHours;
}
public Boolean getDivision() { public Boolean getDivision() {
return isDivision; return division;
}
public void setDivision(Boolean division) {
isDivision = division;
} }
public Long getTeacherId() { public Long getTeacherId() {
return teacherId; return teacherId;
} }
public void setTeacherId(Long teacherId) { public String getTeacherName() {
this.teacherId = teacherId; return teacherName;
} }
public String getSemesterType() { public String getTeacherJobTitle() {
return teacherJobTitle;
}
public SemesterType getSemesterType() {
return semesterType; return semesterType;
} }
public void setSemesterType(String semesterType) {
this.semesterType = semesterType;
}
public String getPeriod() { public String getPeriod() {
return period; return period;
} }
public void setPeriod(String period) {
this.period = period;
}
} }

View File

@@ -10,11 +10,21 @@ public class UserResponse {
private String role; private String role;
private String fullName; private String fullName;
private String jobTitle; private String jobTitle;
private String departmentName;
private Long departmentId; private Long departmentId;
public UserResponse() { public UserResponse() {
} }
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
this.id = id;
this.username = username;
this.role = role;
this.fullName = fullName;
this.jobTitle = jobTitle;
this.departmentName = departmentName;
}
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) { public UserResponse(Long id, String username, String role, String fullName, String jobTitle, Long departmentId) {
this.id = id; this.id = id;
this.username = username; this.username = username;
@@ -36,47 +46,27 @@ public class UserResponse {
return id; return id;
} }
public void setId(Long id) {
this.id = id;
}
public String getUsername() { public String getUsername() {
return username; return username;
} }
public void setUsername(String username) {
this.username = username;
}
public String getRole() { public String getRole() {
return role; return role;
} }
public void setRole(String role) {
this.role = role;
}
public String getFullName() { public String getFullName() {
return fullName; return fullName;
} }
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getJobTitle() { public String getJobTitle() {
return jobTitle; return jobTitle;
} }
public void setJobTitle(String jobTitle) { public String getDepartmentName() {
this.jobTitle = jobTitle; return departmentName;
} }
public Long getDepartmentId() { public Long getDepartmentId() {
return departmentId; return departmentId;
} }
public void setDepartmentId(Long departmentId) {
this.departmentId = departmentId;
}
} }

View File

@@ -0,0 +1,31 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name="lesson_types")
public class LessonType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="name", nullable = false)
private String lessonType;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLessonType() {
return lessonType;
}
public void setLessonType(String lessonType) {
this.lessonType = lessonType;
}
}

View File

@@ -29,20 +29,21 @@ public class ScheduleData {
private Long numberOfHours; private Long numberOfHours;
@Column(name="is_division", nullable = false) @Column(name="is_division", nullable = false)
private Boolean isDivision; private Boolean division;
@Column(name="teacher_id", nullable = false) @Column(name="teacher_id", nullable = false)
private Long teacherId; private Long teacherId;
@Enumerated(EnumType.STRING)
@Column(name="semester_type", nullable = false) @Column(name="semester_type", nullable = false)
private String semesterType; private SemesterType semesterType;
@Column(name="period", nullable = false) @Column(name="period", nullable = false)
private String period; private String period;
public ScheduleData() {} public ScheduleData() {}
public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean isDivision, Long teacherId, String semesterType, String period) { public ScheduleData(Long id, Long departmentId, Long semester, Long groupId, Long subjectsId, Long lessonTypeId, Long numberOfHours, Boolean division, Long teacherId, SemesterType semesterType, String period) {
this.id = id; this.id = id;
this.departmentId = departmentId; this.departmentId = departmentId;
this.semester = semester; this.semester = semester;
@@ -50,7 +51,7 @@ public class ScheduleData {
this.subjectsId = subjectsId; this.subjectsId = subjectsId;
this.lessonTypeId = lessonTypeId; this.lessonTypeId = lessonTypeId;
this.numberOfHours = numberOfHours; this.numberOfHours = numberOfHours;
this.isDivision = isDivision; this.division = division;
this.teacherId = teacherId; this.teacherId = teacherId;
this.semesterType = semesterType; this.semesterType = semesterType;
this.period = period; this.period = period;
@@ -113,11 +114,11 @@ public class ScheduleData {
} }
public Boolean getDivision() { public Boolean getDivision() {
return isDivision; return division;
} }
public void setDivision(Boolean division) { public void setDivision(Boolean division) {
isDivision = division; this.division = division;
} }
public Long getTeacherId() { public Long getTeacherId() {
@@ -128,11 +129,11 @@ public class ScheduleData {
this.teacherId = teacherId; this.teacherId = teacherId;
} }
public String getSemesterType() { public SemesterType getSemesterType() {
return semesterType; return semesterType;
} }
public void setSemesterType(String semesterType) { public void setSemesterType(SemesterType semesterType) {
this.semesterType = semesterType; this.semesterType = semesterType;
} }

View File

@@ -0,0 +1,6 @@
package com.magistr.app.model;
public enum SemesterType {
spring,
autumn
}

View File

@@ -1,5 +1,6 @@
package com.magistr.app.model; package com.magistr.app.model;
import com.magistr.app.utils.CourseCalculator;
import jakarta.persistence.*; import jakarta.persistence.*;
@Entity @Entity
@@ -23,8 +24,11 @@ public class StudentGroup {
@Column(name = "department_id", nullable = false) @Column(name = "department_id", nullable = false)
private Long departmentId; private Long departmentId;
@Column(name = "course", nullable = false) @Column(name = "enrollment_year", nullable = false)
private Integer course; private Integer enrollmentYear;
@Column(name="specialty_code", nullable = false)
private Long specialityCode;
public StudentGroup() { public StudentGroup() {
} }
@@ -69,11 +73,37 @@ public class StudentGroup {
this.departmentId = departmentId; this.departmentId = departmentId;
} }
public Integer getCourse() { public Integer getEnrollmentYear() {
return course; return enrollmentYear;
} }
public void setCourse(Integer course) { public void setEnrollmentYear(Integer enrollmentYear) {
this.course = course; this.enrollmentYear = enrollmentYear;
}
/**
* Вычисляемый курс на основе года начала обучения.
*/
@Transient
public Integer getCourse() {
if (enrollmentYear == null) return null;
return CourseCalculator.calculateCourse(enrollmentYear);
}
/**
* Вычисляемый семестр на основе года начала обучения.
*/
@Transient
public Integer getSemester() {
if (enrollmentYear == null) return null;
return CourseCalculator.calculateSemester(enrollmentYear);
}
public Long getSpecialityCode() {
return specialityCode;
}
public void setSpecialityCode(Long specialityCode) {
this.specialityCode = specialityCode;
} }
} }

View File

@@ -0,0 +1,7 @@
package com.magistr.app.repository;
import com.magistr.app.model.LessonType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LessonTypesRepository extends JpaRepository<LessonType, Long> {
}

View File

@@ -1,7 +1,25 @@
package com.magistr.app.repository; package com.magistr.app.repository;
import com.magistr.app.model.ScheduleData; import com.magistr.app.model.ScheduleData;
import com.magistr.app.model.SemesterType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ScheduleDataRepository extends JpaRepository<ScheduleData, Long> { public interface ScheduleDataRepository extends JpaRepository<ScheduleData, Long> {
List<ScheduleData> findByDepartmentIdAndSemesterTypeAndPeriod(Long departmentId, SemesterType semesterType, String period);
boolean existsByDepartmentIdAndSemesterAndGroupIdAndSubjectsIdAndLessonTypeIdAndNumberOfHoursAndDivisionAndTeacherIdAndSemesterTypeAndPeriod(
Long departmentId,
Long semester,
Long groupId,
Long subjectsId,
Long lessonTypeId,
Long numberOfHours,
Boolean division,
Long teacherId,
SemesterType semesterType,
String period
);
} }

View File

@@ -0,0 +1,53 @@
package com.magistr.app.utils;
import java.time.LocalDate;
/**
* Утилитный класс для вычисления курса и семестра группы
* на основе года начала обучения.
*/
public final class CourseCalculator {
private CourseCalculator() {
}
/**
* Вычисляет текущий курс группы.
* До сентября студент ещё на старом курсе, с сентября — на следующем.
*
* @param enrollmentYear год начала обучения (напр. 2023)
* @return номер курса (1, 2, 3, ...)
*/
public static int calculateCourse(int enrollmentYear) {
LocalDate now = LocalDate.now();
int currentYear = now.getYear();
int currentMonth = now.getMonthValue();
// С сентября начинается новый учебный год
if (currentMonth >= 9) {
return currentYear - enrollmentYear + 1;
} else {
return currentYear - enrollmentYear;
}
}
/**
* Вычисляет текущий семестр группы.
* Сентябрь–январь → нечётный (осенний) семестр, февраль–август → чётный (весенний).
*
* @param enrollmentYear год начала обучения (напр. 2023)
* @return номер семестра (1, 2, 3, ...)
*/
public static int calculateSemester(int enrollmentYear) {
int course = calculateCourse(enrollmentYear);
int currentMonth = LocalDate.now().getMonthValue();
// Сентябрь–январь: осенний (нечётный) семестр
// Февраль–август: весенний (чётный) семестр
if (currentMonth >= 9 || currentMonth <= 1) {
return (course - 1) * 2 + 1;
} else {
return (course - 1) * 2 + 2;
}
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.utils;
import com.magistr.app.model.SemesterType;
import java.util.Arrays;
public class SemesterTypeValidator {
public static boolean isValidTypeSemester(String semesterType) {
if (semesterType == null) {
return false;
}
try {
SemesterType.valueOf(semesterType);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
public static String getValidTypes() {
return String.join(", ", Arrays.stream(SemesterType.values())
.map(Enum::name)
.toArray(String[]::new));
}
}

View File

@@ -0,0 +1,50 @@
-- ==========================================
-- Редактирование учебных групп
-- ==========================================
ALTER TABLE student_groups
ADD COLUMN IF NOT EXISTS specialty_code INT REFERENCES specialties(id);
UPDATE student_groups
SET specialty_code = 1
WHERE specialty_code IS NULL;
ALTER TABLE student_groups
ALTER COLUMN specialty_code SET NOT NULL;
-- ==========================================
-- Редактирование данных для расписания
-- ==========================================
INSERT INTO schedule_data (department_id, semester, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period)
VALUES (1, 1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(3, 5, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(2, 4, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'),
(1, 1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'),
(1, 2, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'),
(1, 3, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'),
(1, 4, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025');
-- ==========================================
-- Год начала обучения вместо статического курса
-- ==========================================
ALTER TABLE student_groups
ADD COLUMN IF NOT EXISTS enrollment_year INT;
-- Обратный расчёт: enrollment_year = текущий_год - course + 1
-- (для месяцев до сентября курс ещё не увеличился)
UPDATE student_groups
SET enrollment_year = EXTRACT(YEAR FROM NOW())::INT - course
+ CASE WHEN EXTRACT(MONTH FROM NOW()) >= 9 THEN 1 ELSE 0 END;
ALTER TABLE student_groups
ALTER COLUMN enrollment_year SET NOT NULL;
ALTER TABLE student_groups
DROP COLUMN IF EXISTS course;
COMMENT ON COLUMN student_groups.enrollment_year IS 'Год начала обучения группы';

View File

@@ -53,8 +53,8 @@
**Ответ:** **Ответ:**
```json ```json
[ [
{ "id": 1, "username": "admin", "role": "ADMIN" }, { "id": 1, "username": "admin", "role": "ADMIN", "fullName": "Иванов Админ Иванович", "jobTitle": "Доцент", "departmentName": "Кафедра ИБ" },
{ "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER" } { "id": 2, "username": "Тестовый преподаватель", "role": "TEACHER", "fullName": "Петров Препод Петрович", "jobTitle": "Профессор", "departmentName": "Кафедра ВТ" }
] ]
``` ```
@@ -62,6 +62,10 @@
Список только преподавателей (роль `TEACHER`). Список только преподавателей (роль `TEACHER`).
### `GET /api/users/teachers/{departmentId}`
Список преподавателей привязанных к конкретной кафедре (роль `TEACHER`, код кафедры `departmentId`).
### `POST /api/users` ### `POST /api/users`
Создание пользователя. Создание пользователя.
@@ -69,16 +73,21 @@
**Тело запроса:** **Тело запроса:**
```json ```json
{ {
"username": "Новый преподаватель", "username": "teacher1",
"password": "password123", "password": "password",
"role": "TEACHER" "role": "TEACHER",
"fullName": "Test Teacher",
"jobTitle": "Proffessor",
"departmentId": 1
} }
``` ```
**Валидация:** **Валидация:**
- `username` — обязателен - `username` — обязателен и уникален
- `password` — минимум 4 символа - `password` — минимум 4 символа
- `role``ADMIN`, `TEACHER` или `STUDENT` - `role``ADMIN`, `TEACHER` или `STUDENT`
- `fullName` — обязателен
- `departmentId` — обязателен
### `DELETE /api/users/{id}` ### `DELETE /api/users/{id}`
@@ -92,7 +101,7 @@
Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория). Список всех занятий с разрешёнными именами (преподаватель, группа, дисциплина, аудитория).
**Ответ:** **Ответ:**
```json ```json
[ [
{ {
@@ -135,6 +144,7 @@
``` ```
**Валидация:** **Валидация:**
| Поле | Правило | | Поле | Правило |
|------|---------| |------|---------|
| `teacherId` | Обязателен, ≠ 0 | | `teacherId` | Обязателен, ≠ 0 |
@@ -147,10 +157,36 @@
| `week` | `Верхняя`, `Нижняя`, `Обе` | | `week` | `Верхняя`, `Нижняя`, `Обе` |
| `time` | Обязателен | | `time` | Обязателен |
### `PUT /api/users/lessons/update/{lessonId}` ### `PUT /api/users/lessons/update/{lessonId}`
Обновление занятия. Поддерживает partial update — передаются только изменённые поля. Обновление занятия. Поддерживает partial update — передаются только изменённые поля.
**Тело ответа:**
```json
{
"id": 5,
"teacherId": 1,
"groupId": 1,
"subjectId": 2,
"LessonFormat": "Онлайн",
"typeLesson": "Практическая работа",
"classroomId": 3,
"day": "Понедельник",
"week": "Верхняя",
"time": "9:40 - 11:10",
"updatedFields": {
"teacherId": 1,
"subjectId": 2,
"lessonFormat": "Онлайн",
"classroomId": 3,
"day": "Понедельник",
"time": "9:40 - 11:10"
},
"message": "Занятие успешно обновлено"
}
```
### `DELETE /api/users/lessons/delete/{lessonId}` ### `DELETE /api/users/lessons/delete/{lessonId}`
Удаление занятия. Удаление занятия.
@@ -175,20 +211,30 @@
"name": "ИВТ-21-1", "name": "ИВТ-21-1",
"groupSize": 25, "groupSize": 25,
"educationFormId": 1, "educationFormId": 1,
"educationFormName": "Бакалавриат" "educationFormName": "Бакалавриат",
"departmentId": 1,
"course": 3,
"specialityCode": 1
} }
] ]
``` ```
### `GET /api/groups/{departmentId}`
Список всех групп привязанных к конкретной кафедре.
### `POST /api/groups` ### `POST /api/groups`
Создание группы. Создание группы.
```json ```json
{ {
"name": Б-31м", "name": ВТ-11",
"groupSize": 20, "groupSize": 12,
"educationFormId": 2 "educationFormId": 1,
"departmentId": 1,
"course": 2,
"specialityCode": 1
} }
``` ```
@@ -249,10 +295,26 @@
Список всех дисциплин. Список всех дисциплин.
```json
{
"name": "Физика",
"code": null,
"departmentId": 1
}
```
### `GET /api/subjects/{departmentId}`
Список всех дисциплин привязанных к кафедре.
### `POST /api/subjects` ### `POST /api/subjects`
```json ```json
{ "name": "Физика" } {
"name": "Физика",
"code": null,
"departmentId": 1
}
``` ```
### `DELETE /api/subjects/{id}` ### `DELETE /api/subjects/{id}`

View File

@@ -51,7 +51,8 @@ erDiagram
BIGINT group_size BIGINT group_size
BIGINT education_form_id FK BIGINT education_form_id FK
BIGINT department_id FK BIGINT department_id FK
INT course INT enrollment_year
INT specialty_code FK
TIMESTAMP created_at TIMESTAMP created_at
} }
@@ -220,7 +221,10 @@ erDiagram
| `group_size` | BIGINT | Количество студентов | | `group_size` | BIGINT | Количество студентов |
| `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `education_form_id` | BIGINT FK → education_forms | Форма обучения |
| `department_id` | BIGINT FK → departments | Кафедра | | `department_id` | BIGINT FK → departments | Кафедра |
| `course` | INT CHECK(16) | Курс | | `enrollment_year` | INT NOT NULL | Год начала обучения (напр. 2023) |
| `specialty_code` | INT FK → specialties | Код специальности |
> **Примечание:** Курс и семестр **вычисляются динамически** на основе `enrollment_year` и текущей даты (утилита `CourseCalculator.java`). В БД не хранятся.
#### `subgroups` — Подгруппы #### `subgroups` — Подгруппы
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
@@ -341,6 +345,7 @@ erDiagram
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | | `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии |
| `V2__editScheduleData.sql` | Добавление `specialty_code`, тестовые данные расписания, замена `course``enrollment_year` |
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

@@ -28,12 +28,14 @@ frontend/
│ │ ├── main.css # CSS-переменные, цвета, типографика │ │ ├── main.css # CSS-переменные, цвета, типографика
│ │ ├── layout.css # Раскладка (sidebar, topbar, content) │ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы │ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ── modals.css # Модальные окна │ │ ── modals.css # Модальные окна
│ │ ├── department.css # Стили кафедры
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/ │ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия) │ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── users.js # Управление пользователями │ │ ├── users.js # Управление пользователями
│ │ ├── groups.js # Управление группами │ │ ├── groups.js # Управление группами
@@ -42,16 +44,30 @@ frontend/
│ │ ├── equipments.js # Управление оборудованием │ │ ├── equipments.js # Управление оборудованием
│ │ ├── edu-forms.js # Формы обучения │ │ ├── edu-forms.js # Формы обучения
│ │ ├── schedule.js # Расписание занятий │ │ ├── schedule.js # Расписание занятий
│ │ ── database.js # Управление тенантами │ │ ── database.js # Управление тенантами
└── views/ # HTML-шаблоны представлений │ ├── department.js # Кафедры
── users.html ── departments-data.js # Создание кафедры/специальности
├── groups.html ├── views/ # HTML-шаблоны представлений
├── classrooms.html ├── users.html
├── subjects.html ├── groups.html
├── equipments.html ├── classrooms.html
├── edu-forms.html ├── subjects.html
├── schedule.html ├── equipments.html
── database.html ── edu-forms.html
│ │ ├── schedule.html
│ │ ├── database.html
│ │ ├── department.html
│ │ └── departments-data.html
│ │
│ └── settings/ # ⚙️ Страница настроек (отдельный SPA)
│ ├── index.html # Оболочка с собственной sidebar
│ ├── css/
│ │ ├── main.css # CSS-переменные, базовые стили
│ │ └── layout.css # Sidebar, topbar, content
│ ├── js/
│ │ └── main.js # Навигация по вкладкам настроек
│ └── views/
│ └── general.html # Общие настройки (заглушка)
├── teacher/ # 👩‍🏫 Интерфейс преподавателя ├── teacher/ # 👩‍🏫 Интерфейс преподавателя
│ └── index.html # Просмотр расписания │ └── index.html # Просмотр расписания
@@ -92,6 +108,17 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `schedule` | Расписание | `/api/users/lessons` | | `schedule` | Расписание | `/api/users/lessons` |
| `database` | Тенанты | `/api/database` | | `database` | Тенанты | `/api/database` |
| `department` | Кафедры | `/api/departments` |
| `departments-data` | Создание кафедры/специальности | `/api/departments` |
### Страница настроек (`/admin/settings/`)
Настройки — это **отдельный SPA** со своей боковой панелью и вкладками, не связанными с основной админ-панелью.
- Доступ: через dropdown «Настройки» в footer боковой панели админки
- Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки:
- **Общие настройки** — заглушка (в разработке)
--- ---
@@ -157,7 +184,7 @@ export function isAuthenticatedAsAdmin() {
### Выход ### Выход
Кнопка «Выйти» очищает `localStorage` и перенаправляет на `/`. Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
--- ---
@@ -165,12 +192,14 @@ export function isAuthenticatedAsAdmin() {
### Модульный подход ### Модульный подход
Стили разделены на 4 файла (порядок подключения важен): Стили разделены на модульные файлы (порядок подключения важен):
1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема 1. **`main.css`** — CSS-переменные (цвета, шрифты, отступы), глобальные стили, тёмная тема
2. **`layout.css`** — Sidebar, topbar, content area, responsive 2. **`layout.css`** — Sidebar, topbar, content area, dropdown настроек, responsive
3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы 3. **`components.css`** — Кнопки, таблицы, карточки, badge, формы, theme-toggle
4. **`modals.css`** — Модальные окна 4. **`modals.css`** — Модальные окна
5. **`department.css`** — Стили страницы кафедр
6. **`departments-data.css`** — Стили создания кафедры/специальности
### Темизация ### Темизация
@@ -194,10 +223,34 @@ CSS-переменные позволяют поддерживать светл
--- ---
## Боковая панель (Sidebar)
- **Скрытие/раскрытие** — кнопка-крестик в правом верхнем углу sidebar
- **Десктоп** (`>768px`): sidebar складывается влево, контент расширяется; состояние сохраняется в `localStorage` (`sidebar-collapsed`)
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
---
## OpenTelemetry (`otel.js`)
Клиентская телеметрия (document-load, fetch, XHR) отправляется через `BatchSpanProcessor` на `/otel/v1/traces`.
- **На production** — загружается автоматически через динамический `import()`
- **На localhost** — пропускается, чтобы избежать таймаутов CDN `esm.sh`
```javascript
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
```
---
## Адаптивность ## Адаптивность
Интерфейс адаптирован под мобильные устройства: Интерфейс адаптирован под мобильные устройства:
- Sidebar скрывается на экранах < 768px - Sidebar скрывается на экранах < 768px, выезжает как overlay
- Появляется кнопка-гамбургер (`#menu-toggle`) - Появляется кнопка-гамбургер (`#menu-toggle`)
- Sidebar выезжает как overlay - Кнопка-крестик закрывает sidebar на всех устройствах
- Таблицы получают горизонтальный скролл - Таблицы получают горизонтальный скролл

115
docs/UI_COMPONENTS.md Normal file
View File

@@ -0,0 +1,115 @@
# 🎨 Использование UI компонентов: Выпадающие списки (Dropdowns)
В проекте Magistr используется **премиальная кастомная дизайн-система** выпадающих списков. В связи с ограничениями браузеров на стилизацию стандартных элементов `<select>`, мы реализовали два типа компонентов, которые выглядят потрясающе (с эффектом glassmorphism, встроенными микро-анимациями и свечением), но интегрируются максимально просто.
---
## 1. Стандартные одинарные списки (Custom Select Wrapper)
Этот компонент автоматически "оборачивает" любые стандартные теги `<select>` на всём сайте, превращая их в красивые выпадающие меню. Вам **не нужно** писать сложный HTML, всё работает автоматически!
### Как добавить новый одинарный список:
Просто добавьте обычный тег `<select>` в HTML:
```html
<div class="form-group">
<label for="my-new-select">Выберите опцию</label>
<select id="my-new-select">
<option value="">Выберите...</option>
<option value="1">Опция 1</option>
<option value="2">Опция 2</option>
</select>
</div>
```
### Как это работает:
1. В файле `frontend/admin/js/dropdown.js` инициализируется глобальный **`MutationObserver`**.
2. Как только любой скрипт или загрузка страницы добавляет `<select>` в DOM, скрипт автоматически:
- Скрывает оригинальный `<select>` (но оставляет его доступным из JS!).
- Рисует поверх него красивый `div.custom-select-wrapper` с нужным текстом, иконкой-шевроном и эффектом размытия фона.
- Синхронизирует состояния (если вы выберете элемент в кастомном UI, он автоматически изменит `select.value` и кинет событие `change`).
### Динамическое обновление списка (через JS):
Если вы подгружаете список с API, просто обновите `innerHTML` **нативного селекта**, как обычно:
```javascript
const select = document.getElementById('my-new-select');
select.innerHTML = '<option value="99">Новое значение с API</option>';
```
**Магия!** Экземпляр `CustomSelect` использует свой собственный внутренний `MutationObserver` для отслеживания изменений `<option>`, поэтому он **автоматически перестроит красивый кастомный выпадающий список**. Никаких дополнительных вызовов для перерисовки не требуется.
---
## 2. Множественный выбор (Multi-Select с чекбоксами)
Этот UI-компонент позволяет выбирать сразу несколько элементов из выпадающего списка. Он включает в себя кастомные красивые галочки (checkmarks) с неоновой подсветкой и кастомный скроллбар.
Этот компонент требует написания определённой HTML-структуры, так как нативного тега `select multiple` с похожей функциональностью не существует.
### Как добавить мульти-селект:
**1. HTML Структура:**
```html
<div class="form-group">
<label>Выберите оборудование</label>
<div class="custom-multi-select">
<!-- Кнопка-триггер (то, на что нажимаем) -->
<div class="select-box" id="my-multi-box">
<span class="select-text" id="my-multi-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="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- Само выпадающее меню -->
<div class="dropdown-menu" id="my-multi-menu">
<div id="my-multi-checkboxes" class="checkbox-group-vertical">
<!-- Сюда JS добавит чекбоксы -->
</div>
</div>
</div>
</div>
```
**2. Инициализация (в вашем JS-файле):**
Используйте готовую утилиту `initMultiSelect` из `utils.js` (она обрабатывает клики и открытие/закрытие):
```javascript
import { initMultiSelect } from '../utils.js';
// Передаем ID: box, menu, text, container
initMultiSelect('my-multi-box', 'my-multi-menu', 'my-multi-text', 'my-multi-checkboxes');
```
**3. Рендеринг элементов с кастомными галочками:**
Чтобы нарисовать сами чекбоксы, нужно использовать класс `.checkbox-item` и обязательный пустой `span.checkmark`. Пример генерации HTML:
```javascript
const container = document.getElementById('my-multi-checkboxes');
const items = [{id: 1, name: "Проектор"}, {id: 2, name: "Компьютер"}];
container.innerHTML = items.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<!-- Обязательный элемент для красивой галочки: -->
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
```
### Как прочитать выбранные значения:
Просто соберите массив value у выбранных чекбоксов внутри контейнера:
```javascript
const checkedBoxes = Array.from(document.querySelectorAll('#my-multi-checkboxes input:checked'));
const selectedIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
console.log(selectedIds); // [1, 2]
```
---
## Итог и правила
1. **Никогда не пытайтесь "красить" нативные теги `<option>`.** Браузеры (особенно Safari и Chrome) не позволяют этого сделать.
2. Для **отдельного выбора (1 из N)** всегда используйте стандартный `select`. Наша обёртка сделает всю магию сама.
3. Для **множественного выбора (N из M)** используйте HTML-шаблон `.custom-multi-select` (с `span.checkmark`).

View File

@@ -72,7 +72,7 @@
} }
.form-group input, .form-group input,
.form-group select { .filter-row input {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg-input); background: var(--bg-input);
@@ -85,20 +85,22 @@
transition: all var(--transition); transition: all var(--transition);
} }
.form-group input::placeholder { .form-group input::placeholder,
.filter-row input::placeholder {
color: var(--text-placeholder); color: var(--text-placeholder);
transition: opacity var(--transition); transition: opacity var(--transition);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .filter-row input:focus {
background: var(--bg-input-focus); background: var(--bg-input-focus);
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow); box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px); transform: translateY(-1px);
} }
.form-group input:focus::placeholder { .form-group input:focus::placeholder,
.filter-row input:focus::placeholder {
opacity: 0.5; opacity: 0.5;
} }
@@ -114,34 +116,187 @@ input[type="number"] {
appearance: textfield; appearance: textfield;
} }
/* Select Base Style */ /* ===== Premium Custom Dropdown Styles ===== */
.form-group select, .custom-select-wrapper {
.filter-row select { position: relative;
cursor: pointer; width: 100%;
appearance: none; user-select: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); font-family: inherit;
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
} }
.form-group select option, .custom-select-trigger {
.filter-row select option { display: flex;
background: #1a1a2e; align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
outline: none;
transition: all var(--transition);
}
.filter-row .custom-select-trigger {
padding: 0.45rem 1rem;
font-size: 0.85rem;
border-color: transparent;
}
.custom-select-trigger:hover {
background: var(--bg-hover);
}
.custom-select-trigger:focus,
.custom-select-wrapper.open .custom-select-trigger {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.filter-row .custom-select-wrapper.open .custom-select-trigger,
.filter-row .custom-select-trigger:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.custom-select-trigger.placeholder-active .custom-select-text {
color: var(--text-placeholder);
}
.custom-select-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-select-icon {
margin-left: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.custom-select-wrapper.open .custom-select-icon {
transform: rotate(180deg);
color: var(--accent);
}
.custom-select-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
width: 100%;
max-height: 280px;
overflow-y: auto;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 0.5rem;
z-index: 9999;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.98);
transform-origin: top center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
list-style: none;
margin: 0;
}
.custom-select-wrapper.open .custom-select-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
/* Custom Scrollbar for Dropdown */
.custom-select-menu::-webkit-scrollbar {
width: 6px;
}
.custom-select-menu::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
.custom-select-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.custom-select-menu::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.custom-select-item {
padding: 0.6rem 0.8rem;
margin-bottom: 0.15rem;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
}
.custom-select-item:last-child {
margin-bottom: 0;
}
.custom-select-item:hover:not(.disabled) {
background: var(--bg-hover);
padding-left: 1.1rem;
}
.custom-select-item.selected {
background: var(--accent-glow);
color: #fff;
font-weight: 500;
}
.custom-select-item.selected:hover {
background: var(--accent-glow);
padding-left: 0.8rem;
}
.custom-select-item.disabled {
color: var(--text-secondary);
opacity: 0.6;
cursor: not-allowed;
}
.custom-select-item.placeholder-item {
display: none; /* Hide placeholder options in the actual dropdown list naturally */
} }
/* Light theme selects */ /* Light theme selects */
[data-theme="light"] .form-group input, [data-theme="light"] .form-group input,
[data-theme="light"] .form-group select, [data-theme="light"] .filter-row input,
[data-theme="light"] .filter-row select { [data-theme="light"] .custom-select-trigger {
border-color: rgba(0, 0, 0, 0.15); border-color: rgba(0, 0, 0, 0.15);
} }
[data-theme="light"] .form-group select option, [data-theme="light"] .custom-select-menu {
[data-theme="light"] .filter-row select option { background: rgba(255, 255, 255, 0.95);
background: #fff; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
color: #1a1a2e; }
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .custom-select-menu::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
[data-theme="light"] .custom-select-item.selected {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
} }
/* Filter Row */ /* Filter Row */
@@ -172,7 +327,7 @@ input[type="number"] {
white-space: nowrap; white-space: nowrap;
} }
.filter-row select { .filter-row input {
padding: 0.45rem 2rem 0.45rem 0.7rem; padding: 0.45rem 2rem 0.45rem 0.7rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid transparent; border: 1px solid transparent;
@@ -182,7 +337,7 @@ input[type="number"] {
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
} }
.filter-row select:focus { .filter-row input:focus {
background-color: var(--bg-input-focus); background-color: var(--bg-input-focus);
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-glow);
@@ -230,26 +385,33 @@ input[type="number"] {
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 100%; top: calc(100% + 0.5rem);
left: 0; left: 0;
width: 100%; width: 100%;
margin-top: 0.5rem; background: rgba(10, 10, 15, 0.95);
background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(16px);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 1rem; padding: 0.5rem;
z-index: 100; z-index: 100;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(-10px); transform: translateY(-8px) scale(0.98);
transition: all var(--transition); transform-origin: top center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
} }
[data-theme="light"] .custom-multi-select .dropdown-menu { [data-theme="light"] .custom-multi-select .dropdown-menu {
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
}
.dropdown-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
} }
.dropdown-menu.open { .dropdown-menu.open {
@@ -261,26 +423,102 @@ input[type="number"] {
.checkbox-group-vertical { .checkbox-group-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.25rem;
max-height: 200px; max-height: 250px;
overflow-y: auto; overflow-y: auto;
padding-right: 0.25rem;
}
.checkbox-group-vertical::-webkit-scrollbar {
width: 6px;
}
.checkbox-group-vertical::-webkit-scrollbar-track {
background: transparent;
border-radius: 8px;
}
.checkbox-group-vertical::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .checkbox-group-vertical::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
} }
.checkbox-item { .checkbox-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; position: relative;
padding: 0.5rem 0.5rem 0.5rem 2.25rem;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-primary); color: var(--text-primary);
padding: 0.25rem 0; border-radius: var(--radius-sm);
user-select: none;
transition: background 0.2s ease;
}
.checkbox-item:hover {
background: var(--bg-hover);
} }
.checkbox-item input[type="checkbox"] { .checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer; cursor: pointer;
width: 1.1rem; height: 0;
height: 1.1rem; width: 0;
accent-color: var(--accent); }
.checkbox-item .checkmark {
position: absolute;
top: 50%;
left: 0.6rem;
transform: translateY(-50%);
height: 1.15rem;
width: 1.15rem;
background-color: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-item:hover input ~ .checkmark {
border-color: var(--accent);
}
.checkbox-item input:focus ~ .checkmark {
box-shadow: 0 0 0 3px var(--accent-glow);
}
.checkbox-item input:checked ~ .checkmark {
background-color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
}
.checkmark::after {
content: "";
display: none;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
margin-bottom: 2px;
}
.checkbox-item input:checked ~ .checkmark::after {
display: block;
} }
/* ===== Buttons ===== */ /* ===== Buttons ===== */
@@ -753,4 +991,45 @@ tbody tr:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
/* ===== Theme Toggle Button ===== */
.theme-toggle {
width: 42px;
height: 42px;
border: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
z-index: 100;
flex-shrink: 0;
}
.theme-toggle svg {
width: 20px;
height: 20px;
transition: transform 0.4s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px var(--accent-glow);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle--fixed {
position: fixed;
top: 1.25rem;
right: 1.25rem;
} }

View File

@@ -1,235 +1,315 @@
.wrap{ /* ===== Оверлей для модалок создания записей (к/ф) ===== */
max-width: 900px; .cs-overlay {
margin: 0 auto; display: none;
background: var(--bg-card); position: fixed;
border: 1px solid var(--bg-card-border); inset: 0;
border-radius: 12px; z-index: 1000;
overflow: hidden; background: rgba(0, 0, 0, 0.55);
box-shadow: 0 6px 20px rgba(0,0,0,.06); backdrop-filter: blur(4px);
} -webkit-backdrop-filter: blur(4px);
}
.header{
padding: 14px 16px; .cs-overlay.open {
border-bottom: 1px solid var(--bg-card-border); display: block;
font-weight: 700; }
color: var(--text-primary);
} .cs-overlay-scroll {
width: 100%;
details.table-item{ height: 100%;
border-top: 1px solid var(--bg-card-border); overflow-y: auto;
} padding: 2rem 1rem;
details.table-item:first-of-type{ border-top:none; } display: flex;
flex-direction: column;
summary{ align-items: center;
list-style: none; gap: 1rem;
cursor: pointer; }
user-select: none;
padding: 12px 16px; /* Общие стили для обеих модалок */
display: flex; .cs-modal {
align-items: center; width: 100%;
gap: 10px; max-width: 1100px;
} position: relative;
summary::-webkit-details-marker{ display:none; } animation: csModalAppear 0.25s ease-out;
}
.chev{
width: 28px; /* Модалка 1 (форма) всегда поверх модалки 2 (таблицы),
height: 28px; чтобы выпадающие списки не уходили под таблицу */
border: 1px solid var(--bg-card-border); .cs-modal-form {
border-radius: 10px; z-index: 2;
display: grid; }
place-items: center;
flex: 0 0 auto; .cs-modal-table {
z-index: 1;
color: var(--text-secondary); }
background: var(--bg-input);
@keyframes csModalAppear {
transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease; from { opacity: 0; transform: translateY(-12px); }
} to { opacity: 1; transform: translateY(0); }
}
.chev-icon{
width: 16px; .cs-modal-header {
height: 16px; display: flex;
display: block; justify-content: space-between;
} align-items: center;
margin-bottom: 1rem;
summary:hover .chev{ }
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border)); .cs-modal-header h2 {
color: var(--text-primary); margin: 0;
} }
details[open] .chev{ /* Кнопка закрытия */
transform: rotate(180deg); .btn-close-panel {
color: var(--accent); background: none;
border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border)); border: 1px solid var(--bg-card-border);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); border-radius: var(--radius-sm);
} font-size: 1.3rem;
line-height: 1;
.meta{ color: var(--text-secondary); font-size: 12px; } padding: 0.25rem 0.6rem;
color: var(--text-secondary);
.content{ padding: 0 16px 16px 16px; } cursor: pointer;
transition: color var(--transition), background var(--transition), border-color var(--transition);
.wrap table{ }
width: 100%;
border-collapse: collapse; .btn-close-panel:hover {
border: 1px solid var(--bg-card-border); color: var(--error);
border-radius: 10px; background: rgba(239, 68, 68, 0.1);
overflow: hidden; border-color: var(--error);
background: var(--bg-card); }
}
.wrap{
.wrap thead th{ max-width: 900px;
text-align: left; margin: 0 auto;
font-size: 13px; background: var(--bg-card);
color: var(--text-secondary); border: 1px solid var(--bg-card-border);
background: var(--bg-input); border-radius: 12px;
border-bottom: 1px solid var(--bg-card-border); overflow: hidden;
padding: 10px 12px; box-shadow: 0 6px 20px rgba(0,0,0,.06);
} }
.wrap tbody td{ .header{
padding: 10px 12px; padding: 14px 16px;
border-bottom: 1px solid var(--bg-card-border); border-bottom: 1px solid var(--bg-card-border);
font-size: 14px; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.wrap tbody tr:hover{ background: var(--bg-hover); } details.table-item{
border-top: 1px solid var(--bg-card-border);
.title-multiline{ }
display: flex; details.table-item:first-of-type{ border-top:none; }
flex-direction: column;
gap: 2px; summary{
line-height: 1.2; list-style: none;
} cursor: pointer;
user-select: none;
.title-multiline .title-main{ padding: 12px 16px;
font-weight: 700; display: flex;
color: var(--text-primary); align-items: center;
} gap: 10px;
}
.title-multiline .title-sub{ summary::-webkit-details-marker{ display:none; }
font-weight: 500;
font-size: 12px; .chev{
color: var(--text-secondary); width: 28px;
} height: 28px;
border: 1px solid var(--bg-card-border);
.title-multiline b{ border-radius: 10px;
font-weight: 700; display: grid;
color: var(--text-primary); place-items: center;
} flex: 0 0 auto;
/* summary = 3 колонки: [chev] [title] [meta] */ color: var(--text-secondary);
details.table-item > summary{ background: var(--bg-input);
display: grid;
grid-template-columns: 28px 1fr auto; transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease;
gap: 12px; }
align-items: start; /* важно: всё прижимаем к верху */
padding: 12px 16px; .chev-icon{
} width: 16px;
height: 16px;
/* чтобы текст нормально переносился и не растягивал мету */ display: block;
details.table-item > summary .title{ }
min-width: 0; /* важно для grid, иначе может распирать */
} summary:hover .chev{
background: var(--bg-hover);
/* "2 записи" всегда справа и сверху, аккуратно */ border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border));
details.table-item > summary .meta{ color: var(--text-primary);
justify-self: end; }
align-self: start;
white-space: nowrap; details[open] .chev{
padding-top: 4px; /* чуть опустить относительно первой строки */ transform: rotate(180deg);
font-size: 12px; color: var(--accent);
color: var(--text-secondary); border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border));
} background: color-mix(in srgb, var(--accent) 10%, var(--bg-input));
}
/* стрелка тоже сверху */
details.table-item > summary .chev{ .meta{ color: var(--text-secondary); font-size: 12px; }
align-self: start;
margin-top: 2px; .content{ padding: 0 16px 16px 16px; }
}
.wrap table{
.records-search{ width: 100%;
width: min(360px, 60vw); border-collapse: collapse;
padding: 0.45rem 0.7rem; border: 1px solid var(--bg-card-border);
background: var(--bg-input); border-radius: 10px;
border: 1px solid var(--bg-card-border); overflow: hidden;
border-radius: var(--radius-sm); background: var(--bg-card);
color: var(--text-primary); }
font-size: 0.9rem;
outline: none; .wrap thead th{
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease; text-align: left;
} font-size: 13px;
color: var(--text-secondary);
.records-search::placeholder{ color: var(--text-placeholder); } background: var(--bg-input);
border-bottom: 1px solid var(--bg-card-border);
.records-search:focus{ padding: 10px 12px;
background: var(--bg-input-focus); }
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow); .wrap tbody td{
} padding: 10px 12px;
/* Таблица внутри раскрывающегося блока */ border-bottom: 1px solid var(--bg-card-border);
details.table-item .content table{ font-size: 14px;
width: 100%; color: var(--text-primary);
border-collapse: separate; /* нужно для красивых линий */ }
border-spacing: 0;
border: 1px solid var(--bg-card-border); .wrap tbody tr:hover{ background: var(--bg-hover); }
border-radius: 12px;
overflow: hidden; .title-multiline{
background: var(--bg-card); display: flex;
} flex-direction: column;
gap: 2px;
/* Шапка */ line-height: 1.2;
details.table-item .content thead th{ }
position: sticky; /* опционально: шапка прилипает при скролле */
top: 0; .title-multiline .title-main{
z-index: 1; font-weight: 700;
color: var(--text-primary);
background: var(--bg-input); }
color: var(--text-secondary);
border-bottom: 1px solid var(--bg-card-border); .title-multiline .title-sub{
} font-weight: 500;
font-size: 12px;
/* Ячейки: одинаковые отступы */ color: var(--text-secondary);
details.table-item .content th, }
details.table-item .content td{
padding: 0.75rem 0.85rem; .title-multiline b{
vertical-align: top; font-weight: 700;
} color: var(--text-primary);
}
/* Вертикальные разделители между колонками */
details.table-item .content th:not(:last-child), /* summary = 3 колонки: [chev] [title] [meta] */
details.table-item .content td:not(:last-child){ details.table-item > summary{
border-right: 1px solid var(--bg-card-border); display: grid;
} grid-template-columns: 28px 1fr auto;
gap: 12px;
/* Горизонтальные разделители между строками */ align-items: start; /* важно: всё прижимаем к верху */
details.table-item .content tbody td{ padding: 12px 16px;
border-bottom: 1px solid var(--bg-card-border); }
color: var(--text-primary);
} /* чтобы текст нормально переносился и не растягивал мету */
details.table-item > summary .title{
/* У последней строки нет нижней линии */ min-width: 0; /* важно для grid, иначе может распирать */
details.table-item .content tbody tr:last-child td{ }
border-bottom: none;
} /* "2 записи" всегда справа и сверху, аккуратно */
details.table-item > summary .meta{
/* "Зебра" для читабельности */ justify-self: end;
details.table-item .content tbody tr:nth-child(even){ align-self: start;
background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover)); white-space: nowrap;
} padding-top: 4px; /* чуть опустить относительно первой строки */
font-size: 12px;
/* Ховер по строке */ color: var(--text-secondary);
details.table-item .content tbody tr:hover{ }
background: var(--bg-hover);
} /* стрелка тоже сверху */
details.table-item > summary .chev{
/* (Опционально) Чтобы длинный текст не ломал ширину */ align-self: start;
details.table-item .content td{ margin-top: 2px;
word-break: break-word; }
}
.records-search{
/* (Опционально) если таблица широкая — пусть скроллится горизонтально */ width: min(360px, 60vw);
details.table-item .content{ padding: 0.45rem 0.7rem;
overflow-x: auto; background: var(--bg-input);
} border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
}
.records-search::placeholder{ color: var(--text-placeholder); }
.records-search:focus{
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* Таблица внутри раскрывающегося блока */
details.table-item .content table{
width: 100%;
border-collapse: separate; /* нужно для красивых линий */
border-spacing: 0;
border: 1px solid var(--bg-card-border);
border-radius: 12px;
overflow: hidden;
background: var(--bg-card);
}
/* Шапка */
details.table-item .content thead th{
position: sticky; /* опционально: шапка прилипает при скролле */
top: 0;
z-index: 1;
background: var(--bg-input);
color: var(--text-secondary);
border-bottom: 1px solid var(--bg-card-border);
}
/* Ячейки: одинаковые отступы */
details.table-item .content th,
details.table-item .content td{
padding: 0.75rem 0.85rem;
vertical-align: top;
}
/* Вертикальные разделители между колонками */
details.table-item .content th:not(:last-child),
details.table-item .content td:not(:last-child){
border-right: 1px solid var(--bg-card-border);
}
/* Горизонтальные разделители между строками */
details.table-item .content tbody td{
border-bottom: 1px solid var(--bg-card-border);
color: var(--text-primary);
}
/* У последней строки нет нижней линии */
details.table-item .content tbody tr:last-child td{
border-bottom: none;
}
/* "Зебра" для читабельности */
details.table-item .content tbody tr:nth-child(even){
background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-hover));
}
/* Ховер по строке */
details.table-item .content tbody tr:hover{
background: var(--bg-hover);
}
/* (Опционально) Чтобы длинный текст не ломал ширину */
details.table-item .content td{
word-break: break-word;
}
/* (Опционально) если таблица широкая — пусть скроллится горизонтально */
details.table-item .content{
overflow-x: auto;
}

View File

@@ -12,13 +12,34 @@
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 10; z-index: 1000;
transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
.sidebar-header { .sidebar-header {
padding: 1.25rem; padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border); border-bottom: 1px solid var(--bg-card-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.sidebar-close-btn:hover {
background: var(--bg-card-border);
color: var(--text-primary);
} }
.logo { .logo {
@@ -99,7 +120,7 @@
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
} }
.btn-logout { .btn-settings {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -116,16 +137,189 @@
position: relative; position: relative;
} }
.btn-logout:hover { .btn-settings:hover {
background: rgba(248, 113, 113, 0.1); background: var(--bg-hover);
color: var(--text-primary);
}
.settings-chevron {
margin-left: auto;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.settings-dropdown.open .settings-chevron {
transform: rotate(180deg);
}
/* Settings Dropdown Menu */
.settings-dropdown {
position: relative;
}
.settings-menu {
position: absolute;
bottom: calc(100% + 0.5rem);
left: 0;
min-width: 100%;
width: max-content;
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
padding: 0.5rem;
z-index: 200;
opacity: 0;
visibility: hidden;
transform: translateY(8px) scale(0.98);
transform-origin: bottom center;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.25s ease;
}
[data-theme="light"] .settings-menu {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
}
.settings-dropdown.open .settings-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.settings-menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, color 0.2s ease, padding-left 0.2s ease;
width: 100%;
margin-bottom: 0.15rem;
}
.settings-menu-item:last-child {
margin-bottom: 0;
}
.settings-menu-item:hover {
background: var(--bg-hover);
padding-left: 1.1rem;
}
.settings-menu-item--danger {
color: var(--error); color: var(--error);
} }
.settings-menu-item--danger:hover {
background: rgba(248, 113, 113, 0.1);
padding-left: 1.1rem;
}
.settings-menu-divider {
height: 1px;
background: var(--bg-card-border);
margin: 0.25rem 0.5rem;
}
/* ===== Main ===== */ /* ===== Main ===== */
.main { .main {
flex: 1; flex: 1;
margin-left: 260px; margin-left: 260px;
min-height: 100vh; min-height: 100vh;
transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Desktop Collapse State */
@media (min-width: 769px) {
.sidebar.collapsed {
width: 74px;
}
.sidebar.collapsed .logo span,
.sidebar.collapsed .settings-chevron {
display: none;
}
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .btn-settings span {
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%) translateX(-10px);
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: var(--text-primary);
padding: 0.5rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .sidebar.collapsed .nav-item span,
[data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sidebar.collapsed .nav-item:hover span,
.sidebar.collapsed .btn-settings:hover span {
opacity: 1;
visibility: visible;
transform: translateY(-50%) translateX(0);
}
.sidebar.collapsed .sidebar-close-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .logo {
justify-content: center;
padding: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.75rem 0;
overflow: visible;
}
.sidebar.collapsed .btn-settings {
justify-content: center;
padding: 0.65rem 0;
}
.sidebar.collapsed .sidebar-header {
flex-direction: column;
gap: 1.5rem;
padding: 1.25rem 0;
}
.main.sidebar-collapsed {
margin-left: 74px;
}
.main.sidebar-collapsed .menu-toggle {
display: none;
}
} }
.topbar { .topbar {
@@ -180,7 +374,9 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
z-index: 9; z-index: 9;
opacity: 0; opacity: 0;
transition: opacity var(--transition); visibility: hidden;
pointer-events: none;
transition: opacity var(--transition), visibility var(--transition);
} }
/* ===== Responsive Mobile ===== */ /* ===== Responsive Mobile ===== */
@@ -212,5 +408,7 @@
.sidebar-overlay.open { .sidebar-overlay.open {
opacity: 1; opacity: 1;
visibility: visible;
pointer-events: auto;
} }
} }

View File

@@ -36,6 +36,11 @@
</svg> </svg>
<span>Magistr</span> <span>Magistr</span>
</div> </div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="users"> <a href="#" class="nav-item" data-tab="users">
@@ -46,7 +51,7 @@
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> <path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg> </svg>
Пользователи <span>Пользователи</span>
</a> </a>
<a href="#" class="nav-item" data-tab="department"> <a href="#" class="nav-item" data-tab="department">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" <svg width="20" height="20" viewBox="0 0 24 24" fill="none"
@@ -57,14 +62,14 @@
<path d="M8 11h0M12 11h0M16 11h0" /> <path d="M8 11h0M12 11h0M16 11h0" />
<path d="M10 21v-4h4v4" /> <path d="M10 21v-4h4v4" />
</svg> </svg>
Кафедра <span>Кафедра</span>
</a> </a>
<a href="#" class="nav-item" data-tab="departments-data"> <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> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect> <rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg> </svg>
Создание кафедры/специальности <span>Создание кафедры/специальности</span>
</a> </a>
<a href="#" class="nav-item" data-tab="groups"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -72,7 +77,7 @@
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /> <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" /> <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg> </svg>
Группы <span>Группы</span>
</a> </a>
<a href="#" class="nav-item" data-tab="edu-forms"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -82,7 +87,7 @@
<line x1="9" y1="7" x2="17" y2="7" /> <line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" /> <line x1="9" y1="11" x2="15" y2="11" />
</svg> </svg>
Формы обучения <span>Формы обучения</span>
</a> </a>
<a href="#" class="nav-item" data-tab="equipments"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -90,14 +95,14 @@
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect> <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> <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg> </svg>
Оборудование <span>Оборудование</span>
</a> </a>
<a href="#" class="nav-item" data-tab="classrooms"> <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" <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">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" /> <path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg> </svg>
Аудитории <span>Аудитории</span>
</a> </a>
<a href="#" class="nav-item" data-tab="subjects"> <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" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -105,7 +110,7 @@
<path d="M12 20h9" /> <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" /> <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg> </svg>
Дисциплины <span>Дисциплины</span>
</a> </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">
@@ -114,7 +119,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>
</a> </a>
<a href="#" class="nav-item" data-tab="database"> <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"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -122,19 +127,44 @@
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path> <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> <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg> </svg>
База данных <span>База данных</span>
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="btn-logout" id="btn-logout"> <div class="settings-dropdown" id="settings-dropdown">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <button class="btn-settings" id="btn-settings">
stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 17 21 12 16 7" /> <circle cx="12" cy="12" r="3" />
<line x1="21" y1="12" x2="9" y2="12" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
Выйти <span>Настройки</span>
</button> <svg class="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div class="settings-menu" id="settings-menu">
<a href="/admin/settings/" class="settings-menu-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Общие настройки
</a>
<div class="settings-menu-divider"></div>
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Выйти
</button>
</div>
</div>
</div> </div>
</aside> </aside>

View File

@@ -0,0 +1,222 @@
// dropdown.js - Premium Custom Dropdowns
export class CustomSelect {
constructor(originalSelect) {
if (originalSelect.classList.contains('custom-select-initialized')) return;
this.originalSelect = originalSelect;
this.originalSelect.classList.add('custom-select-initialized');
// Hide original but keep it accessible for form submissions and JS
this.originalSelect.style.display = 'none';
// Bind methods
this.handleTriggerClick = this.handleTriggerClick.bind(this);
this.closeAll = this.closeAll.bind(this);
this.handleItemClick = this.handleItemClick.bind(this);
this.rebuildMenu = this.rebuildMenu.bind(this);
this.init();
// Watch for dynamic changes (like when api fetching populates <option> tags)
this.observer = new MutationObserver((mutations) => {
let shouldRebuild = false;
mutations.forEach(mut => {
if (mut.type === 'childList') shouldRebuild = true;
});
if (shouldRebuild) {
this.rebuildMenu();
}
});
this.observer.observe(this.originalSelect, { childList: true });
// Listen for external value changes (e.g. form.reset())
this.originalSelect.addEventListener('change', () => {
this.syncTriggerText();
});
}
init() {
// Create wrapper
this.wrapper = document.createElement('div');
this.wrapper.className = 'custom-select-wrapper';
// Insert wrapper right after the original select
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect.nextSibling);
// Create trigger button
this.trigger = document.createElement('div');
this.trigger.className = 'custom-select-trigger';
this.trigger.tabIndex = 0; // Make focusable
this.triggerText = document.createElement('span');
this.triggerText.className = 'custom-select-text';
this.triggerIcon = document.createElement('div');
this.triggerIcon.className = 'custom-select-icon';
this.triggerIcon.innerHTML = `<svg 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>`;
this.trigger.appendChild(this.triggerText);
this.trigger.appendChild(this.triggerIcon);
// Create menu
this.menu = document.createElement('ul');
this.menu.className = 'custom-select-menu';
this.wrapper.appendChild(this.trigger);
this.wrapper.appendChild(this.menu);
this.rebuildMenu();
// Events
this.trigger.addEventListener('click', this.handleTriggerClick);
this.trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleTriggerClick(e);
} else if (e.key === 'Escape') {
this.close();
}
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!this.wrapper.contains(e.target)) {
this.close();
}
});
}
rebuildMenu() {
this.menu.innerHTML = '';
const options = Array.from(this.originalSelect.options);
if (options.length === 0) {
const li = document.createElement('li');
li.className = 'custom-select-item disabled';
li.textContent = 'Нет опций';
this.menu.appendChild(li);
} else {
options.forEach((option, index) => {
const li = document.createElement('li');
li.className = 'custom-select-item';
li.textContent = option.text;
li.dataset.value = option.value;
li.dataset.index = index;
if (option.disabled || option.value === '') {
li.classList.add('disabled');
if (option.value === '') li.classList.add('placeholder-item');
} else {
li.addEventListener('click', (e) => this.handleItemClick(e, index));
}
if (option.selected) {
li.classList.add('selected');
}
this.menu.appendChild(li);
});
}
this.syncTriggerText();
}
syncTriggerText() {
const selectedOption = this.originalSelect.options[this.originalSelect.selectedIndex];
if (selectedOption) {
this.triggerText.textContent = selectedOption.text;
if (selectedOption.value === '') {
this.trigger.classList.add('placeholder-active');
} else {
this.trigger.classList.remove('placeholder-active');
}
} else {
this.triggerText.textContent = '—';
this.trigger.classList.add('placeholder-active');
}
// Disable state sync
if (this.originalSelect.disabled) {
this.wrapper.classList.add('disabled');
this.trigger.tabIndex = -1;
} else {
this.wrapper.classList.remove('disabled');
this.trigger.tabIndex = 0;
}
// Highlight selected in menu
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => item.classList.remove('selected'));
if (selectedOption && this.originalSelect.selectedIndex >= 0) {
const activeItem = this.menu.querySelector(`[data-index="${this.originalSelect.selectedIndex}"]`);
if(activeItem) activeItem.classList.add('selected');
}
}
handleTriggerClick(e) {
if (this.originalSelect.disabled) return;
const isOpen = this.wrapper.classList.contains('open');
this.closeAll(); // Close other open dropdowns
if (!isOpen) {
this.wrapper.classList.add('open');
// Scroll selected item into view
const selectedItem = this.menu.querySelector('.selected');
if (selectedItem) {
setTimeout(() => {
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 50);
}
}
}
closeAll() {
document.querySelectorAll('.custom-select-wrapper.open').forEach(wrapper => {
wrapper.classList.remove('open');
});
}
close() {
this.wrapper.classList.remove('open');
}
handleItemClick(e, index) {
e.stopPropagation();
this.originalSelect.selectedIndex = index;
// Trigger native change event so other scripts (users.js) pick it up
this.originalSelect.dispatchEvent(new Event('change', { bubbles: true }));
this.syncTriggerText();
this.close();
}
}
// Global initializer
export function initAllCustomDropdowns(root = document) {
const selects = root.querySelectorAll('select:not(.custom-select-initialized)');
selects.forEach(select => {
new CustomSelect(select);
});
}
// Observe DOM for automatically picking up new select elements
export function startDropdownAutoObserver() {
const observer = new MutationObserver((mutations) => {
let shouldInit = false;
mutations.forEach(mut => {
if (mut.addedNodes.length > 0) {
shouldInit = true;
}
});
if (shouldInit) {
initAllCustomDropdowns(document.body);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}

View File

@@ -1,7 +1,22 @@
import './otel.js'; // OTel: загружаем только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { isAuthenticatedAsAdmin } from './api.js'; import { isAuthenticatedAsAdmin } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
// Auth check
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
import { initUsers } from './views/users.js'; import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js'; import { initGroups } from './views/groups.js';
@@ -37,7 +52,9 @@ const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay'); const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle'); const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
// Initial auth check // Initial auth check
if (!isAuthenticatedAsAdmin()) { if (!isAuthenticatedAsAdmin()) {
@@ -48,16 +65,56 @@ if (!isAuthenticatedAsAdmin()) {
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
// Menu Toggle // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Menu Toggle (Hamburger)
menuToggle.addEventListener('click', () => { menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open'); if (window.innerWidth <= 768) {
sidebarOverlay.classList.toggle('open'); sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
}); });
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => { sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open'); sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open'); sidebarOverlay.classList.remove('open');
}); });
// Settings Dropdown
const settingsDropdown = document.getElementById('settings-dropdown');
const btnSettings = document.getElementById('btn-settings');
btnSettings.addEventListener('click', (e) => {
e.stopPropagation();
settingsDropdown.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target)) {
settingsDropdown.classList.remove('open');
}
});
// Logout // Logout
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', () => {
localStorage.removeItem('token'); localStorage.removeItem('token');

View File

@@ -1,4 +1,398 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDepartment() { } export async function initDepartment() {
const form = document.getElementById('department-schedule-form');
const departmentSelect = document.getElementById('filter-department');
const container = document.getElementById('schedule-blocks-container');
let departments = [];
// Загрузка кафедр
try {
departments = await api.get('/api/departments');
departmentSelect.innerHTML = '<option value="">Выберите кафедру...</option>' +
departments.map(d => `<option value="${d.id}">${escapeHtml(d.departmentName || d.name)}</option>`).join('');
} catch (e) {
departmentSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('schedule-form-alert');
const departmentId = departmentSelect.value;
const period = document.getElementById('filter-period').value;
const semesterType = document.querySelector('input[name="semesterType"]:checked')?.value;
if (!departmentId || !period || !semesterType) {
showAlert('schedule-form-alert', 'Заполните все поля', 'error');
return;
}
const deptName = departmentSelect.options[departmentSelect.selectedIndex].text;
try {
const params = new URLSearchParams({ departmentId, semesterType, period });
const data = await api.get(`/api/department/schedule?${params.toString()}`);
const semesterName = semesterType === 'spring' ? 'весенний' : (semesterType === 'autumn' ? 'осенний' : semesterType);
const periodName = period.replace('-', '/');
renderScheduleBlock(deptName, semesterName, periodName, data);
form.reset();
} catch (err) {
showAlert('schedule-form-alert', err.message || 'Ошибка загрузки данных', 'error');
}
});
function renderScheduleBlock(deptName, semester, period, schedule) {
const details = document.createElement('details');
details.className = 'table-item';
details.open = true;
details.innerHTML = `
<summary>
<div class="chev" aria-hidden="true">
<svg viewBox="0 0 20 20" class="chev-icon" focusable="false" aria-hidden="true">
<path d="M5.5 7.5L10 12l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="title title-multiline">
<span class="title-main">Данные к составлению расписания</span>
<span class="title-sub">Кафедра: <b>${escapeHtml(deptName)}</b></span>
<span class="title-sub">Семестр: <b>${escapeHtml(semester)}</b></span>
<span class="title-sub">Уч. год: <b>${escapeHtml(period)}</b></span>
</div>
<div class="meta">${schedule ? schedule.length : 0} записей</div>
</summary>
<div class="content">
<table>
<thead>
<tr>
<th>Специальность</th>
<th>Курс/семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид занятий</th>
<th>Часов в неделю</th>
<th>Деление на подгруппы</th>
<th>Преподаватель</th>
</tr>
</thead>
<tbody>
${renderRows(schedule)}
</tbody>
</table>
</div>
`;
container.prepend(details);
}
function renderRows(schedule) {
if (!schedule || schedule.length === 0) {
return '<tr><td colspan="8" class="loading-row">Нет данных</td></tr>';
}
return schedule.map(r => `
<tr>
<td>${escapeHtml(r.specialityCode || '-')}</td>
<td>${(() => {
const course = r.groupCourse || '-';
const semester = r.semester || '-';
if (course === '-' && semester === '-') return '-';
return `${course} | ${semester}`;
})()}</td>
<td>${escapeHtml(r.groupName || '-')}</td>
<td>${escapeHtml(r.subjectName || '-')}</td>
<td>${escapeHtml(r.lessonType || '-')}</td>
<td>${escapeHtml(r.numberOfHours || '-')}</td>
<td>${r.division === true ? '✓' : ''}</td>
<td>${(() => {
const jobTitle = r.teacherJobTitle || '-';
const teacherName = r.teacherName || '-';
if (jobTitle === '-' && teacherName === '-') return '-';
return `${jobTitle}, ${teacherName}`;
})()}</td>
</tr>
`).join('');
}
// =========================================================
// ЛОГИКА ДЛЯ ФУНКЦИОНАЛА "СОЗДАТЬ ЗАПИСЬ (К/Ф)"
// Два модальных окна поверх всего контента в одном оверлее
// =========================================================
const btnCreateSchedule = document.getElementById('btn-create-schedule');
const csOverlay = document.getElementById('cs-overlay');
const modalCreateSchedule = document.getElementById('modal-create-schedule');
const modalCreateScheduleClose = document.getElementById('modal-create-schedule-close');
const formCreateSchedule = document.getElementById('create-schedule-form');
const modalViewSchedules = document.getElementById('modal-view-schedules');
const btnSaveSchedules = document.getElementById('btn-save-schedules');
const preparedSchedulesTbody = document.getElementById('prepared-schedules-tbody');
const csGroupSelect = document.getElementById('cs-group');
const csSubjectSelect = document.getElementById('cs-subject');
const csTeacherSelect = document.getElementById('cs-teacher');
const csDepartmentIdInput = document.getElementById('cs-department-id');
let preparedSchedules = [];
let csGroups = [];
let csSubjects = [];
let csTeachers = [];
const SEMESTER_LABELS = { autumn: 'Осенний', spring: 'Весенний' };
const LESSON_TYPE_LABELS = { 1: 'Лекция', 2: 'Практическая работа', 3: 'Лабораторная работа' };
const localDepartmentId = localStorage.getItem('departmentId');
// ===== Загрузка справочников =====
async function loadDictionariesForSchedule() {
try {
csGroups = await api.get('/api/groups');
csGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
csGroups.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join('');
csSubjects = await api.get('/api/subjects');
csSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
csSubjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (localDepartmentId) {
csTeachers = await api.get(`/api/users/teachers/${localDepartmentId}`);
csTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
csTeachers.map(t => `<option value="${t.id}">${escapeHtml(t.fullName || t.username)}</option>`).join('');
} else {
csTeacherSelect.innerHTML = '<option value="">Ошибка: Не найден ID кафедры</option>';
}
} catch (e) {
console.error('Ошибка загрузки справочников:', e);
}
}
loadDictionariesForSchedule();
// ===== Открытие / Закрытие оверлея =====
function openOverlay() {
csOverlay.classList.add('open');
document.body.style.overflow = 'hidden'; // Предотвращаем скролл страницы
}
function closeOverlay() {
csOverlay.classList.remove('open');
document.body.style.overflow = '';
hideAlert('create-schedule-alert');
hideAlert('save-schedules-alert');
}
function updateTableVisibility() {
modalViewSchedules.style.display = preparedSchedules.length > 0 ? '' : 'none';
}
// ===== Кнопка «Создать запись» =====
btnCreateSchedule.addEventListener('click', () => {
if (localDepartmentId) {
csDepartmentIdInput.value = localDepartmentId;
} else {
showAlert('schedule-form-alert', 'Требуется перезайти (отсутствует ID кафедры)', 'error');
return;
}
openOverlay();
});
// ===== Закрытие =====
modalCreateScheduleClose.addEventListener('click', closeOverlay);
csOverlay.addEventListener('click', (e) => {
// Закрыть по клику на затемнённый фон (но не по клику на содержимое модалок)
if (e.target === csOverlay || e.target.classList.contains('cs-overlay-scroll')) {
closeOverlay();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && csOverlay.classList.contains('open')) {
closeOverlay();
}
});
// ===== Рендер таблицы =====
function renderPreparedSchedules() {
if (preparedSchedules.length === 0) {
preparedSchedulesTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет записей</td></tr>';
return;
}
preparedSchedulesTbody.innerHTML = preparedSchedules.map((s, index) => {
const groupName = csGroups.find(g => g.id == s.groupId)?.name || s.groupId;
const subjectName = csSubjects.find(sub => sub.id == s.subjectsId)?.name || s.subjectsId;
const teacherName = csTeachers.find(t => t.id == s.teacherId)?.fullName
|| csTeachers.find(t => t.id == s.teacherId)?.username || s.teacherId;
const lessonTypeName = LESSON_TYPE_LABELS[s.lessonTypeId] || 'Неизвестно';
const semLabel = SEMESTER_LABELS[s.semesterType] || s.semesterType;
const periodDisplay = s.period.replace('-', '/');
const divText = s.division ? '✓' : '';
const hasError = !!s._errorMsg;
const rowStyle = hasError ? ' style="background: rgba(239, 68, 68, 0.08);"' : '';
let row = `
<tr${rowStyle}>
<td>${escapeHtml(periodDisplay)}</td>
<td>${escapeHtml(semLabel)}</td>
<td>${s.semester}</td>
<td>${escapeHtml(String(groupName))}</td>
<td>${escapeHtml(String(subjectName))}</td>
<td>${escapeHtml(lessonTypeName)}</td>
<td>${s.numberOfHours}</td>
<td>${divText}</td>
<td>${escapeHtml(String(teacherName))}</td>
<td><button type="button" class="btn-delete" data-index="${index}">Удалить</button></td>
</tr>`;
if (hasError) {
row += `<tr style="background: rgba(239, 68, 68, 0.05);">
<td colspan="10" style="color: var(--error); font-size: 0.85rem; padding: 0.4rem 0.85rem;">
${escapeHtml(s._errorMsg)}
</td>
</tr>`;
}
return row;
}).join('');
}
// ===== Удаление строки из таблицы =====
preparedSchedulesTbody.addEventListener('click', (e) => {
if (e.target.classList.contains('btn-delete')) {
const idx = parseInt(e.target.getAttribute('data-index'), 10);
preparedSchedules.splice(idx, 1);
renderPreparedSchedules();
updateTableVisibility();
}
});
// ===== Очистка полей формы (частичная) =====
// НЕ очищаем select'ы — они остаются заполненными для удобства.
// Пользователь сам изменит нужные поля для следующей записи.
function clearFormFields() {
document.getElementById('cs-hours').value = '';
document.getElementById('cs-division').checked = false;
}
// ===== Добавление записи в список =====
formCreateSchedule.addEventListener('submit', (e) => {
e.preventDefault();
hideAlert('create-schedule-alert');
const depId = csDepartmentIdInput.value;
const period = document.getElementById('cs-period').value;
const semesterType = document.querySelector('input[name="csSemesterType"]:checked')?.value;
const semester = document.getElementById('cs-semester').value;
const groupId = csGroupSelect.value;
const subjectId = csSubjectSelect.value;
const lessonTypeId = document.getElementById('cs-lesson-type').value;
const hours = document.getElementById('cs-hours').value;
const division = document.getElementById('cs-division').checked;
const teacherId = csTeacherSelect.value;
if (!period || !semesterType || !semester || !groupId || !subjectId || !lessonTypeId || !hours || !teacherId) {
showAlert('create-schedule-alert', 'Заполните все обязательные поля', 'error');
return;
}
const newRecord = {
departmentId: Number(depId),
semester: Number(semester),
groupId: Number(groupId),
subjectsId: Number(subjectId),
lessonTypeId: Number(lessonTypeId),
numberOfHours: Number(hours),
division: division,
teacherId: Number(teacherId),
semesterType: semesterType,
period: period
};
// Проверка на дубликат в уже добавленных записях
const isDuplicate = preparedSchedules.some(s =>
s.period === newRecord.period &&
s.semesterType === newRecord.semesterType &&
s.semester === newRecord.semester &&
s.groupId === newRecord.groupId &&
s.subjectsId === newRecord.subjectsId &&
s.lessonTypeId === newRecord.lessonTypeId &&
s.numberOfHours === newRecord.numberOfHours &&
s.division === newRecord.division &&
s.teacherId === newRecord.teacherId
);
if (isDuplicate) {
showAlert('create-schedule-alert', 'Такая запись уже есть в списке', 'error');
return;
}
preparedSchedules.push(newRecord);
clearFormFields();
showAlert('create-schedule-alert', 'Запись добавлена ✓', 'success');
setTimeout(() => hideAlert('create-schedule-alert'), 2000);
renderPreparedSchedules();
updateTableVisibility();
});
// ===== Сохранение в БД =====
btnSaveSchedules.addEventListener('click', async () => {
if (preparedSchedules.length === 0) {
showAlert('save-schedules-alert', 'Нет записей для сохранения', 'error');
return;
}
btnSaveSchedules.disabled = true;
btnSaveSchedules.textContent = 'Сохранение...';
hideAlert('save-schedules-alert');
let errors = 0;
let saved = 0;
const failedRecords = [];
for (const record of preparedSchedules) {
try {
await api.post('/api/department/schedule/create', record);
saved++;
} catch (err) {
console.error('Ошибка сохранения записи:', err);
errors++;
// Помечаем запись как дубликат, если бэк вернул соответствующую ошибку
const isDuplicate = err.status === 409 ||
(err.message && err.message.toLowerCase().includes('уже существует'));
failedRecords.push({
...record,
_errorMsg: isDuplicate
? 'Такая запись уже есть в базе данных'
: (err.message || 'Ошибка сохранения')
});
}
}
btnSaveSchedules.disabled = false;
btnSaveSchedules.textContent = 'Сохранить в БД';
if (errors === 0) {
showAlert('save-schedules-alert', `Все записи (${saved}) успешно сохранены!`, 'success');
preparedSchedules = [];
renderPreparedSchedules();
updateTableVisibility();
setTimeout(closeOverlay, 2000);
} else {
// Оставляем неудачные записи для повторной попытки / удаления
preparedSchedules = failedRecords;
renderPreparedSchedules();
if (saved > 0) {
showAlert('save-schedules-alert',
`Сохранено: ${saved}. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error');
} else {
showAlert('save-schedules-alert',
`Не удалось сохранить. Ошибок: ${errors}. Проблемные записи отмечены в таблице.`, 'error');
}
}
});
}

View File

@@ -24,7 +24,9 @@ export function renderEquipmentCheckboxes(equipments, containerId, textId, check
const isChecked = checkedIds.includes(eq.id) ? 'checked' : ''; const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return ` return `
<label class="checkbox-item"> <label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)} <input type="checkbox" value="${eq.id}" ${isChecked}>
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label> </label>
`}).join(''); `}).join('');
updateSelectText(containerId, textId); updateSelectText(containerId, textId);

View File

@@ -17,7 +17,7 @@ export async function initGroups() {
populateEfSelects(educationForms); populateEfSelects(educationForms);
await loadGroups(); await loadGroups();
} catch (e) { } catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки данных</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Ошибка загрузки данных</td></tr>';
} }
} }
@@ -26,7 +26,7 @@ export async function initGroups() {
allGroups = await api.get('/api/groups'); allGroups = await api.get('/api/groups');
applyGroupFilter(); applyGroupFilter();
} catch (e) { } catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Ошибка загрузки</td></tr>';
} }
} }
@@ -61,7 +61,7 @@ export async function initGroups() {
function renderGroups(groups) { function renderGroups(groups) {
if (!groups || !groups.length) { if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет групп</td></tr>'; groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
return; return;
} }
groupsTbody.innerHTML = groups.map(g => ` groupsTbody.innerHTML = groups.map(g => `
@@ -71,7 +71,9 @@ export async function initGroups() {
<td>${escapeHtml(g.groupSize)}</td> <td>${escapeHtml(g.groupSize)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td> <td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td>${g.departmentId || '-'}</td> <td>${g.departmentId || '-'}</td>
<td>${g.enrollmentYear || '-'}</td>
<td>${g.course || '-'}</td> <td>${g.course || '-'}</td>
<td>${g.semester || '-'}</td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td> <td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -83,13 +85,13 @@ export async function initGroups() {
const groupSize = document.getElementById('new-group-size').value; const groupSize = document.getElementById('new-group-size').value;
const educationFormId = newGroupEfSelect.value; const educationFormId = newGroupEfSelect.value;
const departmentId = document.getElementById('new-group-department').value; const departmentId = document.getElementById('new-group-department').value;
const course = document.getElementById('new-group-course').value; const enrollmentYear = document.getElementById('new-group-enrollment-year').value;
if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; }
if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; }
if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; } if (!departmentId) { showAlert('create-group-alert', 'Введите ID кафедры', 'error'); return; }
if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; } if (!enrollmentYear) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; }
try { try {
const data = await api.post('/api/groups', { const data = await api.post('/api/groups', {
@@ -97,7 +99,7 @@ export async function initGroups() {
groupSize: Number(groupSize), groupSize: Number(groupSize),
educationFormId: Number(educationFormId), educationFormId: Number(educationFormId),
departmentId: Number(departmentId), departmentId: Number(departmentId),
course: Number(course) enrollmentYear: Number(enrollmentYear)
}); });
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success');
createGroupForm.reset(); createGroupForm.reset();

View File

@@ -213,7 +213,7 @@ export async function initUsers() {
<td>${escapeHtml(u.username)}</td> <td>${escapeHtml(u.username)}</td>
<td>${escapeHtml(u.fullName || '-')}</td> <td>${escapeHtml(u.fullName || '-')}</td>
<td>${escapeHtml(u.jobTitle || '-')}</td> <td>${escapeHtml(u.jobTitle || '-')}</td>
<td>${u.departmentId || '-'}</td> <td>${u.departmentName || '-'}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td> <td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td> <td>
<button class="btn-delete" data-id="${u.id}">Удалить</button> <button class="btn-delete" data-id="${u.id}">Удалить</button>

View File

@@ -0,0 +1,325 @@
/* ===== Sidebar ===== */
.sidebar {
width: 260px;
min-height: 100vh;
background: var(--bg-sidebar);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--bg-card-border);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.sidebar-close-btn:hover {
background: var(--bg-card-border);
color: var(--text-primary);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.25rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
transition: all var(--transition);
position: relative;
overflow: hidden;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 0 4px 4px 0;
transform: scaleY(0);
transition: transform var(--transition);
opacity: 0;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateX(4px);
}
.nav-item.active {
background: rgba(139, 92, 246, 0.12);
color: var(--accent-hover);
}
[data-theme="light"] .nav-item.active {
background: rgba(99, 102, 241, 0.18);
}
.nav-item.active::before {
transform: scaleY(1);
opacity: 1;
}
.nav-item svg {
transition: transform var(--transition);
}
.nav-item:hover svg,
.nav-item.active svg {
transform: scale(1.15) rotate(-5deg);
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
}
.btn-back {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
transition: background var(--transition), color var(--transition);
position: relative;
}
.btn-back:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ===== Main ===== */
.main {
flex: 1;
margin-left: 260px;
min-height: 100vh;
transition: margin-left 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Desktop Collapse State */
@media (min-width: 769px) {
.sidebar.collapsed {
width: 74px;
}
.sidebar.collapsed .logo span {
display: none;
}
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .btn-back span {
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%) translateX(-10px);
background: rgba(10, 10, 15, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: var(--text-primary);
padding: 0.5rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="light"] .sidebar.collapsed .nav-item span,
[data-theme="light"] .sidebar.collapsed .btn-back span {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.sidebar.collapsed .nav-item:hover span,
.sidebar.collapsed .btn-back:hover span {
opacity: 1;
visibility: visible;
transform: translateY(-50%) translateX(0);
}
.sidebar.collapsed .sidebar-close-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .logo {
justify-content: center;
padding: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.75rem 0;
overflow: visible;
}
.sidebar.collapsed .btn-back {
justify-content: center;
padding: 0.65rem 0;
}
.sidebar.collapsed .sidebar-header {
flex-direction: column;
gap: 1.5rem;
padding: 1.25rem 0;
}
.main.sidebar-collapsed {
margin-left: 74px;
}
.main.sidebar-collapsed .menu-toggle {
display: none;
}
}
.topbar {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--bg-card-border);
transition: border-color 0.4s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.topbar h1 {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
flex: 1;
}
.content {
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: fadeIn 0.2s ease;
}
/* ===== Mobile Menu Toggle ===== */
.menu-toggle {
display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.menu-toggle:hover {
background: var(--bg-hover);
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
z-index: 9;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--transition), visibility var(--transition);
}
/* ===== Responsive Mobile ===== */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.main {
margin-left: 0;
}
.topbar {
padding: 1rem 1.25rem;
}
.content {
padding: 1.25rem;
}
.menu-toggle,
.sidebar-overlay {
display: block;
}
.sidebar-overlay.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}

View File

@@ -0,0 +1,153 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0f;
--bg-sidebar: rgba(255, 255, 255, 0.02);
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-placeholder: #475569;
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius-sm: 10px;
--radius-md: 16px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* ===== Light Theme ===== */
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-sidebar: rgba(255, 255, 255, 0.7);
--bg-card: rgba(255, 255, 255, 0.75);
--bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-placeholder: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #d946ef;
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
transition: background 0.4s ease, color 0.4s ease;
}
/* ===== Animations ===== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Theme Toggle ===== */
.theme-toggle {
width: 42px;
height: 42px;
border: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
z-index: 100;
flex-shrink: 0;
}
.theme-toggle svg {
width: 20px;
height: 20px;
transition: transform 0.4s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px var(--accent-glow);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle--fixed {
position: fixed;
top: 1.25rem;
right: 1.25rem;
}
/* ===== Settings Placeholder ===== */
.settings-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 4rem 2rem;
animation: fadeIn 0.4s ease both;
}
.settings-placeholder .icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(236, 72, 153, 0.15));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
}
.settings-placeholder h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.settings-placeholder p {
color: var(--text-secondary);
font-size: 0.95rem;
max-width: 400px;
line-height: 1.6;
}

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройки — Magistr</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="12" fill="url(#lg)" />
<path d="M12 20L18 26L28 14" stroke="#fff" stroke-width="3" stroke-linecap="round"
stroke-linejoin="round" />
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
<stop stop-color="#6366f1" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>Настройки</span>
</div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item" data-tab="general">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>Общие настройки</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/admin/" class="btn-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
<span>Назад в панель</span>
</a>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Main -->
<main class="main">
<header class="topbar">
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 id="page-title">Загрузка...</h1>
</header>
<section class="content" id="app-content">
<!-- Content loaded via JS -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
// Settings page main.js
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
// Auth check
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
window.location.href = '/';
}
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
initAllCustomDropdowns(document.body);
startDropdownAutoObserver();
});
// Configuration
const ROUTES = {
general: { title: 'Общие настройки', file: 'views/general.html' },
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main');
// Init sidebar state from localStorage
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
sidebar.classList.add('collapsed');
main.classList.add('sidebar-collapsed');
}
// Menu Toggle (Hamburger)
menuToggle.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
});
// Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', sidebar.classList.contains('collapsed'));
}
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
try {
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Failed to load view');
const html = await response.text();
appContent.innerHTML = html;
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div style="padding:1rem;color:var(--error);">Ошибка загрузки: ${e.message}</div>`;
console.error(e);
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Load default tab
switchTab('general');

View File

@@ -0,0 +1,11 @@
<div class="settings-placeholder">
<div class="icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
<h2>Общие настройки</h2>
<p>Этот раздел находится в разработке. Здесь будут доступны общие настройки системы.</p>
</div>

View File

@@ -1,193 +1,186 @@
<div class="card"> <div class="card create-card">
<h2>Кафедра</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>Запрос расписания кафедры</h2>
<button id="btn-create-schedule" class="btn-primary" style="margin-top: 0;">Создать запись</button>
</div>
<form id="department-schedule-form">
<div class="form-row">
<div class="form-group">
<label for="filter-department">Кафедра</label>
<select id="filter-department" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Семестр</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="radio" name="semesterType" value="autumn" id="sem-autumn" required>
<span class="checkbox-btn">Осенний</span>
</label>
<label class="btn-checkbox">
<input type="radio" name="semesterType" value="spring" id="sem-spring" required>
<span class="checkbox-btn">Весенний</span>
</label>
</div>
</div>
<div class="form-group">
<label for="filter-period">Учебный год</label>
<select id="filter-period" required>
<option value="">Выберите...</option>
<option value="2022-2023">2022/2023</option>
<option value="2023-2024">2023/2024</option>
<option value="2024-2025">2024/2025</option>
<option value="2025-2026">2025/2026</option>
<option value="2026-2027">2026/2027</option>
</select>
</div>
<button type="submit" class="btn-primary" style="align-self: flex-end;">Запросить</button>
</div>
<div class="form-alert" id="schedule-form-alert" role="alert"></div>
</form>
</div>
<!-- ===== Общий оверлей для обеих модалок ===== -->
<div class="cs-overlay" id="cs-overlay">
<div class="cs-overlay-scroll">
<!-- Модалка 1: Форма создания записи -->
<div class="cs-modal cs-modal-form card" id="modal-create-schedule">
<div class="cs-modal-header">
<h2>Создать запись (к/ф)</h2>
<button class="btn-close-panel" id="modal-create-schedule-close" title="Закрыть (Esc)">&times;</button>
</div>
<form id="create-schedule-form">
<input type="hidden" id="cs-department-id" value="">
<div class="form-row"
style="align-items: flex-start; gap: 1rem; flex-wrap: wrap; width: 100%; justify-content: space-between;">
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-period">Учебный год</label>
<select id="cs-period" required>
<option value="">Выберите...</option>
<option value="2024-2025">2024/2025</option>
<option value="2025-2026">2025/2026</option>
<option value="2026-2027">2026/2027</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label>Семестр</label>
<div style="display: flex; gap: 0.2rem;">
<label class="btn-checkbox">
<input type="radio" name="csSemesterType" value="autumn" required>
<span class="checkbox-btn">Осенний</span>
</label>
<label class="btn-checkbox">
<input type="radio" name="csSemesterType" value="spring" required>
<span class="checkbox-btn">Весенний</span>
</label>
</div>
</div>
<div class="form-group" style="flex: 1 1 150px;">
<label for="cs-semester">Курс/Семестр (номер)</label>
<input type="number" id="cs-semester" required min="1" max="12" placeholder="Например: 1">
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-group">Группа</label>
<select id="cs-group" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-subject">Дисциплина</label>
<select id="cs-subject" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label for="cs-lesson-type">Вид занятий</label>
<select id="cs-lesson-type" required>
<option value="">Выберите тип</option>
<option value="1">Лекция</option>
<option value="2">Практическая работа</option>
<option value="3">Лабораторная работа</option>
</select>
</div>
<div class="form-group" style="flex: 1 1 150px;">
<label for="cs-hours">Часов (семестр)</label>
<input type="number" id="cs-hours" required min="1" max="500" placeholder="Например: 36">
</div>
<div class="form-group" style="flex: 1 1 180px;">
<label>Деление на подгруппы</label>
<div style="display: flex; gap: 0.5rem; align-items: center; height: 42px;">
<label class="btn-checkbox" style="width:100%;">
<input type="checkbox" id="cs-division" value="true">
<span class="checkbox-btn" style="width:100%; text-align:center;">Есть деление</span>
</label>
</div>
</div>
<div class="form-group" style="flex: 1 1 250px;">
<label for="cs-teacher">Преподаватель</label>
<select id="cs-teacher" required>
<option value="">Выберите преподавателя</option>
</select>
</div>
<div class="form-group" style="flex: 0 0 auto; display:flex; align-items: flex-end;">
<button type="submit" class="btn-primary" style="white-space: nowrap;">Добавить в список</button>
</div>
</div>
<div class="form-alert" id="create-schedule-alert" role="alert" style="margin-top: 1rem;"></div>
</form>
</div>
<!-- Модалка 2: Таблица подготовленных записей -->
<div class="cs-modal cs-modal-table card" id="modal-view-schedules" style="display: none;">
<div class="cs-modal-header">
<h2>Подготовленные записи</h2>
<div style="display:flex; gap: 0.75rem; align-items:center;">
<button id="btn-save-schedules" class="btn-primary">Сохранить в БД</button>
</div>
</div>
<div class="form-alert" id="save-schedules-alert" role="alert" style="margin-bottom: 1rem;"></div>
<div class="table-wrap">
<table id="prepared-schedules-table">
<thead>
<tr>
<th>Уч. год</th>
<th>Семестр</th>
<th></th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид</th>
<th>Часы</th>
<th>Деление</th>
<th>Преподаватель</th>
<th>Действие</th>
</tr>
</thead>
<tbody id="prepared-schedules-tbody">
<tr>
<td colspan="10" class="loading-row">Нет записей</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="filter-row" style="gap:.75rem;">
<label for="recordsSearch">Поиск</label>
<input
id="recordsSearch"
class="records-search"
type="search"
placeholder="Группа, дисциплина, преподаватель…"
autocomplete="off"
/>
<button type="button" class="btn-delete" id="recordsSearchClear">Сброс</button>
</div> </div>
</div> </div>
<div class="table-wrap" id="schedule-blocks-container">
<div class="table-wrap"> <!-- Сгенерированные блоки таблиц будут появляться здесь -->
<!-- Таблица 1 -->
<details class="table-item">
<summary>
<div class="chev" aria-hidden="true">
<svg viewBox="0 0 20 20" class="chev-icon" focusable="false" aria-hidden="true">
<path d="M5.5 7.5L10 12l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="title title-multiline">
<span class="title-main">Данные к составлению расписания</span>
<span class="title-sub">Кафедра: <b>Информационная безопасность</b></span>
<span class="title-sub">Факультет: <b>ФиПИ</b></span>
<span class="title-sub">Семестр: <b>весенний</b></span>
<span class="title-sub">Уч. год: <b>2024/2025</b></span>
</div>
<div class="meta">3 записи</div>
</summary>
<div class="content">
<table>
<thead>
<tr>
<th>Специальность</th>
<th>Курс и семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид занятий</th>
<th>Часов в неделю</th>
<th>Деление на подгруппы</th>
<th>Фамилия преподавателя</th>
</tr>
</thead>
<tbody>
<!-- 1 строка = 1 запись HARDCODE -->
<tr>
<td>09.02.07</td>
<td>2 курс, 4 семестр</td>
<td>ИС-21</td>
<td>Базы данных</td>
<td>Лабораторная</td>
<td>2</td>
<td>Да</td>
<td>Иванов</td>
</tr>
<tr>
<td>09.02.07</td>
<td>2 курс, 4 семестр</td>
<td>ИС-22</td>
<td>Операционные системы</td>
<td>Практика</td>
<td>1</td>
<td>Нет</td>
<td>Смирнов</td>
</tr>
<tr>
<td>09.02.07</td>
<td>1 курс, 2 семестр</td>
<td>ИС-12</td>
<td>Алгоритмы</td>
<td>Лекция</td>
<td>2</td>
<td>Нет</td>
<td>Кузнецов</td>
</tr>
</tbody>
</table>
</div>
</details>
<!-- Таблица 2 -->
<details class="table-item">
<summary>
<div class="chev" aria-hidden="true">
<svg viewBox="0 0 20 20" class="chev-icon" focusable="false" aria-hidden="true">
<path d="M5.5 7.5L10 12l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="title">orders</div>
<div class="meta">1 запись</div>
</summary>
<div class="content">
<table>
<thead>
<tr>
<th>Специальность</th>
<th>Курс и семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид занятий</th>
<th>Часов в неделю</th>
<th>Деление на подгруппы</th>
<th>Фамилия преподавателя</th>
</tr>
</thead>
<tbody>
<tr>
<td>38.02.01</td>
<td>1 курс, 1 семестр</td>
<td>ЭК-11</td>
<td>Экономика</td>
<td>Лекция</td>
<td>1</td>
<td>Нет</td>
<td>Петров</td>
</tr>
</tbody>
</table>
</div>
</details>
<!-- Таблица 3 -->
<details class="table-item">
<summary>
<div class="chev" aria-hidden="true">
<svg viewBox="0 0 20 20" class="chev-icon" focusable="false" aria-hidden="true">
<path d="M5.5 7.5L10 12l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="title">products</div>
<div class="meta">2 записи</div>
</summary>
<div class="content">
<table>
<thead>
<tr>
<th>Специальность</th>
<th>Курс и семестр</th>
<th>Группа</th>
<th>Дисциплина</th>
<th>Вид занятий</th>
<th>Часов в неделю</th>
<th>Деление на подгруппы</th>
<th>Фамилия преподавателя</th>
</tr>
</thead>
<tbody>
<tr>
<td>15.02.08</td>
<td>3 курс, 6 семестр</td>
<td>МС-31</td>
<td>Материаловедение</td>
<td>Практика</td>
<td>3</td>
<td>Да</td>
<td>Сидоров</td>
</tr>
<tr>
<td>15.02.08</td>
<td>3 курс, 6 семестр</td>
<td>МС-32</td>
<td>Технология металлов</td>
<td>Лабораторная</td>
<td>2</td>
<td>Да</td>
<td>Орлов</td>
</tr>
</tbody>
</table>
</div>
</details>
</div>
</div> </div>

View File

@@ -22,8 +22,8 @@
<input type="number" id="new-group-department" placeholder="ID" required> <input type="number" id="new-group-department" placeholder="ID" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-group-course">Курс</label> <label for="new-group-enrollment-year">Год начала обучения</label>
<input type="number" id="new-group-course" placeholder="1-6" min="1" max="6" required> <input type="number" id="new-group-enrollment-year" placeholder="2023" min="2000" max="2100" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn-primary">Создать</button>
</div> </div>
@@ -50,13 +50,15 @@
<th>Численность (чел.)</th> <th>Численность (чел.)</th>
<th>Форма обучения</th> <th>Форма обучения</th>
<th>ID кафедры</th> <th>ID кафедры</th>
<th>Год начала</th>
<th>Курс</th> <th>Курс</th>
<th>Семестр</th>
<th>Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody id="groups-tbody"> <tbody id="groups-tbody">
<tr> <tr>
<td colspan="7" class="loading-row">Загрузка...</td> <td colspan="9" class="loading-row">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -28,7 +28,7 @@
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required> <input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-department">ID кафедры</label> <label for="new-department">ID Кафедры</label>
<input type="number" id="new-department" placeholder="ID" required> <input type="number" id="new-department" placeholder="ID" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn-primary">Создать</button>
@@ -47,7 +47,7 @@
<th>Имя пользователя</th> <th>Имя пользователя</th>
<th>ФИО</th> <th>ФИО</th>
<th>Должность</th> <th>Должность</th>
<th>ID кафедры</th> <th>Кафедра</th>
<th>Роль</th> <th>Роль</th>
<th colspan="2">Действия</th> <th colspan="2">Действия</th>
</tr> </tr>

View File

@@ -2,29 +2,31 @@
'use strict'; 'use strict';
// --- OpenTelemetry Frontend Instrumentation --- // --- OpenTelemetry Frontend Instrumentation ---
// Загружаем OTel Web SDK динамически через esm.sh, чтобы не ломать старый Vanilla JS (без type="module") // Загружаем OTel только на продакшене (не на localhost)
import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => { if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http'); import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => {
const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web'); const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http');
const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation'); const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web');
const { Resource } = await import('https://esm.sh/@opentelemetry/resources'); const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation');
const { Resource } = await import('https://esm.sh/@opentelemetry/resources');
const exporter = new OTLPTraceExporter({ const exporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces' // Трафик пойдет через ваш Caddy Proxy url: window.location.origin + '/otel/v1/traces'
}); });
const provider = new WebTracerProvider({ const provider = new WebTracerProvider({
resource: new Resource({ 'service.name': 'magistr-frontend' }), resource: new Resource({ 'service.name': 'magistr-frontend' }),
}); });
provider.addSpanProcessor(new BatchSpanProcessor(exporter)); provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register(); provider.register();
registerInstrumentations({ registerInstrumentations({
instrumentations: [getWebAutoInstrumentations()] instrumentations: [getWebAutoInstrumentations()]
}); });
console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде."); console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде.");
}).catch(e => console.error("Ошибка загрузки OTel:", e)); }).catch(e => console.error("Ошибка загрузки OTel:", e));
}
// ---------------------------------------------- // ----------------------------------------------
const form = document.getElementById('login-form'); const form = document.getElementById('login-form');
@@ -141,6 +143,7 @@
if (data.token) localStorage.setItem('token', data.token); if (data.token) localStorage.setItem('token', data.token);
if (data.role) localStorage.setItem('role', data.role); if (data.role) localStorage.setItem('role', data.role);
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
const redirect = data.redirect || '/'; const redirect = data.redirect || '/';
setTimeout(() => { window.location.href = redirect; }, 400); setTimeout(() => { window.location.href = redirect; }, 400);

120
tz2.md Normal file
View File

@@ -0,0 +1,120 @@
# План выполнения работ по новым интерфейсам расписания
На основе предоставленного технического задания составлен следующий детализированный план разработки макетов и функционала новой подсистемы составления и просмотра расписания.
## 1. Вкладка "Загрузка аудиторий"
**Концепция:** Динамическая таблица, визуализирующая текущее использование аудиторного фонда в конкретную учебную неделю.
### Интерфейс
* **Сетка данных:**
* **Столбцы:** Аудитории.
* **Строки:** Время пар (расписание звонков).
* **Ячейки:** Информация о проходящем занятии (Группа, Преподаватель, Дисциплина).
* **Элементы управления:**
* **Календарь недель:** Выпадающий список или слайдер для переключения между учебными неделями семестра. Учитывает изменения в графике (сессии, приезд заочников и т.д.).
* **Фильтр аудиторий:** Чекбоксы, мультиселект или группировка по корпусам/типам, позволяющие скрывать неотображаемые аудитории для удобства просмотра.
### Функционал
* Отображение данных на основе сохраненного расписания из БД с привязкой к выбранной неделе.
* **Интерактивность:** Возможность клика по пустой ячейке для добавления нового занятия. Открывается модальное окно с предзаполненными полями `Аудитория`, `Время` и `Неделя`.
---
## 2. Вкладка "Загруженность преподавателей"
**Концепция:** Интерфейс, дублирующий логику загрузки аудиторий, но с фокусом на профессорско-преподавательский состав (ППС).
### Интерфейс
* **Сетка данных:**
* **Столбцы:** Список преподавателей.
* **Строки:** Время пар.
* **Ячейки:** Информация о занятии (Группа, Аудитория, Дисциплина).
* **Элементы управления:**
* Календарь недель (аналогично аудиториям).
* Поиск/фильтрация по ФИО преподавателя или кафедре.
---
## 3. Рабочее окно составителя расписания
**Концепция:** Основной инструмент диспетчера. Интерактивная среда для распределения нагрузки.
### Интерфейс
* **Сетка расписания:**
* **Столбцы:** Учебные группы.
* **Строки:** Время пар.
* **Панель нагрузки:** Боковая панель или вызываемое окно со списком нераспределенных предметов для выбранной группы/курса.
### Алгоритм работы (User Flow)
1. **Старт:** Диспетчер кликает в конкретную ячейку сетки (выбирает группу и время проведения пары).
2. **Выбор предмета:** Появляется меню со списком предметов, которые необходимо поставить данной группе. Диспетчер выбирает нужный.
3. **Выбор преподавателя и проверка его занятости:**
* Система автоматически подтягивает преподавателя (или список возможных), закрепленного за этой дисциплиной.
* Отображается **карта свободных слотов преподавателя**, чтобы убедиться, что он не ведет пару в это же время у другой группы (предотвращение накладок).
4. **Выбор аудитории (Умный подбор):**
* Если преподаватель свободен, всплывает **карта загрузки аудиторий**.
* Аудитории отображаются с цветовой индикацией:
* 🟢 **Зеленый:** Аудитория свободна и её характеристики (тип, вместимость) полностью подходят для занятия.
* 🟡 **Желтый:** Аудитория свободна, но не подходит по требованиям (например, это лекционный зал для маленькой группы, или обычная аудитория для компьютерного практикума).
* 🔴 **Красный:** Аудитория занята (при наведении или клике показывается, кто именно там занимается).
* Диспетчер выбирает подходящую аудиторию.
5. **Финал:** Занятие фиксируется в сетке, предмет вычитается из пула нераспределенной нагрузки.
---
## Этапы реализации и анализ архитектуры БД
На основе анализа существующей базы данных проекта (см. `docs/DATABASE.md`) выявлено, что значительная часть необходимых данных уже присутствует, однако для полного удовлетворения ТЗ требуются точечные доработки структуры БД.
### Анализ требований ТЗ и текущей БД
1. **"Аудитории: нет пункта о том, в каком корпусе она находится"**:
* **Текущее состояние в БД**: В таблице `classrooms` **уже существуют** поля `building` (Корпус) и `floor` (Этаж).
* **Вывод**: Добавление характеристик корпуса в БД не требуется. Информацию нужно просто вывести через Backend API на Frontend.
2. **Динамическое расписание и календарь недель ("закончился семестр у магистров", "заочники")**:
* **Текущее состояние в БД**: Таблица `lessons` содержит поле `week` (с текстовыми значениями `Верхняя / Нижняя / Обе`), что подразумевает статический цикличный график (раз в 2 недели).
* **Чего не хватает**: Текущая схема не позволяет гибко привязывать занятия к конкретным календарным датам или конкретным учебным неделям семестра (например, с 1 по 18 неделю).
* **Вывод**: Потребуется миграция БД для внедрения календаря (например, таблица `academic_weeks` или изменение структуры `lessons`).
3. **Умный подбор аудиторий (желтая индикация — "не подходит оборудование")**:
* **Текущее состояние в БД**: Есть таблица `classroom_equipments`, описывающая инвентарь аудитории.
* **Чего не хватает**: В системе отсутствует информация о том, какое оборудование **требуется** для конкретной дисциплины.
* **Вывод**: Необходимо добавить новую связующую таблицу (например, `subject_equipments` или `lesson_type_equipments`), чтобы алгоритм мог сопоставлять требования предмета с оснащением выбранной аудитории.
4. **Списки нагрузки для распределения**:
* **Текущее состояние в БД**: Присутствует таблица `schedule_data` со столбцом `number_of_hours` (часы, подлежащие распределению).
* **Вывод**: Архитектура готова. Потребуется лишь бизнес-логика для связывания созданных записей `lessons` с нераспределенной нагрузкой `schedule_data` (для вычета распределенных часов).
---
### Детализированный план реализации
#### Этап 1: Доработка базы данных (Flyway миграции)
* **Миграция БД (Календарь):** Проектирование и создание механизма привязки расписания к конкретным неделям/датам, отход от жесткой привязки "Верхняя/Нижняя".
* **Миграция БД (Оборудование):** Создание таблицы для хранения технических требований дисциплин к аудиториям (`subject_equipments`), чтобы стала возможна "желтая" индикация.
* *(Напоминание: все миграции создаются как новые файлы `V2__...sql`, `V3__...sql` в директории `db/migration/`, изменение `V1__init.sql` запрещено).*
#### Этап 2: Разработка Backend API (Java Spring Boot)
* **Эндпоинты получения видов (View API):**
* API для сетки аудиторий: агрегация занятий по аудиториям с учетом выбранной недели.
* API для сетки преподавателей: агрегация занятий по преподавателям.
* API нераспределенной нагрузки: получение остатка часов из `schedule_data` для выбранной группы.
* **Интеллектуальные алгоритмы проверок (Service Layer):**
* Логика проверки накладок преподавателей.
* Алгоритм "Цветофор" для аудитории:
* Красный (занятость по времени).
* Желтый (сопоставление вместимости `capacity` с `group_size` + проверка наличия нужного оборудования).
* Зеленый (все проверки пройдены).
#### Этап 3: UI-разработка (Frontend)
* Верстка трех основных табличных сеток (Audience Load, Teacher Workload, Schedule Maker).
* Реализация календаря/селектора недель (влияющего на выводимые данные).
* Программирование интерактивного Flow диспетчера в Vanilla JS:
1. Клик в ячейку.
2. Вызов списка нагрузки -> выбор предмета.
3. Отображение свободных слотов преподавателя.
4. Вывод карты аудиторий с динамической цветовой индикацией.
5. Сохранение результата.
#### Этап 4: Интеграция и стабилизация
* Интеграция Front и Back-частей.
* Сквозное тестирование сценариев создания, редактирования и удаления занятий с пересчетом часов нагрузки.