diff --git a/.gitignore b/.gitignore index 5922140..95e33b9 100755 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,16 @@ # Игнорируем данные БД (но не init-скрипты) db/data/ -postgres_data/ # Игнорируем секреты .env -GEMINI.md -AGENTS.md +!GEMINI.md +!AGENTS.md # Игнорируем системные папки IDE (если редактируете с ПК) .idea/ .vscode/ *.DS_Store -.agent - +.agent/ # Игнорируем временные файлы сборки (на будущее) backend/target/ backend/build/ diff --git a/backend/src/main/java/com/magistr/app/controller/ClassroomController.java b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java new file mode 100644 index 0000000..8084f41 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ClassroomController.java @@ -0,0 +1,109 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.ClassroomRequest; +import com.magistr.app.dto.ClassroomResponse; +import com.magistr.app.model.Classroom; +import com.magistr.app.model.Equipment; +import com.magistr.app.repository.ClassroomRepository; +import com.magistr.app.repository.EquipmentRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/classrooms") +public class ClassroomController { + + private final ClassroomRepository classroomRepository; + private final EquipmentRepository equipmentRepository; + + public ClassroomController(ClassroomRepository classroomRepository, EquipmentRepository equipmentRepository) { + this.classroomRepository = classroomRepository; + this.equipmentRepository = equipmentRepository; + } + + @GetMapping + public List getAllClassrooms() { + return classroomRepository.findAll().stream() + .map(this::mapToResponse) + .toList(); + } + + @PostMapping + public ResponseEntity createClassroom(@RequestBody ClassroomRequest request) { + if (request.getName() == null || request.getName().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название аудитории обязательно")); + } + if (request.getCapacity() == null || request.getCapacity() <= 0) { + return ResponseEntity.badRequest().body(Map.of("message", "Вместимость должна быть больше нуля")); + } + if (classroomRepository.findByName(request.getName().trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Аудитория с таким названием уже существует")); + } + + Classroom classroom = new Classroom(); + classroom.setName(request.getName().trim()); + classroom.setCapacity(request.getCapacity()); + classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true); + + if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) { + List equipments = equipmentRepository.findAllById(request.getEquipmentIds()); + classroom.setEquipments(new java.util.HashSet<>(equipments)); + } + + classroomRepository.save(classroom); + return ResponseEntity.ok(mapToResponse(classroom)); + } + + @PutMapping("/{id}") + public ResponseEntity updateClassroom(@PathVariable Long id, @RequestBody ClassroomRequest request) { + Optional opt = classroomRepository.findById(id); + if (opt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Classroom classroom = opt.get(); + + if (request.getName() != null && !request.getName().isBlank() + && !classroom.getName().equals(request.getName().trim())) { + if (classroomRepository.findByName(request.getName().trim()).isPresent()) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Аудитория с таким названием уже существует")); + } + classroom.setName(request.getName().trim()); + } + + if (request.getCapacity() != null && request.getCapacity() > 0) { + classroom.setCapacity(request.getCapacity()); + } + + if (request.getIsAvailable() != null) { + classroom.setIsAvailable(request.getIsAvailable()); + } + + if (request.getEquipmentIds() != null) { + List equipments = equipmentRepository.findAllById(request.getEquipmentIds()); + classroom.setEquipments(new java.util.HashSet<>(equipments)); + } + + classroomRepository.save(classroom); + return ResponseEntity.ok(mapToResponse(classroom)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteClassroom(@PathVariable Long id) { + if (!classroomRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + classroomRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Аудитория удалена")); + } + + private ClassroomResponse mapToResponse(Classroom c) { + return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(), + new java.util.ArrayList<>(c.getEquipments())); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/EquipmentController.java b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java new file mode 100644 index 0000000..f305efb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/EquipmentController.java @@ -0,0 +1,51 @@ +package com.magistr.app.controller; + +import com.magistr.app.model.Equipment; +import com.magistr.app.repository.EquipmentRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/equipments") +public class EquipmentController { + + private final EquipmentRepository equipmentRepository; + + public EquipmentController(EquipmentRepository equipmentRepository) { + this.equipmentRepository = equipmentRepository; + } + + @GetMapping + public List getAllEquipments() { + return equipmentRepository.findAll(); + } + + @PostMapping + public ResponseEntity createEquipment(@RequestBody Map request) { + String name = request.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно")); + } + if (equipmentRepository.findByName(name.trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Оборудование с таким названием уже существует")); + } + + Equipment equipment = new Equipment(); + equipment.setName(name.trim()); + equipmentRepository.save(equipment); + + return ResponseEntity.ok(equipment); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteEquipment(@PathVariable Long id) { + if (!equipmentRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + equipmentRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Оборудование удалено")); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/SubjectController.java b/backend/src/main/java/com/magistr/app/controller/SubjectController.java new file mode 100644 index 0000000..fb9e64f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/SubjectController.java @@ -0,0 +1,51 @@ +package com.magistr.app.controller; + +import com.magistr.app.model.Subject; +import com.magistr.app.repository.SubjectRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/subjects") +public class SubjectController { + + private final SubjectRepository subjectRepository; + + public SubjectController(SubjectRepository subjectRepository) { + this.subjectRepository = subjectRepository; + } + + @GetMapping + public List getAllSubjects() { + return subjectRepository.findAll(); + } + + @PostMapping + public ResponseEntity createSubject(@RequestBody Map request) { + String name = request.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно")); + } + if (subjectRepository.findByName(name.trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует")); + } + + Subject subject = new Subject(); + subject.setName(name.trim()); + subjectRepository.save(subject); + + return ResponseEntity.ok(subject); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteSubject(@PathVariable Long id) { + if (!subjectRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + subjectRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Дисциплина удалена")); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java new file mode 100644 index 0000000..022006f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TeacherSubjectController.java @@ -0,0 +1,85 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TeacherSubjectResponse; +import com.magistr.app.model.TeacherSubject; +import com.magistr.app.model.TeacherSubjectId; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.TeacherSubjectRepository; +import com.magistr.app.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/teacher-subjects") +public class TeacherSubjectController { + + private final TeacherSubjectRepository teacherSubjectRepository; + private final UserRepository userRepository; + private final SubjectRepository subjectRepository; + + public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository, + UserRepository userRepository, + SubjectRepository subjectRepository) { + this.teacherSubjectRepository = teacherSubjectRepository; + this.userRepository = userRepository; + this.subjectRepository = subjectRepository; + } + + @GetMapping + public List getAll() { + return teacherSubjectRepository.findAll().stream() + .map(ts -> new TeacherSubjectResponse( + ts.getUserId(), + ts.getUser().getUsername(), + ts.getSubjectId(), + ts.getSubject().getName() + )) + .toList(); + } + + @PostMapping + public ResponseEntity create(@RequestBody Map request) { + Long userId = request.get("userId"); + Long subjectId = request.get("subjectId"); + + if (userId == null || subjectId == null) { + return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны")); + } + if (!userRepository.existsById(userId)) { + return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден")); + } + if (!subjectRepository.existsById(subjectId)) { + return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена")); + } + + TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); + if (teacherSubjectRepository.existsById(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Привязка уже существует")); + } + + TeacherSubject ts = new TeacherSubject(userId, subjectId); + teacherSubjectRepository.save(ts); + + return ResponseEntity.ok(Map.of("message", "Привязка создана")); + } + + @DeleteMapping + public ResponseEntity delete(@RequestBody Map request) { + Long userId = request.get("userId"); + Long subjectId = request.get("subjectId"); + + if (userId == null || subjectId == null) { + return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны")); + } + + TeacherSubjectId id = new TeacherSubjectId(userId, subjectId); + if (!teacherSubjectRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + teacherSubjectRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Привязка удалена")); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/UserController.java b/backend/src/main/java/com/magistr/app/controller/UserController.java index cd256ef..2dfad6b 100644 --- a/backend/src/main/java/com/magistr/app/controller/UserController.java +++ b/backend/src/main/java/com/magistr/app/controller/UserController.java @@ -31,6 +31,13 @@ public class UserController { .toList(); } + @GetMapping("/teachers") + public List getTeachers() { + return userRepository.findByRole(Role.TEACHER).stream() + .map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name())) + .toList(); + } + @PostMapping public ResponseEntity createUser(@RequestBody CreateUserRequest request) { if (request.getUsername() == null || request.getUsername().isBlank()) { diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java new file mode 100644 index 0000000..72927e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomRequest.java @@ -0,0 +1,42 @@ +package com.magistr.app.dto; + +import java.util.List; + +public class ClassroomRequest { + private String name; + private Integer capacity; + private Boolean isAvailable; + private List equipmentIds; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getCapacity() { + return capacity; + } + + public void setCapacity(Integer capacity) { + this.capacity = capacity; + } + + public Boolean getIsAvailable() { + return isAvailable; + } + + public void setIsAvailable(Boolean isAvailable) { + this.isAvailable = isAvailable; + } + + public List getEquipmentIds() { + return equipmentIds; + } + + public void setEquipmentIds(List equipmentIds) { + this.equipmentIds = equipmentIds; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java new file mode 100644 index 0000000..35b46d5 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ClassroomResponse.java @@ -0,0 +1,63 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.Equipment; +import java.util.List; + +public class ClassroomResponse { + private Long id; + private String name; + private Integer capacity; + private Boolean isAvailable; + private List equipments; + + public ClassroomResponse() { + } + + public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List equipments) { + this.id = id; + this.name = name; + this.capacity = capacity; + this.isAvailable = isAvailable; + this.equipments = equipments; + } + + 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 Integer getCapacity() { + return capacity; + } + + public void setCapacity(Integer capacity) { + this.capacity = capacity; + } + + public Boolean getIsAvailable() { + return isAvailable; + } + + public void setIsAvailable(Boolean isAvailable) { + this.isAvailable = isAvailable; + } + + public List getEquipments() { + return equipments; + } + + public void setEquipments(List equipments) { + this.equipments = equipments; + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java new file mode 100644 index 0000000..c3c4e93 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TeacherSubjectResponse.java @@ -0,0 +1,32 @@ +package com.magistr.app.dto; + +public class TeacherSubjectResponse { + + private Long userId; + private String username; + private Long subjectId; + private String subjectName; + + public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) { + this.userId = userId; + this.username = username; + this.subjectId = subjectId; + this.subjectName = subjectName; + } + + public Long getUserId() { + return userId; + } + + public String getUsername() { + return username; + } + + public Long getSubjectId() { + return subjectId; + } + + public String getSubjectName() { + return subjectName; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Classroom.java b/backend/src/main/java/com/magistr/app/model/Classroom.java new file mode 100644 index 0000000..67b2840 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Classroom.java @@ -0,0 +1,70 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "classrooms") +public class Classroom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private Integer capacity; + + @Column(name = "is_available", nullable = false) + private Boolean isAvailable = true; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name = "classroom_equipments", joinColumns = @JoinColumn(name = "classroom_id"), inverseJoinColumns = @JoinColumn(name = "equipment_id")) + private Set equipments = new HashSet<>(); + + public Classroom() { + } + + 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 Integer getCapacity() { + return capacity; + } + + public void setCapacity(Integer capacity) { + this.capacity = capacity; + } + + public Boolean getIsAvailable() { + return isAvailable; + } + + public void setIsAvailable(Boolean isAvailable) { + this.isAvailable = isAvailable; + } + + public Set getEquipments() { + return equipments; + } + + public void setEquipments(Set equipments) { + this.equipments = equipments; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Equipment.java b/backend/src/main/java/com/magistr/app/model/Equipment.java new file mode 100644 index 0000000..a550410 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Equipment.java @@ -0,0 +1,39 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "equipments") +public class Equipment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 50) + private String name; + + public Equipment() { + } + + public Equipment(Long id, String name) { + this.id = id; + this.name = name; + } + + 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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Subject.java b/backend/src/main/java/com/magistr/app/model/Subject.java new file mode 100644 index 0000000..c11d0b1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Subject.java @@ -0,0 +1,39 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "subjects") +public class Subject { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 200) + private String name; + + public Subject() { + } + + public Subject(Long id, String name) { + this.id = id; + this.name = name; + } + + 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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherSubject.java b/backend/src/main/java/com/magistr/app/model/TeacherSubject.java new file mode 100644 index 0000000..4809bde --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherSubject.java @@ -0,0 +1,57 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "teacher_subjects") +@IdClass(TeacherSubjectId.class) +public class TeacherSubject { + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "subject_id") + private Long subjectId; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "subject_id", insertable = false, updatable = false) + private Subject subject; + + public TeacherSubject() { + } + + public TeacherSubject(Long userId, Long subjectId) { + this.userId = userId; + this.subjectId = subjectId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getSubjectId() { + return subjectId; + } + + public void setSubjectId(Long subjectId) { + this.subjectId = subjectId; + } + + public User getUser() { + return user; + } + + public Subject getSubject() { + return subject; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TeacherSubjectId.java b/backend/src/main/java/com/magistr/app/model/TeacherSubjectId.java new file mode 100644 index 0000000..f4e62b7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TeacherSubjectId.java @@ -0,0 +1,46 @@ +package com.magistr.app.model; + +import java.io.Serializable; +import java.util.Objects; + +public class TeacherSubjectId implements Serializable { + + private Long userId; + private Long subjectId; + + public TeacherSubjectId() { + } + + public TeacherSubjectId(Long userId, Long subjectId) { + this.userId = userId; + this.subjectId = subjectId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getSubjectId() { + return subjectId; + } + + public void setSubjectId(Long subjectId) { + this.subjectId = subjectId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TeacherSubjectId that)) return false; + return Objects.equals(userId, that.userId) && Objects.equals(subjectId, that.subjectId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, subjectId); + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java new file mode 100644 index 0000000..aee60f2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ClassroomRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Classroom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ClassroomRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java new file mode 100644 index 0000000..8d5a445 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/EquipmentRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Equipment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EquipmentRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java new file mode 100644 index 0000000..8c5a8fa --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubjectRepository.java @@ -0,0 +1,10 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Subject; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubjectRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java new file mode 100644 index 0000000..8f90553 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TeacherSubjectRepository.java @@ -0,0 +1,12 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TeacherSubject; +import com.magistr.app.model.TeacherSubjectId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TeacherSubjectRepository extends JpaRepository { + List findByUserId(Long userId); + List findBySubjectId(Long subjectId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/UserRepository.java b/backend/src/main/java/com/magistr/app/repository/UserRepository.java index 5c7ae4a..3711bd3 100644 --- a/backend/src/main/java/com/magistr/app/repository/UserRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/UserRepository.java @@ -1,11 +1,15 @@ package com.magistr.app.repository; +import com.magistr.app.model.Role; import com.magistr.app.model.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + + List findByRole(Role role); } diff --git a/compose.yaml b/compose.yaml index b266c61..a918b89 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,6 +27,8 @@ services: image: postgres:alpine3.23 container_name: db restart: always + ports: + - "5432:5432" environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} diff --git a/db/init/init.sql b/db/init/init.sql index 4e21755..4234149 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1,8 +1,5 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; --- ===================================================== --- 1. Таблица users --- ===================================================== CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, @@ -10,48 +7,135 @@ CREATE TABLE IF NOT EXISTS users ( role VARCHAR(20) NOT NULL DEFAULT 'STUDENT' ); --- Админ по умолчанию +-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto) INSERT INTO users (username, password, role) VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN') ON CONFLICT (username) DO NOTHING; --- ===================================================== --- 2. Таблица education_forms --- ===================================================== CREATE TABLE IF NOT EXISTS education_forms ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL ); -INSERT INTO education_forms (name) -VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет') +INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет') ON CONFLICT (name) DO NOTHING; --- ===================================================== --- 3. Таблица student_groups --- ===================================================== CREATE TABLE IF NOT EXISTS student_groups ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, education_form_id BIGINT NOT NULL REFERENCES education_forms(id) ); +-- ========================================== +-- Справочники +-- ========================================== --- ===================================================== --- 4. Таблица lessons (ЭТА ЧАСТЬ БЫЛА ИСПОРЧЕНА) --- ===================================================== -CREATE TABLE IF NOT EXISTS lessons ( +-- Дисциплины +CREATE TABLE IF NOT EXISTS subjects ( id BIGSERIAL PRIMARY KEY, - teacher_id BIGINT NOT NULL, - group_id BIGINT NOT NULL, - discipline_id BIGINT NOT NULL, - day VARCHAR(255) NOT NULL, - week VARCHAR(255) NOT NULL, - time VARCHAR(255) NOT NULL + name VARCHAR(200) UNIQUE NOT NULL ); --- Добавляем внешние ключи -ALTER TABLE lessons ADD CONSTRAINT fk_lessons_teacher - FOREIGN KEY (teacher_id) REFERENCES users(id); +INSERT INTO subjects (name) VALUES +('Высшая математика'), +('Философия'), +('Информатика'), +('Базы данных'), +('Английский язык') +ON CONFLICT (name) DO NOTHING; -ALTER TABLE lessons ADD CONSTRAINT fk_lessons_group - FOREIGN KEY (group_id) REFERENCES student_groups(id); \ No newline at end of file +-- Типы занятий +CREATE TABLE IF NOT EXISTS lesson_types ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL +); + +INSERT INTO lesson_types (name) VALUES +('Лекция'), +('Практика'), +('Лабораторная работа') +ON CONFLICT (name) DO NOTHING; + +-- Оборудование +CREATE TABLE IF NOT EXISTS equipments ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL +); + +INSERT INTO equipments (name) VALUES +('Проектор'), +('ПК'), +('Лаборатория'), +('Интерактивная доска') +ON CONFLICT (name) DO NOTHING; + +-- Аудитории +CREATE TABLE IF NOT EXISTS classrooms ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + capacity INT NOT NULL, + is_available BOOLEAN DEFAULT TRUE +); + +INSERT INTO classrooms (name, capacity) VALUES +('101 Ленинская', 120), +('202 IT Lab', 20), +('303 Обычная', 30) +ON CONFLICT (name) DO NOTHING; + +-- Привязка оборудования к аудиториям (Many-to-Many) +CREATE TABLE IF NOT EXISTS classroom_equipments ( + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE, + PRIMARY KEY (classroom_id, equipment_id) +); + +-- Заполнение привязок оборудования (на основе ID базовых данных) +-- '101 Ленинская' -> Проектор (1), Интерактивная доска (4) +INSERT INTO classroom_equipments (classroom_id, equipment_id) VALUES +(1, 1), (1, 4), +-- '202 IT Lab' -> ПК (2), Проектор (1), Лаборатория (3) +(2, 2), (2, 1), (2, 3) +-- '303 Обычная' -> ничего +ON CONFLICT DO NOTHING; + +-- ========================================== +-- Связи для преподавателей и студентов +-- ========================================== + +-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1") +CREATE TABLE IF NOT EXISTS subgroups ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + UNIQUE(group_id, name) +); + +-- Тестовая базовая группа для работы +INSERT INTO student_groups (name, education_form_id) +VALUES ('ИВТ-21-1', 1) +ON CONFLICT (name) DO NOTHING; + +-- Привязка преподавателей к дисциплинам +CREATE TABLE IF NOT EXISTS teacher_subjects ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + PRIMARY KEY(user_id, subject_id) +); + +-- ========================================== +-- Основная таблица Расписания (Lessons) +-- ========================================== +CREATE TABLE IF NOT EXISTS lessons ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id), + subject_id BIGINT NOT NULL REFERENCES subjects(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + classroom_id BIGINT NOT NULL REFERENCES classrooms(id), + group_id BIGINT NOT NULL REFERENCES student_groups(id), -- первичная группа + subgroup_id BIGINT REFERENCES subgroups(id), -- необязательно (если делим группу) + + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), -- 1=Понедельник, 7=Воскресенье + is_even_week BOOLEAN NOT NULL, -- Четная/нечетная неделя + start_time TIME NOT NULL, -- Напр. '08:00:00' + end_time TIME NOT NULL -- Напр. '09:30:00' +); diff --git a/frontend/admin/admin.css b/frontend/admin/admin.css index 76d3749..f178ab8 100644 --- a/frontend/admin/admin.css +++ b/frontend/admin/admin.css @@ -8,45 +8,56 @@ } :root { - --bg-primary: #0f0f1a; - --bg-sidebar: rgba(255, 255, 255, 0.03); - --bg-card: rgba(255, 255, 255, 0.05); - --bg-card-border: rgba(255, 255, 255, 0.08); - --bg-input: rgba(255, 255, 255, 0.06); - --bg-input-focus: rgba(255, 255, 255, 0.1); + /* Deep dark premium background */ + --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: #f0f0f5; - --text-secondary: #9ca3af; - --text-placeholder: #6b7280; - --accent: #6366f1; - --accent-hover: #818cf8; - --accent-glow: rgba(99, 102, 241, 0.35); - --error: #f87171; - --success: #34d399; - --warning: #fbbf24; - --radius-sm: 8px; - --radius-md: 12px; - --transition: 0.2s ease; + + /* Typography */ + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-placeholder: #475569; + + /* Vibrant Accents */ + --accent: #8b5cf6; + --accent-hover: #a78bfa; + --accent-glow: rgba(139, 92, 246, 0.4); + --accent-secondary: #ec4899; + + /* Status Colors */ + --error: #ef4444; + --success: #10b981; + --warning: #f59e0b; + + /* Spatial */ + --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: #e8eaef; - --bg-sidebar: rgba(255, 255, 255, 0.88); - --bg-card: rgba(255, 255, 255, 0.95); - --bg-card-border: rgba(0, 0, 0, 0.22); - --bg-input: rgba(0, 0, 0, 0.08); - --bg-input-focus: rgba(0, 0, 0, 0.12); - --bg-hover: rgba(0, 0, 0, 0.08); + --bg-primary: #f8fafc; + --bg-sidebar: rgba(255, 255, 255, 0.7); + --bg-card: rgba(255, 255, 255, 0.7); + --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: #374151; - --text-placeholder: #6b7280; + --text-secondary: #475569; + --text-placeholder: #94a3b8; --accent: #6366f1; --accent-hover: #4f46e5; - --accent-glow: rgba(99, 102, 241, 0.25); - --error: #dc2626; - --success: #16a34a; - --warning: #d97706; + --accent-glow: rgba(99, 102, 241, 0.3); + --accent-secondary: #d946ef; + --error: #ef4444; + --success: #10b981; + --warning: #f59e0b; } [data-theme="light"] .form-group select option, @@ -59,6 +70,10 @@ background: rgba(99, 102, 241, 0.18); } +[data-theme="light"] .custom-multi-select .dropdown-menu { + background: rgba(255, 255, 255, 0.98); +} + [data-theme="light"] .form-group input, [data-theme="light"] .form-group select, [data-theme="light"] .filter-row select { @@ -85,9 +100,11 @@ body { /* ===== Sidebar ===== */ .sidebar { - width: 240px; + 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; @@ -96,7 +113,7 @@ body { top: 0; bottom: 0; z-index: 10; - transition: background 0.4s ease, border-color 0.4s ease; + transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); } .sidebar-header { @@ -121,19 +138,124 @@ body { .nav-item { display: flex; align-items: center; - gap: 0.6rem; - padding: 0.65rem 0.8rem; + 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.9rem; + font-size: 0.95rem; font-weight: 500; - transition: background var(--transition), color var(--transition); + 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); +} + +.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); +} + +/* Checkbox list styling */ +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 8px 0; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + color: var(--text-base); + cursor: pointer; +} + +.checkbox-item input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + accent-color: var(--primary-color); +} + +/* Classroom Status Badges */ +.badge-available { + background-color: var(--success-bg); + color: var(--success-color); +} + +.badge-unavailable { + background-color: var(--error-bg); + color: var(--error-color); +} + +.status-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-icon-toggle { + background: var(--bg-body); + border: 1px solid var(--border-color); + color: var(--text-muted); + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + padding: 0; +} + +.btn-icon-toggle:hover { + background: var(--card-bg); + border-color: var(--primary-color); + color: var(--primary-color); + transform: rotate(45deg); + box-shadow: 0 0 10px rgba(99, 102, 241, 0.2); +} + +.btn-icon-toggle svg { + display: block; } .nav-item.active { @@ -170,7 +292,7 @@ body { /* ===== Main ===== */ .main { flex: 1; - margin-left: 240px; + margin-left: 260px; min-height: 100vh; } @@ -199,12 +321,70 @@ body { } /* ===== Cards ===== */ +@keyframes slideUpCard { + from { + opacity: 0; + transform: translateY(15px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .card { background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); border: 1px solid var(--bg-card-border); border-radius: var(--radius-md); - padding: 1.5rem; - transition: background 0.4s ease, border-color 0.4s ease; + padding: 1.75rem; + position: relative; + overflow: visible; + transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + animation: slideUpCard 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) both; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); + opacity: 0; + transition: opacity var(--transition); +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.12); +} + +.card:hover::before { + opacity: 1; +} + +/* Staggered cards */ +.card:nth-child(1) { + animation-delay: 0.1s; +} + +.card:nth-child(2) { + animation-delay: 0.2s; +} + +.card:nth-child(3) { + animation-delay: 0.3s; +} + +/* Specific Cards */ +.create-card { + z-index: 10; } .card h2 { @@ -242,26 +422,43 @@ body { .form-group input, .form-group select { width: 100%; - padding: 0.65rem 0.8rem; + padding: 0.75rem 1rem; background: var(--bg-input); - border: 1px solid transparent; + border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); color: var(--text-primary); font-family: inherit; - font-size: 0.9rem; + font-size: 0.95rem; outline: none; - transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); + transition: all var(--transition); } .form-group input::placeholder { color: var(--text-placeholder); + transition: opacity var(--transition); } .form-group input:focus, .form-group select:focus { background: var(--bg-input-focus); border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); + box-shadow: 0 0 0 4px var(--accent-glow); + transform: translateY(-1px); +} + +.form-group input:focus::placeholder { + opacity: 0.5; +} + +/* Hide Number Arrows */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; } .form-group select { @@ -279,23 +476,60 @@ body { } .btn-create { - padding: 0.65rem 1.5rem; - background: linear-gradient(135deg, var(--accent), #8b5cf6); + position: relative; + overflow: hidden; + padding: 0.75rem 1.75rem; + background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); border: none; border-radius: var(--radius-sm); color: #fff; font-family: inherit; - font-size: 0.9rem; + font-size: 0.95rem; font-weight: 600; + letter-spacing: 0.02em; cursor: pointer; white-space: nowrap; - transition: transform var(--transition), box-shadow var(--transition); - box-shadow: 0 2px 10px var(--accent-glow); + transition: all var(--transition); + box-shadow: 0 4px 15px var(--accent-glow); +} + +.btn-create::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(rgba(255, 255, 255, 0.2), transparent); + border-radius: inherit; + opacity: 0; + transition: opacity var(--transition); } .btn-create:hover { - transform: translateY(-1px); - box-shadow: 0 4px 16px var(--accent-glow); + transform: translateY(-2px); + box-shadow: 0 8px 25px var(--accent-glow); +} + +.btn-create:hover::before { + opacity: 1; +} + +.btn-create:active { + transform: translateY(1px); + box-shadow: 0 2px 10px var(--accent-glow); +} + +@keyframes slideDownAlert { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .form-alert { @@ -311,6 +545,7 @@ body { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.2); color: var(--error); + animation: slideDownAlert 0.3s ease-out both; } .form-alert.success { @@ -318,11 +553,12 @@ body { background: rgba(52, 211, 153, 0.1); border: 1px solid rgba(52, 211, 153, 0.2); color: var(--success); + animation: slideDownAlert 0.3s ease-out both; } /* ===== Table ===== */ .table-wrap { - overflow-x: auto; + overflow-x: visible; } table { @@ -342,13 +578,63 @@ thead th { } tbody td { - padding: 0.7rem 0.8rem; - font-size: 0.9rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.03); + padding: 0.85rem 1rem; + font-size: 0.95rem; + border-bottom: 1px solid var(--bg-card-border); + transition: background var(--transition); +} + +@keyframes slideInRow { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInRow { + from { + opacity: 0; + transform: translateX(-10px); + } + + to { + opacity: 1; + transform: translateX(0); + } } tbody tr { transition: background var(--transition); + animation: slideInRow 0.3s ease-out both; +} + +tbody tr:nth-child(1) { + animation-delay: 0.05s; +} + +tbody tr:nth-child(2) { + animation-delay: 0.1s; +} + +tbody tr:nth-child(3) { + animation-delay: 0.15s; +} + +tbody tr:nth-child(4) { + animation-delay: 0.2s; +} + +tbody tr:nth-child(5) { + animation-delay: 0.25s; +} + +tbody tr:nth-child(n+6) { + animation-delay: 0.3s; } tbody tr:hover { @@ -477,11 +763,29 @@ tbody tr:hover { font-family: inherit; font-size: 0.8rem; cursor: pointer; - transition: background var(--transition); + transition: background var(--transition), transform var(--transition); } .btn-delete:hover { background: rgba(248, 113, 113, 0.2); + transform: scale(1.05); +} + +/* ===== Ripple Effect ===== */ +.ripple { + position: absolute; + border-radius: 50%; + transform: scale(0); + animation: admin-ripple 0.6s linear; + background-color: rgba(255, 255, 255, 0.3); + pointer-events: none; +} + +@keyframes admin-ripple { + to { + transform: scale(4); + opacity: 0; + } } /* ===== Mobile Menu Toggle ===== */ @@ -512,23 +816,27 @@ tbody tr:hover { /* ===== Theme Toggle Button ===== */ .theme-toggle { - width: 38px; - height: 38px; + width: 40px; + height: 40px; + border: none; border-radius: 50%; - background: var(--bg-input); + 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; - transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: all var(--transition); + z-index: 100; flex-shrink: 0; } .theme-toggle svg { - width: 18px; - height: 18px; + width: 20px; + height: 20px; transition: transform 0.4s ease; } @@ -541,11 +849,142 @@ tbody tr:hover { transform: scale(0.95); } -.theme-toggle--fixed { +/* ===== Custom Multi Select ===== */ +.custom-multi-select { + position: relative; + width: 100%; +} + +.custom-multi-select .select-box { + 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); + font-family: inherit; + font-size: 0.95rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + transition: all var(--transition); +} + +.custom-multi-select .select-box.active { + background: var(--bg-input-focus); + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-glow); +} + +.custom-multi-select .dropdown-icon { + transition: transform var(--transition); +} + +.custom-multi-select .select-box.active .dropdown-icon { + transform: rotate(180deg); +} + +.custom-multi-select .dropdown-menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + width: 100%; + background: rgba(15, 15, 26, 0.98); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + padding: 0.75rem; + z-index: 9999; + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all var(--transition); + max-height: 250px; + overflow-y: auto; +} + +.custom-multi-select .dropdown-menu.open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.checkbox-group-vertical { + display: flex; + flex-direction: column; + gap: 8px; +} + +.checkbox-group-vertical .checkbox-item { + padding: 6px 8px; + border-radius: 6px; + transition: background var(--transition); + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-group-vertical .checkbox-item:hover { + background: var(--bg-hover); +} + +/* ===== Modals ===== */ +.modal-overlay { + display: none; position: fixed; - top: 1.25rem; - right: 1.25rem; - z-index: 100; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + backdrop-filter: blur(2px); + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-overlay.open { + display: flex; + animation: fadeIn 0.2s ease; +} + +.modal-content { + background: var(--bg-primary); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + padding: 2rem; + width: 100%; + max-width: 600px; + position: relative; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); +} + +.modal-content h2 { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); + border-bottom: 1px solid var(--bg-card-border); + padding-bottom: 1rem; +} + +.modal-close { + position: absolute; + top: 1.5rem; + right: 1.5rem; + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + transition: color var(--transition); +} + +.modal-close:hover { + color: var(--error); } /* ===== Responsive ===== */ diff --git a/frontend/admin/admin.js b/frontend/admin/admin.js index 425f28a..9d0c01b 100644 --- a/frontend/admin/admin.js +++ b/frontend/admin/admin.js @@ -16,6 +16,30 @@ const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); + // Global Ripple Effect + document.addEventListener('click', function (e) { + const btn = e.target.closest('.btn-create, .btn-delete, .btn-logout'); + if (!btn) return; + + const rect = btn.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ripple = document.createElement('span'); + ripple.classList.add('ripple'); + ripple.style.left = `${x}px`; + ripple.style.top = `${y}px`; + + if (getComputedStyle(btn).position === 'static') { + btn.style.position = 'relative'; + } + btn.style.overflow = 'hidden'; + + btn.appendChild(ripple); + + setTimeout(() => ripple.remove(), 600); + }); + // Users const usersTbody = document.getElementById('users-tbody'); const createForm = document.getElementById('create-form'); @@ -33,18 +57,100 @@ const createEfForm = document.getElementById('create-ef-form'); const createEfAlert = document.getElementById('create-ef-alert'); + // Classrooms + const classroomsTbody = document.getElementById('classrooms-tbody'); + const createClassroomForm = document.getElementById('create-classroom-form'); + const createClassroomAlert = document.getElementById('create-classroom-alert'); + const modalEditClassroom = document.getElementById('modal-edit-classroom'); + const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close'); + const editClassroomForm = document.getElementById('edit-classroom-form'); + const editClassroomAlert = document.getElementById('edit-classroom-alert'); + const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes'); + + // Equipments + const equipmentsTbody = document.getElementById('equipments-tbody'); + const createEquipmentForm = document.getElementById('create-equipment-form'); + const createEquipmentAlert = document.getElementById('create-equipment-alert'); + const equipmentCheckboxes = document.getElementById('equipment-checkboxes'); + + // Subjects + const subjectsTbody = document.getElementById('subjects-tbody'); + const createSubjectForm = document.getElementById('create-subject-form'); + const createSubjectAlert = document.getElementById('create-subject-alert'); + const assignTeacherForm = document.getElementById('assign-teacher-form'); + const assignTeacherAlert = document.getElementById('assign-teacher-alert'); + const assignTeacherSelect = document.getElementById('assign-teacher-select'); + const assignSubjectSelect = document.getElementById('assign-subject-select'); + const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody'); + + // --- Multi-select logic --- + function updateSelectText(containerId, textId) { + const container = document.getElementById(containerId); + const textEl = document.getElementById(textId); + if (!container || !textEl) return; + const checked = Array.from(container.querySelectorAll('input:checked')); + if (checked.length === 0) { + textEl.textContent = 'Выберите оборудование...'; + } else if (checked.length === 1) { + textEl.textContent = checked[0].parentElement.textContent.trim(); + } else { + textEl.textContent = `Выбрано: ${checked.length}`; + } + } + + function initMultiSelect(boxId, menuId, textId, checkboxContainerId) { + const box = document.getElementById(boxId); + const menu = document.getElementById(menuId); + const container = document.getElementById(checkboxContainerId); + if (!box || !menu || !container) return; + + box.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = menu.classList.contains('open'); + document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active')); + if (!isOpen) { + menu.classList.add('open'); + box.classList.add('active'); + } + }); + + menu.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + container.addEventListener('change', () => { + updateSelectText(checkboxContainerId, textId); + }); + } + + initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes'); + initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes'); + + document.addEventListener('click', () => { + document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open')); + document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active')); + }); + // -------------------------- + const navItems = document.querySelectorAll('.nav-item[data-tab]'); const tabContents = document.querySelectorAll('.tab-content'); // ---- State ---- let allGroups = []; let allEducationForms = []; + let allEquipments = []; + let allSubjects = []; + let allTeachers = []; // ---- Tab Switching ---- const TAB_TITLES = { users: 'Управление пользователями', groups: 'Управление группами', 'edu-forms': 'Формы обучения', + equipments: 'Оборудование', + classrooms: 'Аудитории', + subjects: 'Дисциплины и преподаватели' }; navItems.forEach(item => { @@ -67,6 +173,9 @@ if (tab === 'users') loadUsers(); if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); } if (tab === 'edu-forms') loadEducationForms(); + if (tab === 'equipments') loadEquipments(); + if (tab === 'classrooms') { loadEquipments().then(() => loadClassrooms()); } + if (tab === 'subjects') { Promise.all([loadSubjects(), loadTeachers()]).then(() => loadTeacherSubjects()); } sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); @@ -129,6 +238,7 @@ ${escapeHtml(u.username)} ${ROLE_LABELS[u.role] || u.role} + `).join(''); } @@ -340,6 +450,453 @@ } catch (e) { alert('Ошибка соединения'); } }); + // ============================================================ + // EQUIPMENTS + // ============================================================ + + async function loadEquipments() { + try { + const res = await fetch('/api/equipments', { + headers: { 'Authorization': 'Bearer ' + token }, + }); + allEquipments = await res.json(); + renderEquipments(allEquipments); + renderEquipmentCheckboxes(allEquipments); + } catch (e) { + if (equipmentsTbody) equipmentsTbody.innerHTML = 'Ошибка загрузки'; + if (equipmentCheckboxes) equipmentCheckboxes.innerHTML = '

Ошибка загрузки

'; + } + } + + function renderEquipments(equipments) { + if (!equipments.length) { + equipmentsTbody.innerHTML = 'Нет оборудования'; + return; + } + equipmentsTbody.innerHTML = equipments.map(eq => ` + + ${eq.id} + ${escapeHtml(eq.name)} + + `).join(''); + } + + function renderEquipmentCheckboxes(equipments) { + if (!equipments.length) { + equipmentCheckboxes.innerHTML = '

Нет доступного оборудования

'; + return; + } + equipmentCheckboxes.innerHTML = equipments.map(eq => ` + + `).join(''); + updateSelectText('equipment-checkboxes', 'equipment-select-text'); + } + + createEquipmentForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert(createEquipmentAlert); + const name = document.getElementById('new-equipment-name').value.trim(); + if (!name) { showAlert(createEquipmentAlert, 'Введите название', 'error'); return; } + + try { + const res = await fetch('/api/equipments', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (res.ok) { + showAlert(createEquipmentAlert, `Оборудование "${data.name}" добавлено`, 'success'); + createEquipmentForm.reset(); + loadEquipments(); + } else { + showAlert(createEquipmentAlert, data.message || 'Ошибка создания', 'error'); + } + } catch (e) { showAlert(createEquipmentAlert, 'Ошибка соединения', 'error'); } + }); + + equipmentsTbody.addEventListener('click', async (e) => { + const btn = e.target.closest('.btn-delete'); + if (!btn) return; + if (!confirm('Удалить оборудование?')) return; + try { + const res = await fetch('/api/equipments/' + btn.dataset.id, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token }, + }); + if (res.ok) { + loadEquipments(); + } else { + const data = await res.json(); + alert(data.message || 'Ошибка удаления'); + } + } catch (e) { alert('Ошибка соединения'); } + }); + + // ============================================================ + // CLASSROOMS + // ============================================================ + + async function loadClassrooms() { + try { + const res = await fetch('/api/classrooms', { + headers: { 'Authorization': 'Bearer ' + token }, + }); + const classrooms = await res.json(); + renderClassrooms(classrooms); + } catch (e) { + classroomsTbody.innerHTML = 'Ошибка загрузки'; + } + } + + function renderClassrooms(classrooms) { + if (!classrooms.length) { + classroomsTbody.innerHTML = 'Нет аудиторий'; + return; + } + classroomsTbody.innerHTML = classrooms.map(c => { + const equipHtml = c.equipments && c.equipments.length + ? c.equipments.map(eq => escapeHtml(eq.name)).join(', ') + : '—'; + + return ` + + ${c.id} + ${escapeHtml(c.name)} + ${c.capacity} чел. + ${equipHtml} + +
+ + ${c.isAvailable ? 'Доступна' : 'Не доступна'} + + +
+ + + + + + `; + }).join(''); + } + + createClassroomForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert(createClassroomAlert); + const name = document.getElementById('new-classroom-name').value.trim(); + const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10); + + const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked')); + const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10)); + + if (!name || isNaN(capacity)) { showAlert(createClassroomAlert, 'Заполните обязательные поля', 'error'); return; } + + try { + const res = await fetch('/api/classrooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ name, capacity, equipmentIds, isAvailable: true }), + }); + const data = await res.json(); + if (res.ok) { + showAlert(createClassroomAlert, `Аудитория "${data.name}" добавлена`, 'success'); + createClassroomForm.reset(); + updateSelectText('equipment-checkboxes', 'equipment-select-text'); + loadClassrooms(); + } else { + showAlert(createClassroomAlert, data.message || 'Ошибка создания', 'error'); + } + } catch (e) { showAlert(createClassroomAlert, 'Ошибка соединения', 'error'); } + }); + + classroomsTbody.addEventListener('click', async (e) => { + const btnDelete = e.target.closest('.btn-delete'); + const btnToggleStatus = e.target.closest('.btn-icon-toggle'); + const btnEdit = e.target.closest('.btn-edit-classroom'); + + if (btnDelete) { + if (!confirm('Удалить аудиторию?')) return; + try { + const res = await fetch('/api/classrooms/' + btnDelete.dataset.id, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token }, + }); + if (res.ok) loadClassrooms(); + else alert('Ошибка удаления'); + } catch (err) { alert('Ошибка соединения'); } + } + + if (btnToggleStatus) { + const id = btnToggleStatus.dataset.id; + const currentStatus = btnToggleStatus.dataset.currentStatus === 'true'; + try { + const res = await fetch('/api/classrooms/' + id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ isAvailable: !currentStatus }), + }); + if (res.ok) loadClassrooms(); + else alert('Ошибка изменения статуса'); + } catch (err) { alert('Ошибка соединения'); } + } + + if (btnEdit) { + const id = btnEdit.dataset.id; + openEditClassroomModal(id); + } + }); + + let editingClassroomData = null; + + async function openEditClassroomModal(id) { + try { + const res = await fetch('/api/classrooms', { headers: { 'Authorization': 'Bearer ' + token } }); + const classrooms = await res.json(); + editingClassroomData = classrooms.find(c => c.id == id); + + if (!editingClassroomData) return; + + document.getElementById('edit-classroom-id').value = editingClassroomData.id; + document.getElementById('edit-classroom-name').value = editingClassroomData.name; + document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity; + + if (allEquipments.length) { + editEquipmentCheckboxes.innerHTML = allEquipments.map(eq => { + const isChecked = editingClassroomData.equipments.some(e => e.id === eq.id) ? 'checked' : ''; + return ` + + `; + }).join(''); + } else { + editEquipmentCheckboxes.innerHTML = '

Нет доступного оборудования

'; + } + updateSelectText('edit-equipment-checkboxes', 'edit-equipment-select-text'); + + hideAlert(editClassroomAlert); + modalEditClassroom.classList.add('open'); + } catch (e) { + alert('Ошибка загрузки данных аудитории'); + } + } + + modalEditClassroomClose.addEventListener('click', () => { + modalEditClassroom.classList.remove('open'); + }); + + modalEditClassroom.addEventListener('click', (e) => { + if (e.target === modalEditClassroom) { + modalEditClassroom.classList.remove('open'); + } + }); + + editClassroomForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert(editClassroomAlert); + const id = document.getElementById('edit-classroom-id').value; + const name = document.getElementById('edit-classroom-name').value.trim(); + const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10); + + const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked')); + const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10)); + + if (!name || isNaN(capacity)) { showAlert(editClassroomAlert, 'Заполните обязательные поля', 'error'); return; } + + try { + const res = await fetch('/api/classrooms/' + id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ name, capacity, equipmentIds, isAvailable: editingClassroomData.isAvailable }), + }); + const data = await res.json(); + if (res.ok) { + modalEditClassroom.classList.remove('open'); + showAlert(createClassroomAlert, `Аудитория "${data.name}" обновлена`, 'success'); + loadClassrooms(); + } else { + showAlert(editClassroomAlert, data.message || 'Ошибка обновления', 'error'); + } + } catch (e) { showAlert(editClassroomAlert, 'Ошибка соединения', 'error'); } + }); + + // ============================================================ + // SUBJECTS + // ============================================================ + + async function loadSubjects() { + try { + const res = await fetch('/api/subjects', { + headers: { 'Authorization': 'Bearer ' + token }, + }); + allSubjects = await res.json(); + renderSubjects(allSubjects); + populateSubjectSelect(allSubjects); + } catch (e) { + if (subjectsTbody) subjectsTbody.innerHTML = 'Ошибка загрузки'; + } + } + + function renderSubjects(subjects) { + if (!subjects.length) { + subjectsTbody.innerHTML = 'Нет дисциплин'; + return; + } + subjectsTbody.innerHTML = subjects.map(s => ` + + ${s.id} + ${escapeHtml(s.name)} + + `).join(''); + } + + function populateSubjectSelect(subjects) { + if (!assignSubjectSelect) return; + const currentVal = assignSubjectSelect.value; + assignSubjectSelect.innerHTML = '' + + subjects.map(s => ``).join(''); + if (currentVal && subjects.find(s => s.id == currentVal)) { + assignSubjectSelect.value = currentVal; + } + } + + async function loadTeachers() { + try { + const res = await fetch('/api/users/teachers', { + headers: { 'Authorization': 'Bearer ' + token }, + }); + allTeachers = await res.json(); + populateTeacherSelect(allTeachers); + } catch (e) { + if (assignTeacherSelect) assignTeacherSelect.innerHTML = ''; + } + } + + function populateTeacherSelect(teachers) { + if (!assignTeacherSelect) return; + const currentVal = assignTeacherSelect.value; + if (!teachers.length) { + assignTeacherSelect.innerHTML = ''; + return; + } + assignTeacherSelect.innerHTML = '' + + teachers.map(t => ``).join(''); + if (currentVal && teachers.find(t => t.id == currentVal)) { + assignTeacherSelect.value = currentVal; + } + } + + async function loadTeacherSubjects() { + try { + const res = await fetch('/api/teacher-subjects', { + headers: { 'Authorization': 'Bearer ' + token }, + }); + const tsData = await res.json(); + renderTeacherSubjects(tsData); + } catch (e) { + if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = 'Ошибка загрузки'; + } + } + + function renderTeacherSubjects(tsArray) { + if (!tsArray.length) { + teacherSubjectsTbody.innerHTML = 'Нет привязок'; + return; + } + teacherSubjectsTbody.innerHTML = tsArray.map(ts => ` + + ${escapeHtml(ts.username)} + ${escapeHtml(ts.subjectName)} + + `).join(''); + } + + createSubjectForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert(createSubjectAlert); + const name = document.getElementById('new-subject-name').value.trim(); + if (!name) { showAlert(createSubjectAlert, 'Введите название', 'error'); return; } + + try { + const res = await fetch('/api/subjects', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (res.ok) { + showAlert(createSubjectAlert, `Дисциплина "${data.name}" добавлена`, 'success'); + createSubjectForm.reset(); + loadSubjects(); + } else { + showAlert(createSubjectAlert, data.message || 'Ошибка создания', 'error'); + } + } catch (e) { showAlert(createSubjectAlert, 'Ошибка соединения', 'error'); } + }); + + subjectsTbody.addEventListener('click', async (e) => { + const btn = e.target.closest('.btn-delete'); + if (!btn) return; + if (!confirm('Удалить дисциплину?')) return; + try { + const res = await fetch('/api/subjects/' + btn.dataset.id, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token }, + }); + if (res.ok) { + loadSubjects(); + loadTeacherSubjects(); + } else { + const data = await res.json(); + alert(data.message || 'Ошибка удаления'); + } + } catch (e) { alert('Ошибка соединения'); } + }); + + assignTeacherForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert(assignTeacherAlert); + const userId = assignTeacherSelect.value; + const subjectId = assignSubjectSelect.value; + if (!userId) { showAlert(assignTeacherAlert, 'Выберите преподавателя', 'error'); return; } + if (!subjectId) { showAlert(assignTeacherAlert, 'Выберите дисциплину', 'error'); return; } + + try { + const res = await fetch('/api/teacher-subjects', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ userId: Number(userId), subjectId: Number(subjectId) }), + }); + const data = await res.json(); + if (res.ok) { + showAlert(assignTeacherAlert, 'Привязка создана', 'success'); + loadTeacherSubjects(); + } else { + showAlert(assignTeacherAlert, data.message || 'Ошибка привязки', 'error'); + } + } catch (e) { showAlert(assignTeacherAlert, 'Ошибка соединения', 'error'); } + }); + + teacherSubjectsTbody.addEventListener('click', async (e) => { + const btn = e.target.closest('.btn-delete'); + if (!btn) return; + if (!confirm('Удалить привязку?')) return; + try { + const res = await fetch('/api/teacher-subjects', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ userId: Number(btn.dataset.userId), subjectId: Number(btn.dataset.subjectId) }), + }); + if (res.ok) loadTeacherSubjects(); + else alert('Ошибка удаления'); + } catch (e) { alert('Ошибка соединения'); } + }); + // ============================================================ // LOGOUT & INIT // ============================================================ diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 1fa9ac6..c8fa44a 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -59,6 +59,29 @@ Формы обучения + + + + + + Оборудование + + + + + + Аудитории + + + + + + + Дисциплины + + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index 65a4acf..68e1607 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,7 +37,7 @@
-
+
@@ -49,7 +49,7 @@
-
+
@@ -67,9 +67,9 @@
- + -