Compare commits
30 Commits
e03a68b7a8
...
course-sem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82ed69639 | ||
|
|
cd6cc6f5f7 | ||
| 2be2534a1e | |||
| b14d937062 | |||
| 9d06c99d06 | |||
|
|
522bc97b8c | ||
|
|
d0a8148fa0 | ||
|
|
0b9d063266 | ||
|
|
6f33e23e17 | ||
|
|
bfdcb58c7d | ||
|
|
e015758caf | ||
|
|
6be8db0cd0 | ||
|
|
7a2c385257 | ||
| f7483e7aeb | |||
|
|
55da934545 | ||
|
|
e71bcee9b5 | ||
| 7ce0d1e501 | |||
|
|
3861fa05b5 | ||
|
|
599e284ea9 | ||
|
|
ec7e615557 | ||
|
|
9e7b35aa0b | ||
|
|
4915e6f33b | ||
|
|
798d61c7ea | ||
|
|
0817961d97 | ||
|
|
49ca2e17b6 | ||
|
|
c07e49ca98 | ||
|
|
b89d1c7f72 | ||
| 6774ebb766 | |||
| f7fb524bb0 | |||
| d78e675a71 |
@@ -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` |
|
||||||
|
|
||||||
@@ -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) | Использование дизайн-системы (кастомные селекты, чекбоксы и др.) |
|
||||||
|
|||||||
@@ -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 проекте. Главное, помните: у каждого тенанта — своё изолированное хранилище!
|
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.CreateDepartmentRequest;
|
||||||
|
import com.magistr.app.dto.DepartmentResponse;
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
|
import com.magistr.app.repository.DepartmentRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/departments")
|
||||||
|
public class DepartmentController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(DepartmentController.class);
|
||||||
|
|
||||||
|
private final DepartmentRepository departmentRepository;
|
||||||
|
|
||||||
|
public DepartmentController(DepartmentRepository departmentRepository) {
|
||||||
|
this.departmentRepository = departmentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Department> getAllDepartments() {
|
||||||
|
logger.info("Получен запрос на получение списка кафедр");
|
||||||
|
try {
|
||||||
|
List<Department> departments = departmentRepository.findAll();
|
||||||
|
List<Department> response = departments.stream()
|
||||||
|
.map( d -> new Department(
|
||||||
|
d.getId(),
|
||||||
|
d.getDepartmentName(),
|
||||||
|
d.getDepartmentCode()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} кафедр", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка кафедр: {}", e.getMessage(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> createDepartment(@RequestBody CreateDepartmentRequest request) {
|
||||||
|
logger.info("Получен запрос на создание кафедры: name = {}, code = {}", request.getDepartmentName(), request.getDepartmentCode());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (request.getDepartmentName() == null || request.getDepartmentName().isBlank()){
|
||||||
|
String errorMessage = "Название кафедры обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (departmentRepository.findByDepartmentName(request.getDepartmentName().trim()).isPresent()) {
|
||||||
|
String errorMessage = "Кафедра с таким названием уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getDepartmentCode() == null || request.getDepartmentCode() == 0) {
|
||||||
|
String errorMessage = "Код кафедры обязателен";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (departmentRepository.findByDepartmentCode(request.getDepartmentCode()).isPresent()) {
|
||||||
|
String errorMessage = "Кафедра с таким кодом уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Department department = new Department();
|
||||||
|
department.setDepartmentName(request.getDepartmentName());
|
||||||
|
department.setDepartmentCode(request.getDepartmentCode());
|
||||||
|
departmentRepository.save(department);
|
||||||
|
|
||||||
|
logger.info("Кафедра успешно создана с ID: {}", department.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new DepartmentResponse(
|
||||||
|
department.getId(),
|
||||||
|
department.getDepartmentName(),
|
||||||
|
department.getDepartmentCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} 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<?> deleteDepartment(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление кафедры с ID: {}", id);
|
||||||
|
if (!departmentRepository.existsById(id)) {
|
||||||
|
logger.info("Кафедра с ID - {} не найдена", id);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
departmentRepository.deleteById(id);
|
||||||
|
logger.info("Кафедра с ID - {} успешно удалена", id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Кафедра удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import com.magistr.app.model.EducationForm;
|
|||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.repository.EducationFormRepository;
|
import com.magistr.app.repository.EducationFormRepository;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
import com.magistr.app.repository.GroupRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ import java.util.Optional;
|
|||||||
@RequestMapping("/api/groups")
|
@RequestMapping("/api/groups")
|
||||||
public class GroupController {
|
public class GroupController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupController.class);
|
||||||
|
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final EducationFormRepository educationFormRepository;
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
|
||||||
@@ -28,56 +33,139 @@ public class GroupController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<GroupResponse> getAllGroups() {
|
public List<GroupResponse> getAllGroups() {
|
||||||
return groupRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех групп");
|
||||||
.map(g -> new GroupResponse(
|
|
||||||
|
try {
|
||||||
|
List<StudentGroup> groups = groupRepository.findAll();
|
||||||
|
|
||||||
|
List<GroupResponse> response = groups.stream()
|
||||||
|
.map(g -> new GroupResponse(
|
||||||
g.getId(),
|
g.getId(),
|
||||||
g.getName(),
|
g.getName(),
|
||||||
g.getGroupSize(),
|
g.getGroupSize(),
|
||||||
g.getEducationForm().getId(),
|
g.getEducationForm().getId(),
|
||||||
g.getEducationForm().getName()))
|
g.getEducationForm().getName(),
|
||||||
.toList();
|
g.getDepartmentId(),
|
||||||
|
g.getEnrollmentYear(),
|
||||||
|
g.getCourse(),
|
||||||
|
g.getSemester(),
|
||||||
|
g.getSpecialityCode()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} групп", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка групп: {}", e.getMessage(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{departmentId}")
|
||||||
|
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||||
|
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||||
|
try {
|
||||||
|
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId);
|
||||||
|
|
||||||
|
if(groups.isEmpty()) {
|
||||||
|
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body("Группы для указанной кафедры не найдены");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Найдено {} групп для кафедры с ID - {}", groups.size(), departmentId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(groups);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Получена ошибка при получении списка групп для кафедры с ID - {}: {}", departmentId, e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Произошла ошибка при получении списка групп");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||||
if (request.getName() == null || request.getName().isBlank()) {
|
logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, enrollmentYear = {}",
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
|
request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getEnrollmentYear());
|
||||||
}
|
try {
|
||||||
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
|
String errorMessage = "Название группы обязательно";
|
||||||
}
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
if (request.getGroupSize() == null) {
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Численность группы обязательна"));
|
}
|
||||||
}
|
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
|
||||||
if (request.getEducationFormId() == null) {
|
String errorMessage = "Группа с таким названием уже существует";
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
}
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getGroupSize() == null) {
|
||||||
|
String errorMessage = "Численность группы обязательна";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getEducationFormId() == null) {
|
||||||
|
String errorMessage = "Форма обучения обязательна";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
String errorMessage = "ID кафедры обязателен";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getEnrollmentYear() == null || request.getEnrollmentYear() == 0) {
|
||||||
|
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);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||||
if (efOpt.isEmpty()) {
|
if (efOpt.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||||
|
}
|
||||||
|
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setName(request.getName().trim());
|
||||||
|
group.setGroupSize(request.getGroupSize());
|
||||||
|
group.setEducationForm(efOpt.get());
|
||||||
|
group.setDepartmentId(request.getDepartmentId());
|
||||||
|
group.setEnrollmentYear(request.getEnrollmentYear());
|
||||||
|
group.setSpecialityCode(request.getSpecialityCode());
|
||||||
|
groupRepository.save(group);
|
||||||
|
|
||||||
|
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new GroupResponse(
|
||||||
|
group.getId(),
|
||||||
|
group.getName(),
|
||||||
|
group.getGroupSize(),
|
||||||
|
group.getEducationForm().getId(),
|
||||||
|
group.getEducationForm().getName(),
|
||||||
|
group.getDepartmentId(),
|
||||||
|
group.getEnrollmentYear(),
|
||||||
|
group.getCourse(),
|
||||||
|
group.getSemester(),
|
||||||
|
group.getSpecialityCode()));
|
||||||
|
} catch (Exception e ) {
|
||||||
|
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
StudentGroup group = new StudentGroup();
|
|
||||||
group.setName(request.getName().trim());
|
|
||||||
group.setGroupSize(request.getGroupSize());
|
|
||||||
group.setEducationForm(efOpt.get());
|
|
||||||
groupRepository.save(group);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new GroupResponse(
|
|
||||||
group.getId(),
|
|
||||||
group.getName(),
|
|
||||||
group.getGroupSize(),
|
|
||||||
group.getEducationForm().getId(),
|
|
||||||
group.getEducationForm().getName()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление группы с ID - {}", id);
|
||||||
if (!groupRepository.existsById(id)) {
|
if (!groupRepository.existsById(id)) {
|
||||||
|
logger.info("Группа с ID - {} не найдена", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
groupRepository.deleteById(id);
|
groupRepository.deleteById(id);
|
||||||
|
logger.info("Группа с ID - {} успешно удалена", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.CreateScheduleDataRequest;
|
||||||
|
import com.magistr.app.dto.ScheduleResponse;
|
||||||
|
import com.magistr.app.model.*;
|
||||||
|
import com.magistr.app.repository.*;
|
||||||
|
import com.magistr.app.utils.SemesterTypeValidator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/department/schedule")
|
||||||
|
public class ScheduleDataController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ScheduleDataController.class);
|
||||||
|
|
||||||
|
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, GroupRepository groupRepository, SpecialtiesRepository specialtiesRepository, SubjectRepository subjectRepository, LessonTypesRepository lessonTypesRepository, UserRepository userRepository) {
|
||||||
|
this.scheduleDataRepository = scheduleDataRepository;
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
|
this.subjectRepository = subjectRepository;
|
||||||
|
this.lessonTypesRepository = lessonTypesRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/allList")
|
||||||
|
public List<ScheduleData> getAllScheduleDataList() {
|
||||||
|
logger.info("Получен запрос на получение списка данных расписаний");
|
||||||
|
try {
|
||||||
|
List<ScheduleData> scheduleData = scheduleDataRepository.findAll();
|
||||||
|
List<ScheduleData> response = scheduleData.stream()
|
||||||
|
.map(s -> new ScheduleData(
|
||||||
|
s.getId(),
|
||||||
|
s.getDepartmentId(),
|
||||||
|
s.getSemester(),
|
||||||
|
s.getGroupId(),
|
||||||
|
s.getSubjectsId(),
|
||||||
|
s.getLessonTypeId(),
|
||||||
|
s.getNumberOfHours(),
|
||||||
|
s.getDivision(),
|
||||||
|
s.getTeacherId(),
|
||||||
|
s.getSemesterType(),
|
||||||
|
s.getPeriod()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} записей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка данных расписаний: {}", e.getMessage(), 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", "Запись удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.CreateSpecialityRequest;
|
||||||
|
import com.magistr.app.dto.SpecialityResponse;
|
||||||
|
import com.magistr.app.model.Speciality;
|
||||||
|
import com.magistr.app.repository.SpecialtiesRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/specialties")
|
||||||
|
public class SpecialityController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class);
|
||||||
|
|
||||||
|
private final SpecialtiesRepository specialtiesRepository;
|
||||||
|
|
||||||
|
public SpecialityController(SpecialtiesRepository specialtiesRepository) {
|
||||||
|
this.specialtiesRepository = specialtiesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Speciality> getAllSpecialties() {
|
||||||
|
logger.info("Получен запрос на получение списка специальностей");
|
||||||
|
try {
|
||||||
|
List<Speciality> specialities = specialtiesRepository.findAll();
|
||||||
|
List<Speciality> response = specialities.stream()
|
||||||
|
.map( s -> new Speciality(
|
||||||
|
s.getId(),
|
||||||
|
s.getSpecialityName(),
|
||||||
|
s.getSpecialityCode()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} специальностей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка специальностей: {}", e.getMessage(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> createSpeciality(@RequestBody CreateSpecialityRequest request) {
|
||||||
|
logger.info("Получен запрос на создание специальности: name = {}, code = {}", request.getSpecialityName(), request.getSpecialityCode());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (request.getSpecialityName() == null || request.getSpecialityName().isBlank()) {
|
||||||
|
String errorMessage = "Название специальности обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (specialtiesRepository.findBySpecialityName(request.getSpecialityName().trim()).isPresent()) {
|
||||||
|
String errorMessage = "Специальность с таким названием уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getSpecialityCode() == null || request.getSpecialityCode().isBlank()) {
|
||||||
|
String errorMessage = "Код специальности обязателен";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (specialtiesRepository.findBySpecialityCode(request.getSpecialityCode().trim()).isPresent()) {
|
||||||
|
String errorMessage = "Специальность с таким кодом уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Speciality speciality = new Speciality();
|
||||||
|
speciality.setSpecialityName(request.getSpecialityName());
|
||||||
|
speciality.setSpecialityCode(request.getSpecialityCode());
|
||||||
|
specialtiesRepository.save(speciality);
|
||||||
|
|
||||||
|
logger.info("Специальность успешно создана с ID: {}", speciality.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new SpecialityResponse(
|
||||||
|
speciality.getId(),
|
||||||
|
speciality.getSpecialityName(),
|
||||||
|
speciality.getSpecialityCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} 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<?> deleteSpeciality(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление специальности с ID: {}", id);
|
||||||
|
if (!specialtiesRepository.existsById(id)) {
|
||||||
|
logger.info("Специальность с ID - {} не найдена", id);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
specialtiesRepository.deleteById(id);
|
||||||
|
logger.info("Специальность с ID - {} успешно удалена", id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Специальнсть удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.CreateSubjectRequest;
|
||||||
|
import com.magistr.app.dto.SubjectResponse;
|
||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -12,6 +18,8 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/subjects")
|
@RequestMapping("/api/subjects")
|
||||||
public class SubjectController {
|
public class SubjectController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SubjectController.class);
|
||||||
|
|
||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
|
|
||||||
public SubjectController(SubjectRepository subjectRepository) {
|
public SubjectController(SubjectRepository subjectRepository) {
|
||||||
@@ -20,32 +28,105 @@ public class SubjectController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Subject> getAllSubjects() {
|
public List<Subject> getAllSubjects() {
|
||||||
return subjectRepository.findAll();
|
logger.info("Получен запрос на получение всех дисциплин");
|
||||||
|
try {
|
||||||
|
List<Subject> subjects = subjectRepository.findAll();
|
||||||
|
List<Subject> response = subjects.stream()
|
||||||
|
.map(s -> new Subject(
|
||||||
|
s.getId(),
|
||||||
|
s.getName(),
|
||||||
|
s.getCode(),
|
||||||
|
s.getDepartmentId()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} дисциплин", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка дисциплин: {}", e.getMessage(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{departmentId}")
|
||||||
|
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {
|
||||||
|
logger.info("Получен запрос на получение дисциплин для кафедры с ID - {}", departmentId);
|
||||||
|
try{
|
||||||
|
List<Subject> subjects = subjectRepository.findByDepartmentId(departmentId);
|
||||||
|
|
||||||
|
if(subjects.isEmpty()){
|
||||||
|
logger.info("Дисциплины для кафедры с ID - {} не найдены", departmentId);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body("Дисциплины для указанной кафедры не найдены");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Найдено {} дисциплин для кафедры с ID - {}", subjects.size(), departmentId);
|
||||||
|
return ResponseEntity.ok(subjects);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Произошла ошибка при получении списка дисциплин для кафедры с ID - {}", departmentId);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Произошла ошибка при получении списка дисциплин");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
|
public ResponseEntity<?> createSubject(@RequestBody CreateSubjectRequest request) {
|
||||||
String name = request.get("name");
|
logger.info("Получен запрос на создание дисциплины: name = {}, code = {}, departmentId = {}",
|
||||||
if (name == null || name.isBlank()) {
|
request.getName(), request.getCode(), request.getDepartmentId());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
|
|
||||||
}
|
|
||||||
if (subjectRepository.findByName(name.trim()).isPresent()) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Subject subject = new Subject();
|
try {
|
||||||
subject.setName(name.trim());
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
subjectRepository.save(subject);
|
String errorMessage = "Название обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||||
|
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getCode() == null || request.getCode().isBlank()) {
|
||||||
|
String errorMessage = "Код дисциплины обязателен";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(subject);
|
Subject subject = new Subject();
|
||||||
|
subject.setName(request.getName());
|
||||||
|
subject.setCode(request.getCode());
|
||||||
|
subject.setDepartmentId(request.getDepartmentId());
|
||||||
|
subjectRepository.save(subject);
|
||||||
|
|
||||||
|
logger.info("Дисциплина успешно создана с ID: {}", subject.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new SubjectResponse(
|
||||||
|
subject.getId(),
|
||||||
|
subject.getName(),
|
||||||
|
subject.getCode(),
|
||||||
|
subject.getDepartmentId()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (Exception e){
|
||||||
|
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление дисциплины с ID: {}", id);
|
||||||
if (!subjectRepository.existsById(id)) {
|
if (!subjectRepository.existsById(id)) {
|
||||||
|
logger.info("Дисциплина с ID - {} не найдена", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
subjectRepository.deleteById(id);
|
subjectRepository.deleteById(id);
|
||||||
|
logger.info("Дисциплина с ID - {} успешно удалена", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ 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.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,62 +22,182 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/users")
|
@RequestMapping("/api/users")
|
||||||
public class UserController {
|
public class UserController {
|
||||||
|
|
||||||
|
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
|
||||||
public List<UserResponse> getAllUsers() {
|
public List<UserResponse> getAllUsers() {
|
||||||
return userRepository.findAll().stream()
|
logger.info("Получен запрос на получение всех пользователей");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
try {
|
||||||
.toList();
|
List<User> users = userRepository.findAll();
|
||||||
|
|
||||||
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(u -> {
|
||||||
|
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
|
||||||
|
return new UserResponse(
|
||||||
|
u.getId(),
|
||||||
|
u.getUsername(),
|
||||||
|
u.getRole().name(),
|
||||||
|
u.getFullName(),
|
||||||
|
u.getJobTitle(),
|
||||||
|
departmentName);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} пользователей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка пользователей: {}", e.getMessage(),e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
public List<UserResponse> getTeachers() {
|
public List<UserResponse> getTeachers() {
|
||||||
return userRepository.findByRole(Role.TEACHER).stream()
|
logger.info("Запрос на получение пользователей с ролью 'Преподаватель'");
|
||||||
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
|
||||||
.toList();
|
try {
|
||||||
|
List<User> users = userRepository.findByRole(Role.TEACHER);
|
||||||
|
|
||||||
|
List<UserResponse> response = users.stream()
|
||||||
|
.map(u -> {
|
||||||
|
String departmentName = departmentRepository.findById(u.getDepartmentId())
|
||||||
|
.map(Department::getDepartmentName)
|
||||||
|
.orElse("Неизвестно");
|
||||||
|
|
||||||
|
return new UserResponse(
|
||||||
|
u.getId(),
|
||||||
|
u.getUsername(),
|
||||||
|
u.getRole().name(),
|
||||||
|
u.getFullName(),
|
||||||
|
u.getJobTitle(),
|
||||||
|
departmentName);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
logger.info("Получено {} преподавателей", response.size());
|
||||||
|
return response;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при получении списка преподавателей: {}", e.getMessage(),e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/teachers/{departmentId}")
|
||||||
|
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){
|
||||||
|
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||||
|
try {
|
||||||
|
List<User> users = userRepository.findByRoleAndDepartmentId(Role.TEACHER, departmentId);
|
||||||
|
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body("Преподаватели для указанной кафедры не найдены");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||||
|
|
||||||
|
List<UserResponse> userResponses = users.stream()
|
||||||
|
.map( user -> {
|
||||||
|
|
||||||
|
return new UserResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getRole().name(),
|
||||||
|
user.getFullName(),
|
||||||
|
user.getJobTitle(),
|
||||||
|
user.getDepartmentId()
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(userResponses);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Произошла ошибка при получении списка преподавателей для кафедры с ID - {}: {}",departmentId, e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Произошла ошибка при получении списка преподавателей");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
logger.info("Получен запрос на создание нового пользователя: username = {}, fullName = {}, jobTitle = {}, departmentId = {}", request.getUsername(), request.getFullName(), request.getJobTitle(), request.getDepartmentId());
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Имя пользователя обязательно"));
|
|
||||||
}
|
|
||||||
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Пароль минимум 4 символа"));
|
|
||||||
}
|
|
||||||
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Пользователь уже существует"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Role role;
|
|
||||||
try {
|
try {
|
||||||
role = Role.valueOf(request.getRole());
|
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||||
} catch (Exception e) {
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getPassword() == null || request.getPassword().length() < 4) {
|
||||||
|
String errorMessage = "Пароль минимум 4 символа";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||||
|
String errorMessage = "Пользователь уже существует";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getFullName() == null || request.getFullName().isBlank()) {
|
||||||
|
String errorMessage = "Имя пользователя обязательно";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
if (request.getJobTitle() == null || request.getJobTitle().isBlank()) {
|
||||||
|
logger.info("Должность не была указана, установлено значение по умолчанию: 'Не указано'");
|
||||||
|
request.setJobTitle("Не указано");
|
||||||
|
}
|
||||||
|
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||||
|
String errorMessage = "ID кафедры не может быть равен 0 или пустым";
|
||||||
|
logger.error("Ошибка валидации: {}", errorMessage);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
Role role;
|
||||||
|
try {
|
||||||
|
role = Role.valueOf(request.getRole());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Ошибка при преобразовании роли: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Недопустимая роль"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(request.getUsername());
|
||||||
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setRole(role);
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setJobTitle(request.getJobTitle());
|
||||||
|
user.setDepartmentId(request.getDepartmentId());
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
logger.info("Пользователь успешно создан с ID: {}", user.getId());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name(), user.getFullName(), user.getJobTitle(), user.getDepartmentId()));
|
||||||
|
} catch (Exception e ) {
|
||||||
|
logger.error("Ошибка при создании пользователя: {}", e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("message", "Произошла ошибка при создании пользователя: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = new User();
|
|
||||||
user.setUsername(request.getUsername());
|
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
|
||||||
user.setRole(role);
|
|
||||||
userRepository.save(user);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRole().name()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
|
||||||
|
logger.info("Получен запрос на удаление пользователя с ID: {}", id);
|
||||||
if (!userRepository.existsById(id)) {
|
if (!userRepository.existsById(id)) {
|
||||||
|
logger.info("Пользователь с ID - {} не найден", id);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
userRepository.deleteById(id);
|
userRepository.deleteById(id);
|
||||||
|
logger.info("Пользователь с ID - {} успешно удалён", id);
|
||||||
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
return ResponseEntity.ok(Map.of("message", "Пользователь удалён"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class CreateDepartmentRequest {
|
||||||
|
|
||||||
|
private String departmentName;
|
||||||
|
private Long departmentCode;
|
||||||
|
|
||||||
|
public CreateDepartmentRequest() {}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentName(String departmentName) {
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentCode() {
|
||||||
|
return departmentCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentCode(Long departmentCode) {
|
||||||
|
this.departmentCode = departmentCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class CreateGroupRequest {
|
|||||||
private String name;
|
private String name;
|
||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
|
private Long departmentId;
|
||||||
|
private Integer enrollmentYear;
|
||||||
|
private Long specialityCode;
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
@@ -29,4 +32,28 @@ public class CreateGroupRequest {
|
|||||||
public void setEducationFormId(Long educationFormId) {
|
public void setEducationFormId(Long educationFormId) {
|
||||||
this.educationFormId = educationFormId;
|
this.educationFormId = educationFormId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getEnrollmentYear() {
|
||||||
|
return enrollmentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnrollmentYear(Integer enrollmentYear) {
|
||||||
|
this.enrollmentYear = enrollmentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityCode(Long specialityCode) {
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.magistr.app.model.SemesterType;
|
||||||
|
|
||||||
|
public class CreateScheduleDataRequest {
|
||||||
|
private Long id;
|
||||||
|
private Long departmentId;
|
||||||
|
private Long semester;
|
||||||
|
private Long groupId;
|
||||||
|
private Long subjectsId;
|
||||||
|
private Long lessonTypeId;
|
||||||
|
private Long numberOfHours;
|
||||||
|
private Boolean division;
|
||||||
|
private Long teacherId;
|
||||||
|
private SemesterType semesterType;
|
||||||
|
private String period;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSemester() {
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSemester(Long semester) {
|
||||||
|
this.semester = semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getGroupId() {
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGroupId(Long groupId) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSubjectsId() {
|
||||||
|
return subjectsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubjectsId(Long subjectsId) {
|
||||||
|
this.subjectsId = subjectsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLessonTypeId() {
|
||||||
|
return lessonTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLessonTypeId(Long lessonTypeId) {
|
||||||
|
this.lessonTypeId = lessonTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getNumberOfHours() {
|
||||||
|
return numberOfHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumberOfHours(Long numberOfHours) {
|
||||||
|
this.numberOfHours = numberOfHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getDivision() {
|
||||||
|
return division;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDivision(Boolean division) {
|
||||||
|
this.division = division;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTeacherId() {
|
||||||
|
return teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTeacherId(Long teacherId) {
|
||||||
|
this.teacherId = teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SemesterType getSemesterType() {
|
||||||
|
return semesterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSemesterType(SemesterType semesterType) {
|
||||||
|
this.semesterType = semesterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPeriod() {
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPeriod(String period) {
|
||||||
|
this.period = period;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class CreateSpecialityRequest {
|
||||||
|
|
||||||
|
private String specialityName;
|
||||||
|
private String specialityCode;
|
||||||
|
|
||||||
|
public CreateSpecialityRequest() {}
|
||||||
|
|
||||||
|
public String getSpecialityName() {
|
||||||
|
return specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityName(String specialityName) {
|
||||||
|
this.specialityName = specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityCode(String specialityCode) {
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class CreateSubjectRequest {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String code;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
public CreateSubjectRequest() {};
|
||||||
|
|
||||||
|
public CreateSubjectRequest(Long id, String name, String code, Long departmentId) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.code = code;
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class CreateUserRequest {
|
|||||||
private String username;
|
private String username;
|
||||||
private String password;
|
private String password;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public CreateUserRequest() {
|
public CreateUserRequest() {
|
||||||
}
|
}
|
||||||
@@ -32,4 +35,28 @@ public class CreateUserRequest {
|
|||||||
public void setRole(String role) {
|
public void setRole(String role) {
|
||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJobTitle(String jobTitle) {
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class DepartmentResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String departmentName;
|
||||||
|
private Long departmentCode;
|
||||||
|
|
||||||
|
public DepartmentResponse(Long id, String departmentName, Long departmentCode) {
|
||||||
|
this.id = id;
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
this.departmentCode = departmentCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentName(String departmentName) {
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentCode() {
|
||||||
|
return departmentCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentCode(Long departmentCode) {
|
||||||
|
this.departmentCode = departmentCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,26 @@ public class GroupResponse {
|
|||||||
private Long groupSize;
|
private Long groupSize;
|
||||||
private Long educationFormId;
|
private Long educationFormId;
|
||||||
private String educationFormName;
|
private String educationFormName;
|
||||||
|
private Long departmentId;
|
||||||
|
private Integer enrollmentYear;
|
||||||
|
private Integer course;
|
||||||
|
private Integer semester;
|
||||||
|
private Long specialityCode;
|
||||||
|
|
||||||
public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName) {
|
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.enrollmentYear = enrollmentYear;
|
||||||
|
this.course = course;
|
||||||
|
this.semester = semester;
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -35,4 +48,24 @@ public class GroupResponse {
|
|||||||
public String getEducationFormName() {
|
public String getEducationFormName() {
|
||||||
return educationFormName;
|
return educationFormName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getEnrollmentYear() {
|
||||||
|
return enrollmentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCourse() {
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSemester() {
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java
Normal file
129
backend/src/main/java/com/magistr/app/dto/ScheduleResponse.java
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.magistr.app.model.SemesterType;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class ScheduleResponse {
|
||||||
|
private Long id;
|
||||||
|
private String specialityCode;
|
||||||
|
private Long departmentId;
|
||||||
|
private Long semester;
|
||||||
|
private Long groupId;
|
||||||
|
private String groupName;
|
||||||
|
private Integer groupCourse;
|
||||||
|
private Long subjectsId;
|
||||||
|
private String subjectName;
|
||||||
|
private Long lessonTypeId;
|
||||||
|
private String lessonType;
|
||||||
|
private Long numberOfHours;
|
||||||
|
private Boolean division;
|
||||||
|
private Long teacherId;
|
||||||
|
private String teacherName;
|
||||||
|
private String teacherJobTitle;
|
||||||
|
private SemesterType semesterType;
|
||||||
|
private 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.departmentId = departmentId;
|
||||||
|
this.semester = semester;
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.subjectsId = subjectsId;
|
||||||
|
this.lessonTypeId = lessonTypeId;
|
||||||
|
this.numberOfHours = numberOfHours;
|
||||||
|
this.division = division;
|
||||||
|
this.teacherId = teacherId;
|
||||||
|
this.semesterType = semesterType;
|
||||||
|
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() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSemester() {
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getGroupId() {
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getGroupName() {
|
||||||
|
return groupName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getGroupCourse() {
|
||||||
|
return groupCourse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSubjectsId() {
|
||||||
|
return subjectsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSubjectName() {
|
||||||
|
return subjectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLessonTypeId() {
|
||||||
|
return lessonTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLessonType() {
|
||||||
|
return lessonType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getNumberOfHours() {
|
||||||
|
return numberOfHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getDivision() {
|
||||||
|
return division;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTeacherId() {
|
||||||
|
return teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTeacherName() {
|
||||||
|
return teacherName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTeacherJobTitle() {
|
||||||
|
return teacherJobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SemesterType getSemesterType() {
|
||||||
|
return semesterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPeriod() {
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class SpecialityResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String specialityName;
|
||||||
|
private String specialityCode;
|
||||||
|
|
||||||
|
public SpecialityResponse(Long id, String specialityName, String specialityCode) {
|
||||||
|
this.id = id;
|
||||||
|
this.specialityName = specialityName;
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityName() {
|
||||||
|
return specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityName(String specialityName) {
|
||||||
|
this.specialityName = specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityCode(String specialityCode) {
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class SubjectResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String code;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
public SubjectResponse() {};
|
||||||
|
|
||||||
|
public SubjectResponse(Long id, String name, String code, Long departmentId) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.code = code;
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,41 +1,72 @@
|
|||||||
package com.magistr.app.dto;
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class UserResponse {
|
public class UserResponse {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String fullName;
|
||||||
|
private String jobTitle;
|
||||||
|
private String departmentName;
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public UserResponse() {
|
public UserResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserResponse(Long id, String username, String role) {
|
public UserResponse(Long id, String username, String role, String fullName, String jobTitle, String departmentName) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.role = role;
|
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) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserResponse(Long id, String role, String fullName, String jobTitle, Long departmentId) {
|
||||||
|
this.id = id;
|
||||||
|
this.role = role;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
this.departmentId = departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
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) {
|
public String getFullName() {
|
||||||
this.role = role;
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal file
50
backend/src/main/java/com/magistr/app/model/Department.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name="departments")
|
||||||
|
public class Department {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String departmentName;
|
||||||
|
|
||||||
|
@Column(name = "code", nullable = false)
|
||||||
|
private Long departmentCode;
|
||||||
|
|
||||||
|
public Department() {}
|
||||||
|
|
||||||
|
public Department(Long id, String departmentName, Long departmentCode) {
|
||||||
|
this.id = id;
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
this.departmentCode = departmentCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDepartmentName() {
|
||||||
|
return departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentName(String departmentName) {
|
||||||
|
this.departmentName = departmentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentCode() {
|
||||||
|
return departmentCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentCode(Long departmentCode) {
|
||||||
|
this.departmentCode = departmentCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal file
31
backend/src/main/java/com/magistr/app/model/LessonType.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
backend/src/main/java/com/magistr/app/model/ScheduleData.java
Normal file
147
backend/src/main/java/com/magistr/app/model/ScheduleData.java
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name="schedule_data")
|
||||||
|
public class ScheduleData {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name="department_id", nullable = false)
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
@Column(name="semester", nullable = false)
|
||||||
|
private Long semester;
|
||||||
|
|
||||||
|
@Column(name="group_id", nullable = false)
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
@Column(name="subjects_id", nullable = false)
|
||||||
|
private Long subjectsId;
|
||||||
|
|
||||||
|
@Column(name="lesson_type_id", nullable = false)
|
||||||
|
private Long lessonTypeId;
|
||||||
|
|
||||||
|
@Column(name="number_of_hours", nullable = false)
|
||||||
|
private Long numberOfHours;
|
||||||
|
|
||||||
|
@Column(name="is_division", nullable = false)
|
||||||
|
private Boolean division;
|
||||||
|
|
||||||
|
@Column(name="teacher_id", nullable = false)
|
||||||
|
private Long teacherId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name="semester_type", nullable = false)
|
||||||
|
private SemesterType semesterType;
|
||||||
|
|
||||||
|
@Column(name="period", nullable = false)
|
||||||
|
private String period;
|
||||||
|
|
||||||
|
public ScheduleData() {}
|
||||||
|
|
||||||
|
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.departmentId = departmentId;
|
||||||
|
this.semester = semester;
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.subjectsId = subjectsId;
|
||||||
|
this.lessonTypeId = lessonTypeId;
|
||||||
|
this.numberOfHours = numberOfHours;
|
||||||
|
this.division = division;
|
||||||
|
this.teacherId = teacherId;
|
||||||
|
this.semesterType = semesterType;
|
||||||
|
this.period = period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSemester() {
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSemester(Long semester) {
|
||||||
|
this.semester = semester;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getGroupId() {
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGroupId(Long groupId) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSubjectsId() {
|
||||||
|
return subjectsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubjectsId(Long subjectsId) {
|
||||||
|
this.subjectsId = subjectsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLessonTypeId() {
|
||||||
|
return lessonTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLessonTypeId(Long lessonTypeId) {
|
||||||
|
this.lessonTypeId = lessonTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getNumberOfHours() {
|
||||||
|
return numberOfHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumberOfHours(Long numberOfHours) {
|
||||||
|
this.numberOfHours = numberOfHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getDivision() {
|
||||||
|
return division;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDivision(Boolean division) {
|
||||||
|
this.division = division;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTeacherId() {
|
||||||
|
return teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTeacherId(Long teacherId) {
|
||||||
|
this.teacherId = teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SemesterType getSemesterType() {
|
||||||
|
return semesterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSemesterType(SemesterType semesterType) {
|
||||||
|
this.semesterType = semesterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPeriod() {
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPeriod(String period) {
|
||||||
|
this.period = period;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
public enum SemesterType {
|
||||||
|
spring,
|
||||||
|
autumn
|
||||||
|
}
|
||||||
50
backend/src/main/java/com/magistr/app/model/Speciality.java
Normal file
50
backend/src/main/java/com/magistr/app/model/Speciality.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name="specialties")
|
||||||
|
public class Speciality {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name="name", nullable = false)
|
||||||
|
private String specialityName;
|
||||||
|
|
||||||
|
@Column(name="specialty_code",nullable = false)
|
||||||
|
private String specialityCode;
|
||||||
|
|
||||||
|
public Speciality() {}
|
||||||
|
|
||||||
|
public Speciality(Long id, String specialityName, String specialityCode) {
|
||||||
|
this.id = id;
|
||||||
|
this.specialityName = specialityName;
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityName() {
|
||||||
|
return specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityName(String specialityName) {
|
||||||
|
this.specialityName = specialityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSpecialityCode() {
|
||||||
|
return specialityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpecialityCode(String specialityCode) {
|
||||||
|
this.specialityCode = specialityCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -20,6 +21,15 @@ public class StudentGroup {
|
|||||||
@JoinColumn(name = "education_form_id", nullable = false)
|
@JoinColumn(name = "education_form_id", nullable = false)
|
||||||
private EducationForm educationForm;
|
private EducationForm educationForm;
|
||||||
|
|
||||||
|
@Column(name = "department_id", nullable = false)
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
@Column(name = "enrollment_year", nullable = false)
|
||||||
|
private Integer enrollmentYear;
|
||||||
|
|
||||||
|
@Column(name="specialty_code", nullable = false)
|
||||||
|
private Long specialityCode;
|
||||||
|
|
||||||
public StudentGroup() {
|
public StudentGroup() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +64,46 @@ public class StudentGroup {
|
|||||||
public void setEducationForm(EducationForm educationForm) {
|
public void setEducationForm(EducationForm educationForm) {
|
||||||
this.educationForm = educationForm;
|
this.educationForm = educationForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getEnrollmentYear() {
|
||||||
|
return enrollmentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnrollmentYear(Integer enrollmentYear) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ public class Subject {
|
|||||||
@Column(unique = true, nullable = false, length = 200)
|
@Column(unique = true, nullable = false, length = 200)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "code")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(name = "department_id", nullable = false)
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public Subject() {
|
public Subject() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Subject(Long id, String name) {
|
public Subject(Long id, String name, String code, Long departmentId) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.code = code;
|
||||||
|
this.departmentId = departmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -36,4 +44,20 @@ public class Subject {
|
|||||||
public void setName(String name) {
|
public void setName(String name) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ public class User {
|
|||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private Role role = Role.STUDENT;
|
private Role role = Role.STUDENT;
|
||||||
|
|
||||||
|
@Column(name = "full_name", nullable = false)
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
@Column(name="job_title", nullable = false)
|
||||||
|
private String jobTitle;
|
||||||
|
|
||||||
|
@Column(name="department_id", nullable = false)
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
public User() {
|
public User() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +63,28 @@ public class User {
|
|||||||
public void setRole(Role role) {
|
public void setRole(Role role) {
|
||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobTitle() {
|
||||||
|
return jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJobTitle(String jobTitle) {
|
||||||
|
this.jobTitle = jobTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getDepartmentId() {
|
||||||
|
return departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDepartmentId(Long departmentId) {
|
||||||
|
this.departmentId = departmentId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Department;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface DepartmentRepository extends JpaRepository<Department, Long> {
|
||||||
|
|
||||||
|
Optional<Department> findByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
Optional<Department> findByDepartmentCode(Long departmentCode);
|
||||||
|
}
|
||||||
@@ -11,4 +11,6 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
|||||||
Optional<StudentGroup> findByName(String name);
|
Optional<StudentGroup> findByName(String name);
|
||||||
|
|
||||||
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||||
|
|
||||||
|
List<StudentGroup> findByDepartmentId(Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.ScheduleData;
|
||||||
|
import com.magistr.app.model.SemesterType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Speciality;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SpecialtiesRepository extends JpaRepository<Speciality, Long> {
|
||||||
|
|
||||||
|
Optional<Speciality> findBySpecialityName(String specialityName);
|
||||||
|
|
||||||
|
Optional<Speciality> findBySpecialityCode(String specialityCode);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.Subject;
|
import com.magistr.app.model.Subject;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||||
Optional<Subject> findByName(String name);
|
Optional<Subject> findByName(String name);
|
||||||
|
|
||||||
|
List<Subject> findByDepartmentId(Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
Optional<User> findByUsername(String username);
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
List<User> findByRole(Role role);
|
List<User> findByRole(Role role);
|
||||||
|
|
||||||
|
List<User> findByRoleAndDepartmentId(Role role, Long departmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 'Год начала обучения группы';
|
||||||
@@ -8,6 +8,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
@@ -32,6 +35,11 @@ services:
|
|||||||
POSTGRES_USER: myuser
|
POSTGRES_USER: myuser
|
||||||
POSTGRES_PASSWORD: supersecretpassword
|
POSTGRES_PASSWORD: supersecretpassword
|
||||||
POSTGRES_DB: app_db
|
POSTGRES_DB: app_db
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready -U myuser -d app_db" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
|
|||||||
84
docs/API.md
84
docs/API.md
@@ -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}`
|
||||||
|
|
||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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(1–6) | Курс |
|
| `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` |
|
||||||
|
|
||||||
### Накатывание на существующих тенантов
|
### Накатывание на существующих тенантов
|
||||||
|
|
||||||
|
|||||||
@@ -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
115
docs/UI_COMPONENTS.md
Normal 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`).
|
||||||
@@ -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 ===== */
|
||||||
@@ -754,3 +992,44 @@ tbody tr:hover {
|
|||||||
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;
|
||||||
|
}
|
||||||
315
frontend/admin/css/department.css
Normal file
315
frontend/admin/css/department.css
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/* ===== Оверлей для модалок создания записей (к/ф) ===== */
|
||||||
|
.cs-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-overlay.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-overlay-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие стили для обеих модалок */
|
||||||
|
.cs-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
position: relative;
|
||||||
|
animation: csModalAppear 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модалка 1 (форма) всегда поверх модалки 2 (таблицы),
|
||||||
|
чтобы выпадающие списки не уходили под таблицу */
|
||||||
|
.cs-modal-form {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-modal-table {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes csModalAppear {
|
||||||
|
from { opacity: 0; transform: translateY(-12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка закрытия */
|
||||||
|
.btn-close-panel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition), background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-panel:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap{
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header{
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.table-item{
|
||||||
|
border-top: 1px solid var(--bg-card-border);
|
||||||
|
}
|
||||||
|
details.table-item:first-of-type{ border-top:none; }
|
||||||
|
|
||||||
|
summary{
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker{ display:none; }
|
||||||
|
|
||||||
|
.chev{
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-input);
|
||||||
|
|
||||||
|
transition: transform .18s ease, color .18s ease, border-color .18s ease, background .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev-icon{
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary:hover .chev{
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 22%, var(--bg-card-border));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] .chev{
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--bg-card-border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--bg-input));
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta{ color: var(--text-secondary); font-size: 12px; }
|
||||||
|
|
||||||
|
.content{ padding: 0 16px 16px 16px; }
|
||||||
|
|
||||||
|
.wrap table{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap thead th{
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap tbody td{
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap tbody tr:hover{ background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.title-multiline{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-multiline .title-main{
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-multiline .title-sub{
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-multiline b{
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* summary = 3 колонки: [chev] [title] [meta] */
|
||||||
|
details.table-item > summary{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start; /* важно: всё прижимаем к верху */
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* чтобы текст нормально переносился и не растягивал мету */
|
||||||
|
details.table-item > summary .title{
|
||||||
|
min-width: 0; /* важно для grid, иначе может распирать */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "2 записи" всегда справа и сверху, аккуратно */
|
||||||
|
details.table-item > summary .meta{
|
||||||
|
justify-self: end;
|
||||||
|
align-self: start;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-top: 4px; /* чуть опустить относительно первой строки */
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* стрелка тоже сверху */
|
||||||
|
details.table-item > summary .chev{
|
||||||
|
align-self: start;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-search{
|
||||||
|
width: min(360px, 60vw);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/admin/css/departments-data.css
Normal file
11
frontend/admin/css/departments-data.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/* Стили для формы создания кафедр и специальностей */
|
||||||
|
.departments-data-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#departments-tbody .loading-row,
|
||||||
|
#specialties-tbody .loading-row {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
<link rel="stylesheet" href="css/layout.css">
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<link rel="stylesheet" href="css/components.css">
|
<link rel="stylesheet" href="css/components.css">
|
||||||
<link rel="stylesheet" href="css/modals.css">
|
<link rel="stylesheet" href="css/modals.css">
|
||||||
|
<link rel="stylesheet" href="css/department.css">
|
||||||
|
<link rel="stylesheet" href="css/departments-data.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -34,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">
|
||||||
@@ -44,7 +51,25 @@
|
|||||||
<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 href="#" class="nav-item" data-tab="department">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16" />
|
||||||
|
<path d="M2 21h20" />
|
||||||
|
<path d="M8 7h0M12 7h0M16 7h0" />
|
||||||
|
<path d="M8 11h0M12 11h0M16 11h0" />
|
||||||
|
<path d="M10 21v-4h4v4" />
|
||||||
|
</svg>
|
||||||
|
<span>Кафедра</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="departments-data">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||||
|
</svg>
|
||||||
|
<span>Создание кафедры/специальности</span>
|
||||||
</a>
|
</a>
|
||||||
<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"
|
||||||
@@ -52,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"
|
||||||
@@ -62,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"
|
||||||
@@ -70,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"
|
||||||
@@ -85,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">
|
||||||
@@ -94,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">
|
||||||
@@ -102,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>
|
||||||
|
|
||||||
|
|||||||
222
frontend/admin/js/dropdown.js
Normal file
222
frontend/admin/js/dropdown.js
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -11,6 +26,8 @@ import { initClassrooms } from './views/classrooms.js';
|
|||||||
import { initSubjects } from './views/subjects.js';
|
import { initSubjects } from './views/subjects.js';
|
||||||
import {initSchedule} from "./views/schedule.js";
|
import {initSchedule} from "./views/schedule.js";
|
||||||
import {initDatabase} from "./views/database.js";
|
import {initDatabase} from "./views/database.js";
|
||||||
|
import {initDepartment} from "./views/department.js";
|
||||||
|
import {initDepartmentsData} from "./views/departments-data.js";
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
@@ -22,6 +39,8 @@ const ROUTES = {
|
|||||||
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
|
||||||
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
|
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
|
||||||
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
|
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
|
||||||
|
department: { title: 'Кафедры', file: 'views/department.html', init: initDepartment },
|
||||||
|
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentTab = null;
|
let currentTab = null;
|
||||||
@@ -33,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()) {
|
||||||
@@ -44,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');
|
||||||
|
|||||||
398
frontend/admin/js/views/department.js
Normal file
398
frontend/admin/js/views/department.js
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
103
frontend/admin/js/views/departments-data.js
Normal file
103
frontend/admin/js/views/departments-data.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
|
export async function initDepartmentsData() {
|
||||||
|
const deptTbody = document.getElementById('departments-tbody');
|
||||||
|
const specTbody = document.getElementById('specialties-tbody');
|
||||||
|
|
||||||
|
const createDeptForm = document.getElementById('create-department-form');
|
||||||
|
const createSpecForm = document.getElementById('create-specialty-form');
|
||||||
|
|
||||||
|
let departments = [];
|
||||||
|
let specialties = [];
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
// Load Departments
|
||||||
|
try {
|
||||||
|
departments = await api.get('/api/departments');
|
||||||
|
renderDepartments();
|
||||||
|
} catch (e) {
|
||||||
|
deptTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Specialties
|
||||||
|
try {
|
||||||
|
specialties = await api.get('/api/specialties');
|
||||||
|
renderSpecialties();
|
||||||
|
} catch (e) {
|
||||||
|
specTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDepartments() {
|
||||||
|
if (!departments || !departments.length) {
|
||||||
|
deptTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deptTbody.innerHTML = departments.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${d.id}</td>
|
||||||
|
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||||
|
<td>${escapeHtml(String(d.departmentCode || d.code))}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpecialties() {
|
||||||
|
if (!specialties || !specialties.length) {
|
||||||
|
specTbody.innerHTML = '<tr><td colspan="3" class="loading-row">-</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
specTbody.innerHTML = specialties.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${s.id}</td>
|
||||||
|
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||||
|
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
createDeptForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('create-dept-alert');
|
||||||
|
const name = document.getElementById('dept-name').value.trim();
|
||||||
|
const code = document.getElementById('dept-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('create-dept-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/departments', { departmentName: name, departmentCode: Number(code) });
|
||||||
|
showAlert('create-dept-alert', `Кафедра "${name}" создана`, 'success');
|
||||||
|
createDeptForm.reset();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-dept-alert', error.message || 'Ошибка создания кафедры', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createSpecForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert('create-spec-alert');
|
||||||
|
const name = document.getElementById('spec-name').value.trim();
|
||||||
|
const code = document.getElementById('spec-code').value.trim();
|
||||||
|
|
||||||
|
if (!name || !code) {
|
||||||
|
showAlert('create-spec-alert', 'Заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/specialties', { specialityName: name, specialityCode: code });
|
||||||
|
showAlert('create-spec-alert', `Специальность "${name}" создана`, 'success');
|
||||||
|
createSpecForm.reset();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('create-spec-alert', error.message || 'Ошибка создания специальности', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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="4" 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="4" 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="4" 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 => `
|
||||||
@@ -70,6 +70,10 @@ export async function initGroups() {
|
|||||||
<td>${escapeHtml(g.name)}</td>
|
<td>${escapeHtml(g.name)}</td>
|
||||||
<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.enrollmentYear || '-'}</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('');
|
||||||
}
|
}
|
||||||
@@ -80,14 +84,24 @@ export async function initGroups() {
|
|||||||
const name = document.getElementById('new-group-name').value.trim();
|
const name = document.getElementById('new-group-name').value.trim();
|
||||||
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 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 (!enrollmentYear) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.post('/api/groups', { name, groupSize, educationFormId: Number(educationFormId) });
|
const data = await api.post('/api/groups', {
|
||||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success');
|
name,
|
||||||
|
groupSize: Number(groupSize),
|
||||||
|
educationFormId: Number(educationFormId),
|
||||||
|
departmentId: Number(departmentId),
|
||||||
|
enrollmentYear: Number(enrollmentYear)
|
||||||
|
});
|
||||||
|
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success');
|
||||||
createGroupForm.reset();
|
createGroupForm.reset();
|
||||||
loadGroups();
|
loadGroups();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -24,19 +24,21 @@ export async function initSubjects() {
|
|||||||
renderSubjects(allSubjects);
|
renderSubjects(allSubjects);
|
||||||
populateSubjectSelect(allSubjects);
|
populateSubjectSelect(allSubjects);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubjects(subjects) {
|
function renderSubjects(subjects) {
|
||||||
if (!subjects || !subjects.length) {
|
if (!subjects || !subjects.length) {
|
||||||
subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет дисциплин</td></tr>';
|
subjectsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Нет дисциплин</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subjectsTbody.innerHTML = subjects.map(s => `
|
subjectsTbody.innerHTML = subjects.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${s.id}</td>
|
<td>${s.id}</td>
|
||||||
<td>${escapeHtml(s.name)}</td>
|
<td>${escapeHtml(s.name)}</td>
|
||||||
|
<td>${escapeHtml(s.code || '-')}</td>
|
||||||
|
<td>${s.departmentId || '-'}</td>
|
||||||
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
|
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
@@ -100,11 +102,19 @@ export async function initSubjects() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert('create-subject-alert');
|
hideAlert('create-subject-alert');
|
||||||
const name = document.getElementById('new-subject-name').value.trim();
|
const name = document.getElementById('new-subject-name').value.trim();
|
||||||
|
const code = document.getElementById('new-subject-code').value.trim();
|
||||||
|
const departmentId = document.getElementById('new-subject-department').value;
|
||||||
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
|
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
|
||||||
|
if (!code) { showAlert('create-subject-alert', 'Введите код предмета', 'error'); return; }
|
||||||
|
if (!departmentId) { showAlert('create-subject-alert', 'Введите идентификатор кафедры', 'error'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.post('/api/subjects', { name });
|
const data = await api.post('/api/subjects', {
|
||||||
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success');
|
name,
|
||||||
|
code,
|
||||||
|
departmentId: Number(departmentId)
|
||||||
|
});
|
||||||
|
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" добавлена`, 'success');
|
||||||
createSubjectForm.reset();
|
createSubjectForm.reset();
|
||||||
loadSubjects();
|
loadSubjects();
|
||||||
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
|
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
|
||||||
|
|||||||
@@ -196,14 +196,14 @@ export async function initUsers() {
|
|||||||
renderUsers(users);
|
renderUsers(users);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
usersTbody.innerHTML =
|
usersTbody.innerHTML =
|
||||||
'<tr><td colspan="4" class="loading-row">Ошибка загрузки: ' +
|
'<tr><td colspan="8" class="loading-row">Ошибка загрузки: ' +
|
||||||
escapeHtml(e.message) + '</td></tr>';
|
escapeHtml(e.message) + '</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
if (!users || !users.length) {
|
if (!users || !users.length) {
|
||||||
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
|
usersTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Нет пользователей</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +211,9 @@ export async function initUsers() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>${u.id}</td>
|
<td>${u.id}</td>
|
||||||
<td>${escapeHtml(u.username)}</td>
|
<td>${escapeHtml(u.username)}</td>
|
||||||
|
<td>${escapeHtml(u.fullName || '-')}</td>
|
||||||
|
<td>${escapeHtml(u.jobTitle || '-')}</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>
|
||||||
@@ -378,14 +381,24 @@ export async function initUsers() {
|
|||||||
const username = document.getElementById('new-username').value.trim();
|
const username = document.getElementById('new-username').value.trim();
|
||||||
const password = document.getElementById('new-password').value;
|
const password = document.getElementById('new-password').value;
|
||||||
const role = document.getElementById('new-role').value;
|
const role = document.getElementById('new-role').value;
|
||||||
|
const fullName = document.getElementById('new-fullname').value.trim();
|
||||||
|
const jobTitle = document.getElementById('new-jobtitle').value.trim();
|
||||||
|
const departmentId = document.getElementById('new-department').value;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || !fullName || !jobTitle || !departmentId) {
|
||||||
showAlert('create-alert', 'Заполните все поля', 'error');
|
showAlert('create-alert', 'Заполните все поля', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.post('/api/users', { username, password, role });
|
const data = await api.post('/api/users', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
fullName,
|
||||||
|
jobTitle,
|
||||||
|
departmentId: Number(departmentId)
|
||||||
|
});
|
||||||
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
|
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
|
||||||
createForm.reset();
|
createForm.reset();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|||||||
325
frontend/admin/settings/css/layout.css
Normal file
325
frontend/admin/settings/css/layout.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
frontend/admin/settings/css/main.css
Normal file
153
frontend/admin/settings/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
88
frontend/admin/settings/index.html
Normal file
88
frontend/admin/settings/index.html
Normal 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>
|
||||||
105
frontend/admin/settings/js/main.js
Normal file
105
frontend/admin/settings/js/main.js
Normal 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');
|
||||||
11
frontend/admin/settings/views/general.html
Normal file
11
frontend/admin/settings/views/general.html
Normal 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>
|
||||||
186
frontend/admin/views/department.html
Normal file
186
frontend/admin/views/department.html
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<div class="card create-card">
|
||||||
|
<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)">×</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap" id="schedule-blocks-container">
|
||||||
|
<!-- Сгенерированные блоки таблиц будут появляться здесь -->
|
||||||
|
</div>
|
||||||
80
frontend/admin/views/departments-data.html
Normal file
80
frontend/admin/views/departments-data.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!-- ===== Departments and Specialties Tab ===== -->
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Создание кафедры</h2>
|
||||||
|
<form id="create-department-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dept-name">Название кафедры</label>
|
||||||
|
<input type="text" id="dept-name" placeholder="Например: Кафедра ИБ" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dept-code">Код кафедры</label>
|
||||||
|
<input type="number" id="dept-code" placeholder="Например: 1" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-dept-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Кафедры</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="departments-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Код</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="departments-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Создание специальности</h2>
|
||||||
|
<form id="create-specialty-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spec-name">Название специальности</label>
|
||||||
|
<input type="text" id="spec-name" placeholder="Например: Программная инженерия" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spec-code">Код специальности</label>
|
||||||
|
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-spec-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Специальности</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="specialties-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Код специальности</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="specialties-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-group-department">ID кафедры</label>
|
||||||
|
<input type="number" id="new-group-department" placeholder="ID" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-group-enrollment-year">Год начала обучения</label>
|
||||||
|
<input type="number" id="new-group-enrollment-year" placeholder="2023" min="2000" max="2100" required>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-primary">Создать</button>
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
||||||
@@ -41,12 +49,16 @@
|
|||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Численность (чел.)</th>
|
<th>Численность (чел.)</th>
|
||||||
<th>Форма обучения</th>
|
<th>Форма обучения</th>
|
||||||
|
<th>ID кафедры</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="4" class="loading-row">Загрузка...</td>
|
<td colspan="9" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
<label for="new-subject-name">Название дисциплины</label>
|
<label for="new-subject-name">Название дисциплины</label>
|
||||||
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
|
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-subject-code">Код предмета</label>
|
||||||
|
<input type="text" id="new-subject-code" placeholder="Например: MATH101" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-subject-department">Идентификатор кафедры</label>
|
||||||
|
<input type="number" id="new-subject-department" placeholder="ID кафедры" required>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-primary">Добавить</button>
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-alert" id="create-subject-alert" role="alert"></div>
|
<div class="form-alert" id="create-subject-alert" role="alert"></div>
|
||||||
@@ -43,12 +51,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
|
<th>Код предмета</th>
|
||||||
|
<th>Кафедра (ID)</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="subjects-tbody">
|
<tbody id="subjects-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -19,6 +19,18 @@
|
|||||||
<option value="ADMIN">Администратор</option>
|
<option value="ADMIN">Администратор</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-fullname">ФИО пользователя</label>
|
||||||
|
<input type="text" id="new-fullname" placeholder="Иванов Иван Иванович" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-jobtitle">Должность</label>
|
||||||
|
<input type="text" id="new-jobtitle" placeholder="Студент / Доцент" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-department">ID Кафедры</label>
|
||||||
|
<input type="number" id="new-department" placeholder="ID" required>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-primary">Создать</button>
|
<button type="submit" class="btn-primary">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-alert" id="create-alert" role="alert"></div>
|
<div class="form-alert" id="create-alert" role="alert"></div>
|
||||||
@@ -33,13 +45,16 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Имя пользователя</th>
|
<th>Имя пользователя</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Должность</th>
|
||||||
|
<th>Кафедра</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Действия</th>
|
<th colspan="2">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="users-tbody">
|
<tbody id="users-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
<td colspan="8" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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
120
tz2.md
Normal 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-частей.
|
||||||
|
* Сквозное тестирование сценариев создания, редактирования и удаления занятий с пересчетом часов нагрузки.
|
||||||
Reference in New Issue
Block a user