Compare commits

..

19 Commits

Author SHA1 Message Date
Zuev
18f47c6d3d fix: adapt init.sql lessons table to match Create-Lesson branch entity 2026-02-25 23:27:17 +03:00
ProstoDenya01
007b4fb619 Поправил id дисциплины 2026-02-25 23:13:49 +03:00
ProstoDenya01
678cf94ad3 Merge remote-tracking branch 'refs/remotes/origin/main' into Create-Lesson
# Conflicts:
#	db/init/init.sql
2026-02-25 22:57:32 +03:00
ProstoDenya01
94aa164930 Добавил еще немного 2026-02-25 22:56:44 +03:00
Zuev
9776f4a56f chore: stop tracking .agent folder 2026-02-25 17:38:14 +03:00
Zuev
df6ddeecd5 feat: add subjects & teacher-subjects management tab in admin panel 2026-02-25 17:34:01 +03:00
ProstoDenya01
e44ac04cac Добавил еще немного 2026-02-25 14:35:20 +03:00
Zuev
0ac81284fa chore: проброс порта БД для DBeaver 2026-02-24 20:14:47 +03:00
Zuev
74d937f6dc feat: complete UI redesign with glassmorphism and custom multi-select equipment dropdown 2026-02-21 01:35:56 +03:00
Zuev
eea444409e docs(rules): update database rules to match init.sql 2026-02-20 02:59:10 +03:00
Zuev
c552d14909 feat(backend): implement equipments entities and modify db 2026-02-20 02:53:47 +03:00
Zuev
be46fa2be2 fix(deploy): update workflow with escaped bash string 2026-02-20 02:52:43 +03:00
Zuev
6993ac29d5 feat(admin): add classroom edit modal 2026-02-20 02:49:51 +03:00
Zuev
07419d541e feat(db): update init.sql with subjects, lesson types, classrooms, and schedules tables 2026-02-20 01:56:05 +03:00
Zuev
86a29f6419 feat(frontend): add dynamic animations to login and admin panel 2026-02-20 00:48:03 +03:00
Zuev
e9c08b4c75 chore(git): update git-push skill and script with SSH info 2026-02-19 20:42:25 +03:00
Zuev
ed8668c599 chore: update agent rules, skills and workflows 2026-02-19 20:33:47 +03:00
EgorZuev
64d85eab55 docs(agent): add rules and skills 2026-02-18 21:56:57 +00:00
ProstoDenya01
c3d5246874 Написал запрос на создание занятия 2026-02-18 18:46:24 +03:00
34 changed files with 3071 additions and 209 deletions

8
.gitignore vendored
View File

@@ -1,18 +1,16 @@
# Игнорируем данные БД (но не init-скрипты) # Игнорируем данные БД (но не init-скрипты)
db/data/ db/data/
postgres_data/
# Игнорируем секреты # Игнорируем секреты
.env .env
GEMINI.md !GEMINI.md
AGENTS.md !AGENTS.md
# Игнорируем системные папки IDE (если редактируете с ПК) # Игнорируем системные папки IDE (если редактируете с ПК)
.idea/ .idea/
.vscode/ .vscode/
*.DS_Store *.DS_Store
.agent .agent/
# Игнорируем временные файлы сборки (на будущее) # Игнорируем временные файлы сборки (на будущее)
backend/target/ backend/target/
backend/build/ backend/build/

View File

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

View File

@@ -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<Equipment> getAllEquipments() {
return equipmentRepository.findAll();
}
@PostMapping
public ResponseEntity<?> createEquipment(@RequestBody Map<String, String> 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", "Оборудование удалено"));
}
}

View File

@@ -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<Subject> getAllSubjects() {
return subjectRepository.findAll();
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> 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", "Дисциплина удалена"));
}
}

View File

@@ -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<TeacherSubjectResponse> 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<String, Long> 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<String, Long> 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", "Привязка удалена"));
}
}

View File

@@ -0,0 +1,67 @@
package com.magistr.app.controller;
import com.magistr.app.dto.CreateLessonRequest;
import com.magistr.app.dto.LessonResponse;
import com.magistr.app.model.Lesson;
import com.magistr.app.repository.LessonRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users/test")
public class TestController {
private final LessonRepository lessonRepository;
public TestController(LessonRepository lessonRepository) {
this.lessonRepository = lessonRepository;
}
@PostMapping("/create")
public ResponseEntity<?> createLesson(@RequestBody CreateLessonRequest request) {
if (request.getTeacherId() == null || request.getTeacherId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID преподавателя обязателен"));
}
if (request.getGroupId() == null || request.getGroupId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID группы обязателен"));
}
if (request.getLessonTypeId() == null || request.getLessonTypeId() == 0) {
return ResponseEntity.badRequest().body(Map.of("message", "ID предмета обязателен"));
}
if (request.getDay() == null || request.getDay().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Выбор дня обязателен"));
}
if (request.getWeek() == null || request.getWeek().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Выбор недели обязателен"));
}
if (request.getTime() == null || request.getTime().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Время обязательно"));
}
Lesson lesson = new Lesson();
lesson.setTeacherId(request.getTeacherId());
lesson.setLessonTypeId(request.getLessonTypeId());
lesson.setGroupId(request.getGroupId());
lesson.setDay(request.getDay());
lesson.setWeek(request.getWeek());
lesson.setTime(request.getTime());
lessonRepository.save(lesson);
return ResponseEntity.ok(new LessonResponse(lesson.getId(), lesson.getDay(), lesson.getWeek(), lesson.getTime()));
}
@GetMapping
public List<LessonResponse> getAllLessons() {
return lessonRepository.findAll().stream()
.map(l -> new LessonResponse(l.getId(), l.getTeacherId(), l.getLessonTypeId(), l.getDay(), l.getWeek(), l.getTime()))
.toList();
}
@GetMapping("/ping")
public String ping() {
return "pong";
}
}

View File

@@ -31,6 +31,13 @@ public class UserController {
.toList(); .toList();
} }
@GetMapping("/teachers")
public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
.toList();
}
@PostMapping @PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) { if (request.getUsername() == null || request.getUsername().isBlank()) {

View File

@@ -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<Long> 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<Long> getEquipmentIds() {
return equipmentIds;
}
public void setEquipmentIds(List<Long> equipmentIds) {
this.equipmentIds = equipmentIds;
}
}

View File

@@ -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<Equipment> equipments;
public ClassroomResponse() {
}
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> 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<Equipment> getEquipments() {
return equipments;
}
public void setEquipments(List<Equipment> equipments) {
this.equipments = equipments;
}
}

View File

@@ -0,0 +1,62 @@
package com.magistr.app.dto;
public class CreateLessonRequest {
private Long teacherId;
private Long groupId;
private Long lessonTypeId;
private String day;
private String week;
private String time;
public CreateLessonRequest() {
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId= lessonTypeId;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

@@ -0,0 +1,87 @@
package com.magistr.app.dto;
public class LessonResponse {
private Long id;
private Long teacherId;
private Long groupId;
private Long lessonTypeId;
private String day;
private String week;
private String time;
public LessonResponse() {
}
public LessonResponse(Long lessonTypeId, String day, String week, String time) {
this.lessonTypeId = lessonTypeId;
this.day = day;
this.week = week;
this.time = time;
}
public LessonResponse(Long id, Long teacherId, Long lessonTypeId, String day, String week, String time) {
this.id = id;
this.teacherId = teacherId;
this.lessonTypeId = lessonTypeId;
this.day = day;
this.week = week;
this.time = time;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

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

View File

@@ -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<Equipment> 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<Equipment> getEquipments() {
return equipments;
}
public void setEquipments(Set<Equipment> equipments) {
this.equipments = equipments;
}
}

View File

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

View File

@@ -0,0 +1,89 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "lessons")
public class Lesson {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "teacher_id", nullable = false)
private Long teacherId;
@Column(name = "group_id", nullable = false)
private Long groupId;
@Column(name = "lesson_type_id", nullable = false)
private Long lessonTypeId;
@Column(name = "day", nullable = false, length = 255)
private String day;
@Column(name = "week", nullable = false, length = 255)
private String week;
@Column(name = "time", nullable = false, length = 255)
private String time;
public Lesson() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTeacherId() {
return teacherId;
}
public void setTeacherId(Long teacherId) {
this.teacherId = teacherId;
}
public Long getGroupId() {
return groupId;
}
public void setGroupId(Long groupId) {
this.groupId = groupId;
}
public Long getLessonTypeId() {
return lessonTypeId;
}
public void setLessonTypeId(Long lessonTypeId) {
this.lessonTypeId = lessonTypeId;
}
public String getDay() {
return day;
}
public void setDay(String day) {
this.day = day;
}
public String getWeek() {
return week;
}
public void setWeek(String week) {
this.week = week;
}
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Classroom, Long> {
Optional<Classroom> findByName(String name);
}

View File

@@ -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<Equipment, Long> {
Optional<Equipment> findByName(String name);
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.repository;
import com.magistr.app.model.Lesson;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface LessonRepository extends JpaRepository<Lesson, Long> {
Optional<Lesson> findByLessonTypeId(Long lessonTypeId);
}

View File

@@ -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<Subject, Long> {
Optional<Subject> findByName(String name);
}

View File

@@ -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<TeacherSubject, TeacherSubjectId> {
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId);
}

View File

@@ -1,11 +1,15 @@
package com.magistr.app.repository; package com.magistr.app.repository;
import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
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 UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
List<User> findByRole(Role role);
} }

View File

@@ -27,6 +27,8 @@ services:
image: postgres:alpine3.23 image: postgres:alpine3.23
container_name: db container_name: db
restart: always restart: always
ports:
- "5432:5432"
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

View File

@@ -1,10 +1,18 @@
-- ==========================================
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ==========================================
-- Пользователи и роли
-- ==========================================
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL, username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT' role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto) -- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
@@ -12,16 +20,196 @@ INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN') VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
ON CONFLICT (username) DO NOTHING; ON CONFLICT (username) DO NOTHING;
-- ==========================================
-- Образовательные формы
-- ==========================================
CREATE TABLE IF NOT EXISTS education_forms ( CREATE TABLE IF NOT EXISTS education_forms (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет') INSERT INTO education_forms (name) VALUES
('Бакалавриат'),
('Магистратура'),
('Специалитет')
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
-- ==========================================
-- Учебные группы
-- ==========================================
CREATE TABLE IF NOT EXISTS student_groups ( CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(100) UNIQUE NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id) education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
course INT CHECK (course BETWEEN 1 AND 6),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, education_form_id, course)
VALUES ('ИВТ-21-1', 1, 3)
ON CONFLICT (name) 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,
student_capacity INT,
UNIQUE(group_id, name)
);
-- ==========================================
-- Справочники
-- ==========================================
-- Дисциплины
CREATE TABLE IF NOT EXISTS subjects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) UNIQUE NOT NULL,
code VARCHAR(20),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO subjects (name) VALUES
('Высшая математика'),
('Философия'),
('Информатика'),
('Базы данных'),
('Английский язык')
ON CONFLICT (name) DO NOTHING;
-- Типы занятий
CREATE TABLE IF NOT EXISTS lesson_types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
color_code VARCHAR(7) DEFAULT '#3788d8', -- для цветовой индикации в календаре
duration_minutes INT DEFAULT 90
);
INSERT INTO lesson_types (name, color_code) VALUES
('Лекция', '#FF6B6B'),
('Практика', '#4ECDC4'),
('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
inventory_number VARCHAR(50)
);
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 CHECK (capacity > 0),
building VARCHAR(50),
floor INT,
is_available BOOLEAN DEFAULT TRUE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO classrooms (name, capacity, building, floor) VALUES
('101 Ленинская', 120, 'Главный корпус', 1),
('202 IT Lab', 20, 'Корпус IT', 2),
('303 Обычная', 30, 'Главный корпус', 3)
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,
quantity INT DEFAULT 1 CHECK (quantity > 0),
notes TEXT,
PRIMARY KEY (classroom_id, equipment_id)
);
-- Заполнение привязок оборудования с использованием подзапросов
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
WHEN e.name = 'ПК' THEN 1
ELSE 1
END
FROM classrooms c, equipments e
WHERE
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) 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,
qualification_level VARCHAR(50),
experience_years INT,
PRIMARY KEY(user_id, subject_id)
);
-- Какие типы занятий может вести преподаватель по дисциплине
CREATE TABLE IF NOT EXISTS teacher_lesson_types (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, subject_id, lesson_type_id)
);
-- ==========================================
-- Основная таблица Расписания (Lessons)
-- ==========================================
CREATE TABLE IF NOT EXISTS lessons (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
group_id BIGINT NOT NULL REFERENCES student_groups(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
day VARCHAR(255) NOT NULL,
week VARCHAR(255) NOT NULL,
time VARCHAR(255) NOT NULL
);
-- ==========================================
-- Функция обновления timestamp
-- ==========================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Триггеры для обновления updated_at
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ==========================================
-- Комментарии к таблицам и полям (для документации)
-- ==========================================
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
COMMENT ON TABLE lessons IS 'Основное расписание занятий';

View File

@@ -8,45 +8,56 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--bg-sidebar: rgba(255, 255, 255, 0.03); --bg-primary: #0a0a0f;
--bg-card: rgba(255, 255, 255, 0.05); --bg-sidebar: rgba(255, 255, 255, 0.02);
--bg-card-border: rgba(255, 255, 255, 0.08); --bg-card: rgba(255, 255, 255, 0.03);
--bg-input: rgba(255, 255, 255, 0.06); --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input-focus: rgba(255, 255, 255, 0.1); --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); --bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af; /* Typography */
--text-placeholder: #6b7280; --text-primary: #f8fafc;
--accent: #6366f1; --text-secondary: #94a3b8;
--accent-hover: #818cf8; --text-placeholder: #475569;
--accent-glow: rgba(99, 102, 241, 0.35);
--error: #f87171; /* Vibrant Accents */
--success: #34d399; --accent: #8b5cf6;
--warning: #fbbf24; --accent-hover: #a78bfa;
--radius-sm: 8px; --accent-glow: rgba(139, 92, 246, 0.4);
--radius-md: 12px; --accent-secondary: #ec4899;
--transition: 0.2s ease;
/* 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 ===== */ /* ===== Light Theme ===== */
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-sidebar: rgba(255, 255, 255, 0.88); --bg-sidebar: rgba(255, 255, 255, 0.7);
--bg-card: rgba(255, 255, 255, 0.95); --bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.22); --bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.08); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.12); --bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.08); --bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--text-placeholder: #6b7280; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25); --accent-glow: rgba(99, 102, 241, 0.3);
--error: #dc2626; --accent-secondary: #d946ef;
--success: #16a34a; --error: #ef4444;
--warning: #d97706; --success: #10b981;
--warning: #f59e0b;
} }
[data-theme="light"] .form-group select option, [data-theme="light"] .form-group select option,
@@ -59,6 +70,10 @@
background: rgba(99, 102, 241, 0.18); 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 input,
[data-theme="light"] .form-group select, [data-theme="light"] .form-group select,
[data-theme="light"] .filter-row select { [data-theme="light"] .filter-row select {
@@ -85,9 +100,11 @@ body {
/* ===== Sidebar ===== */ /* ===== Sidebar ===== */
.sidebar { .sidebar {
width: 240px; width: 260px;
min-height: 100vh; min-height: 100vh;
background: var(--bg-sidebar); background: var(--bg-sidebar);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--bg-card-border); border-right: 1px solid var(--bg-card-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -96,7 +113,7 @@ body {
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 10; 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 { .sidebar-header {
@@ -121,19 +138,124 @@ body {
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.75rem;
padding: 0.65rem 0.8rem; padding: 0.75rem 1rem;
margin-bottom: 0.25rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 500; 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 { .nav-item:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); 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 { .nav-item.active {
@@ -170,7 +292,7 @@ body {
/* ===== Main ===== */ /* ===== Main ===== */
.main { .main {
flex: 1; flex: 1;
margin-left: 240px; margin-left: 260px;
min-height: 100vh; min-height: 100vh;
} }
@@ -199,12 +321,70 @@ body {
} }
/* ===== Cards ===== */ /* ===== Cards ===== */
@keyframes slideUpCard {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card { .card {
background: var(--bg-card); background: var(--bg-card);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 1.5rem; padding: 1.75rem;
transition: background 0.4s ease, border-color 0.4s ease; 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 { .card h2 {
@@ -242,26 +422,43 @@ body {
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 0.65rem 0.8rem; padding: 0.75rem 1rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid transparent; border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-family: inherit; font-family: inherit;
font-size: 0.9rem; font-size: 0.95rem;
outline: none; outline: none;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition); transition: all var(--transition);
} }
.form-group input::placeholder { .form-group input::placeholder {
color: var(--text-placeholder); color: var(--text-placeholder);
transition: opacity var(--transition);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
background: var(--bg-input-focus); background: 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 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 { .form-group select {
@@ -279,23 +476,60 @@ body {
} }
.btn-create { .btn-create {
padding: 0.65rem 1.5rem; position: relative;
background: linear-gradient(135deg, var(--accent), #8b5cf6); overflow: hidden;
padding: 0.75rem 1.75rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: #fff; color: #fff;
font-family: inherit; font-family: inherit;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: transform var(--transition), box-shadow var(--transition); transition: all var(--transition);
box-shadow: 0 2px 10px var(--accent-glow); 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 { .btn-create:hover {
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 4px 16px var(--accent-glow); 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 { .form-alert {
@@ -311,6 +545,7 @@ body {
background: rgba(248, 113, 113, 0.1); background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2); border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error); color: var(--error);
animation: slideDownAlert 0.3s ease-out both;
} }
.form-alert.success { .form-alert.success {
@@ -318,11 +553,12 @@ body {
background: rgba(52, 211, 153, 0.1); background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success); color: var(--success);
animation: slideDownAlert 0.3s ease-out both;
} }
/* ===== Table ===== */ /* ===== Table ===== */
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: visible;
} }
table { table {
@@ -342,13 +578,63 @@ thead th {
} }
tbody td { tbody td {
padding: 0.7rem 0.8rem; padding: 0.85rem 1rem;
font-size: 0.9rem; font-size: 0.95rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03); 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 { tbody tr {
transition: background var(--transition); 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 { tbody tr:hover {
@@ -477,11 +763,29 @@ tbody tr:hover {
font-family: inherit; font-family: inherit;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: background var(--transition), transform var(--transition);
} }
.btn-delete:hover { .btn-delete:hover {
background: rgba(248, 113, 113, 0.2); 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 ===== */ /* ===== Mobile Menu Toggle ===== */
@@ -512,23 +816,27 @@ tbody tr:hover {
/* ===== Theme Toggle Button ===== */ /* ===== Theme Toggle Button ===== */
.theme-toggle { .theme-toggle {
width: 38px; width: 40px;
height: 38px; height: 40px;
border: none;
border-radius: 50%; border-radius: 50%;
background: var(--bg-input); background: var(--bg-card);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; flex-shrink: 0;
} }
.theme-toggle svg { .theme-toggle svg {
width: 18px; width: 20px;
height: 18px; height: 20px;
transition: transform 0.4s ease; transition: transform 0.4s ease;
} }
@@ -541,11 +849,142 @@ tbody tr:hover {
transform: scale(0.95); 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; position: fixed;
top: 1.25rem; inset: 0;
right: 1.25rem; background: rgba(0, 0, 0, 0.5);
z-index: 100; 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 ===== */ /* ===== Responsive ===== */

View File

@@ -16,6 +16,30 @@
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay'); 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 // Users
const usersTbody = document.getElementById('users-tbody'); const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form'); const createForm = document.getElementById('create-form');
@@ -33,18 +57,100 @@
const createEfForm = document.getElementById('create-ef-form'); const createEfForm = document.getElementById('create-ef-form');
const createEfAlert = document.getElementById('create-ef-alert'); 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 navItems = document.querySelectorAll('.nav-item[data-tab]');
const tabContents = document.querySelectorAll('.tab-content'); const tabContents = document.querySelectorAll('.tab-content');
// ---- State ---- // ---- State ----
let allGroups = []; let allGroups = [];
let allEducationForms = []; let allEducationForms = [];
let allEquipments = [];
let allSubjects = [];
let allTeachers = [];
// ---- Tab Switching ---- // ---- Tab Switching ----
const TAB_TITLES = { const TAB_TITLES = {
users: 'Управление пользователями', users: 'Управление пользователями',
groups: 'Управление группами', groups: 'Управление группами',
'edu-forms': 'Формы обучения', 'edu-forms': 'Формы обучения',
equipments: 'Оборудование',
classrooms: 'Аудитории',
subjects: 'Дисциплины и преподаватели'
}; };
navItems.forEach(item => { navItems.forEach(item => {
@@ -67,6 +173,9 @@
if (tab === 'users') loadUsers(); if (tab === 'users') loadUsers();
if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); } if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); }
if (tab === 'edu-forms') loadEducationForms(); 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'); sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open'); sidebarOverlay.classList.remove('open');
@@ -341,6 +450,453 @@
} catch (e) { alert('Ошибка соединения'); } } 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 = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
if (equipmentCheckboxes) equipmentCheckboxes.innerHTML = '<p class="text-error">Ошибка загрузки</p>';
}
}
function renderEquipments(equipments) {
if (!equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
function renderEquipmentCheckboxes(equipments) {
if (!equipments.length) {
equipmentCheckboxes.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
equipmentCheckboxes.innerHTML = equipments.map(eq => `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}"> ${escapeHtml(eq.name)}
</label>
`).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 = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms.length) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
return;
}
classroomsTbody.innerHTML = classrooms.map(c => {
const equipHtml = c.equipments && c.equipments.length
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
: '—';
return `
<tr>
<td>${c.id}</td>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td>${c.capacity} чел.</td>
<td><small>${equipHtml}</small></td>
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.isAvailable ? 'Доступна' : 'Не доступна'}
</span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</td>
<td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}" style="padding: 0.35rem 0.7rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); border-radius: var(--radius-sm); color: var(--accent-hover); cursor: pointer; margin-right: 0.5rem;">Изменить</button>
<button class="btn-delete" data-id="${c.id}">Удалить</button>
</td>
</tr>`;
}).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 `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
</label>
`;
}).join('');
} else {
editEquipmentCheckboxes.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
}
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 = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет дисциплин</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.name)}</td>
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
</tr>`).join('');
}
function populateSubjectSelect(subjects) {
if (!assignSubjectSelect) return;
const currentVal = assignSubjectSelect.value;
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (currentVal && subjects.find(s => s.id == currentVal)) {
assignSubjectSelect.value = currentVal;
}
}
async function loadTeachers() {
try {
const res = await fetch('/api/users/teachers', {
headers: { 'Authorization': 'Bearer ' + token },
});
allTeachers = await res.json();
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const res = await fetch('/api/teacher-subjects', {
headers: { 'Authorization': 'Bearer ' + token },
});
const tsData = await res.json();
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault();
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 // LOGOUT & INIT
// ============================================================ // ============================================================

View File

@@ -59,6 +59,29 @@
</svg> </svg>
Формы обучения Формы обучения
</a> </a>
<a href="#" class="nav-item" data-tab="equipments">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
Оборудование
</a>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
Аудитории
</a>
<a href="#" class="nav-item" data-tab="subjects">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Дисциплины
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="btn-logout" id="btn-logout"> <button class="btn-logout" id="btn-logout">
@@ -225,6 +248,230 @@
</div> </div>
</div> </div>
</section> </section>
<!-- ===== Equipments Tab ===== -->
<section class="content tab-content" id="tab-equipments" style="display:none;">
<div class="card create-card">
<h2>Новое оборудование</h2>
<form id="create-equipment-form">
<div class="form-row">
<div class="form-group">
<label for="new-equipment-name">Название оборудования</label>
<input type="text" id="new-equipment-name" placeholder="Проектор" required>
</div>
<button type="submit" class="btn-create">Добавить</button>
</div>
<div class="form-alert" id="create-equipment-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Справочник оборудования</h2>
<div class="table-wrap">
<table id="equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="equipments-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Classrooms Tab ===== -->
<section class="content tab-content" id="tab-classrooms" style="display:none;">
<div class="card create-card">
<h2>Новая аудитория</h2>
<form id="create-classroom-form">
<div class="form-row">
<div class="form-group">
<label for="new-classroom-name">Номер / Название</label>
<input type="text" id="new-classroom-name" placeholder="101 Ленинская" required>
</div>
<div class="form-group">
<label for="new-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="new-classroom-capacity" placeholder="30" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="equipment-select-box">
<span class="select-text" id="equipment-select-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="equipment-dropdown-menu">
<div id="equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
<p class="text-muted"><small>Загрузка...</small></p>
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-create">Добавить</button>
</div>
</div>
<div class="form-alert" id="create-classroom-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Список аудиторий</h2>
<div class="table-wrap">
<table id="classrooms-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Вместимость</th>
<th>Оборудование</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="classrooms-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Subjects Tab ===== -->
<section class="content tab-content" id="tab-subjects" style="display:none;">
<div class="card create-card">
<h2>Новая дисциплина</h2>
<form id="create-subject-form">
<div class="form-row">
<div class="form-group">
<label for="new-subject-name">Название дисциплины</label>
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
</div>
<button type="submit" class="btn-create">Добавить</button>
</div>
<div class="form-alert" id="create-subject-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<h2>Привязка преподавателя к дисциплине</h2>
<form id="assign-teacher-form">
<div class="form-row">
<div class="form-group">
<label for="assign-teacher-select">Преподаватель</label>
<select id="assign-teacher-select">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="assign-subject-select">Дисциплина</label>
<select id="assign-subject-select">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-create">Привязать</button>
</div>
<div class="form-alert" id="assign-teacher-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все дисциплины</h2>
<div class="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Привязки преподавателей</h2>
<div class="table-wrap">
<table id="teacher-subjects-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Дисциплина</th>
<th></th>
</tr>
</thead>
<tbody id="teacher-subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Edit Classroom Modal ===== -->
<div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content">
<h2>Редактировать аудиторию</h2>
<button class="modal-close" id="modal-edit-classroom-close">&times;</button>
<form id="edit-classroom-form">
<input type="hidden" id="edit-classroom-id">
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group">
<label for="edit-classroom-name">Номер / Название</label>
<input type="text" id="edit-classroom-name" required>
</div>
<div class="form-group">
<label for="edit-classroom-capacity">Вместимость (чел.)</label>
<input type="number" id="edit-classroom-capacity" min="1" required>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group" style="flex: 2;">
<label>Оборудование</label>
<div class="custom-multi-select">
<div class="select-box" id="edit-equipment-select-box">
<span class="select-text" id="edit-equipment-select-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="edit-equipment-dropdown-menu">
<div id="edit-equipment-checkboxes" class="checkbox-group-vertical">
<!-- Подгружается через JS -->
</div>
</div>
</div>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-create" style="width: 100%;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
</form>
</div>
</div>
</main> </main>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>

View File

@@ -37,7 +37,7 @@
</div> </div>
<form id="login-form" novalidate> <form id="login-form" novalidate>
<div class="form-group"> <div class="form-group stagger-1">
<label for="username">Имя пользователя</label> <label for="username">Имя пользователя</label>
<div class="input-wrapper"> <div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -49,7 +49,7 @@
<span class="error-message" id="username-error"></span> <span class="error-message" id="username-error"></span>
</div> </div>
<div class="form-group"> <div class="form-group stagger-2">
<label for="password">Пароль</label> <label for="password">Пароль</label>
<div class="input-wrapper"> <div class="input-wrapper">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -67,9 +67,9 @@
<span class="error-message" id="password-error"></span> <span class="error-message" id="password-error"></span>
</div> </div>
<div class="form-alert" id="form-alert" role="alert"></div> <div class="form-alert stagger-3" id="form-alert" role="alert"></div>
<button type="submit" class="btn-submit" id="btn-submit"> <button type="submit" class="btn-submit stagger-4" id="btn-submit">
<span class="btn-text">Войти</span> <span class="btn-text">Войти</span>
<span class="btn-loader" hidden> <span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24"> <svg class="spinner" width="20" height="20" viewBox="0 0 24 24">

View File

@@ -12,6 +12,24 @@
const btnLoader = btnSubmit.querySelector('.btn-loader'); const btnLoader = btnSubmit.querySelector('.btn-loader');
const togglePassword = document.getElementById('toggle-password'); const togglePassword = document.getElementById('toggle-password');
// Ripple effect
btnSubmit.addEventListener('click', function(e) {
const rect = this.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`;
this.appendChild(ripple);
setTimeout(() => {
ripple.remove();
}, 600);
});
togglePassword.addEventListener('click', () => { togglePassword.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password'; const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password'; passwordInput.type = isPassword ? 'text' : 'password';
@@ -27,8 +45,14 @@
} }
function setFieldError(input, errorEl, message) { function setFieldError(input, errorEl, message) {
input.closest('.form-group').classList.add('has-error'); const group = input.closest('.form-group');
group.classList.add('has-error');
errorEl.textContent = message; errorEl.textContent = message;
// Shake animation
group.classList.remove('shake');
void group.offsetWidth; // trigger reflow
group.classList.add('shake');
} }
function showAlert(message, type) { function showAlert(message, type) {

View File

@@ -1,11 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель студента</title> <title>Панель студента</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -14,59 +13,168 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--text-primary: #f0f0f5; --bg-primary: #0a0a0f;
--text-secondary: #9ca3af; --bg-card: rgba(255, 255, 255, 0.03);
--accent-hover: #818cf8; --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-card: rgba(255, 255, 255, 0.05); /* Typography */
--bg-card-border: rgba(255, 255, 255, 0.08); --text-primary: #f8fafc;
--bg-input: rgba(255, 255, 255, 0.06); --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); /* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
} }
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.08);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95); --accent-glow: rgba(99, 102, 241, 0.3);
--bg-card-border: rgba(0, 0, 0, 0.22); --accent-secondary: #d946ef;
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
transition: background 0.4s ease, color 0.4s ease; transition: background 0.4s ease, color 0.4s ease;
} }
.placeholder { /* ===== Animated Background ===== */
.background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.shape {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.4;
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.5s ease;
will-change: transform;
}
[data-theme="light"] .shape { opacity: 0.15; }
.shape-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--accent), transparent 60%);
top: -20%;
left: -10%;
animation-delay: 0s;
}
.shape-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -20%;
right: -10%;
animation-delay: -5s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 30px) scale(0.95); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.placeholder-card {
position: relative;
z-index: 1;
background: var(--bg-card);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border);
border-radius: 24px;
padding: 4rem 3rem;
text-align: center; text-align: center;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
max-width: 400px;
width: 90%;
} }
.placeholder h1 { [data-theme="light"] .placeholder-card {
font-size: 1.5rem; box-shadow:
margin-bottom: 0.5rem; 0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
} }
.placeholder p { .placeholder-card::before {
color: var(--text-secondary); content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
}
.placeholder-card .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
color: var(--text-primary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
} }
.placeholder a { .placeholder-card h1 {
color: var(--accent-hover); font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.placeholder-card p {
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.btn-logout {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 2rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
text-decoration: none; text-decoration: none;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
} }
.placeholder a:hover { .btn-logout:hover {
text-decoration: underline; transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
} }
/* Theme Toggle */ /* Theme Toggle */
@@ -111,10 +219,21 @@
</head> </head>
<body> <body>
<div class="placeholder"> <div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<div class="placeholder-card">
<div class="icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
</div>
<h1>Панель студента</h1> <h1>Панель студента</h1>
<p>Раздел в разработке</p> <p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a> <a href="/" class="btn-logout" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div> </div>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>

View File

@@ -8,50 +8,64 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--bg-card: rgba(255, 255, 255, 0.05); --bg-primary: #0a0a0f;
--bg-card-border: rgba(255, 255, 255, 0.08); --bg-card: rgba(255, 255, 255, 0.03);
--bg-input: rgba(255, 255, 255, 0.06); --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input-focus: rgba(255, 255, 255, 0.1); --bg-input: rgba(255, 255, 255, 0.04);
--text-primary: #f0f0f5; --bg-input-focus: rgba(255, 255, 255, 0.08);
--text-secondary: #9ca3af;
--text-placeholder: #6b7280; /* Typography */
--accent: #6366f1; --text-primary: #f8fafc;
--accent-hover: #818cf8; --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); --text-placeholder: #475569;
--error: #f87171;
--success: #34d399; /* Vibrant Accents */
--radius-sm: 8px; --accent: #8b5cf6;
--radius-md: 14px; --accent-hover: #a78bfa;
--radius-lg: 20px; --accent-glow: rgba(139, 92, 246, 0.4);
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1); --accent-secondary: #ec4899;
/* Status Colors */
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
/* Spatial */
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
} }
/* ===== Light Theme ===== */ /* ===== Light Theme ===== */
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.95); --bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.22); --bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.08); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.12); --bg-input-focus: rgba(0, 0, 0, 0.06);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--text-placeholder: #6b7280; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25); --accent-glow: rgba(99, 102, 241, 0.3);
--error: #dc2626; --accent-secondary: #d946ef;
--success: #16a34a; --error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
} }
[data-theme="light"] .shape { [data-theme="light"] .shape {
opacity: 0.25; opacity: 0.15;
} }
[data-theme="light"] .login-card { [data-theme="light"] .login-card {
box-shadow: box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08), 0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.6); inset 0 1px 0 rgba(255, 255, 255, 0.8),
inset 0 0 20px rgba(255, 255, 255, 0.5);
} }
html { html {
@@ -83,38 +97,39 @@ body {
.shape { .shape {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(80px); filter: blur(90px);
opacity: 0.5; opacity: 0.4;
animation: float 20s ease-in-out infinite; animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.4s ease; transition: opacity 0.5s ease;
will-change: transform;
} }
.shape-1 { .shape-1 {
width: 500px; width: 600px;
height: 500px; height: 600px;
background: radial-gradient(circle, #6366f1, transparent 70%); background: radial-gradient(circle, var(--accent), transparent 60%);
top: -15%; top: -20%;
left: -10%; left: -10%;
animation-delay: 0s; animation-delay: 0s;
} }
.shape-2 { .shape-2 {
width: 400px; width: 500px;
height: 400px; height: 500px;
background: radial-gradient(circle, #8b5cf6, transparent 70%); background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -10%; bottom: -20%;
right: -10%; right: -10%;
animation-delay: -7s; animation-delay: -5s;
} }
.shape-3 { .shape-3 {
width: 300px; width: 400px;
height: 300px; height: 400px;
background: radial-gradient(circle, #a78bfa, transparent 70%); background: radial-gradient(circle, #3b82f6, transparent 60%);
top: 50%; top: 40%;
left: 50%; left: 60%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
animation-delay: -14s; animation-delay: -10s;
} }
@keyframes float { @keyframes float {
@@ -162,15 +177,29 @@ body {
/* ===== Login Card (Glassmorphism) ===== */ /* ===== Login Card (Glassmorphism) ===== */
.login-card { .login-card {
background: var(--bg-card); background: var(--bg-card);
backdrop-filter: blur(24px); backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 2.5rem 2rem 2rem; padding: 3rem 2.5rem 2.5rem;
box-shadow: box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.05); inset 0 1px 0 rgba(255, 255, 255, 0.1),
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease; inset 0 0 32px rgba(255, 255, 255, 0.02);
transition: all 0.4s ease;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
z-index: 1;
} }
/* ===== Header ===== */ /* ===== Header ===== */
@@ -241,9 +270,9 @@ body {
.input-wrapper input { .input-wrapper input {
width: 100%; width: 100%;
padding: 0.8rem 0.8rem 0.8rem 2.75rem; padding: 0.9rem 1rem 0.9rem 2.8rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid transparent; border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-family: inherit; font-family: inherit;
@@ -252,17 +281,24 @@ body {
transition: transition:
background var(--transition), background var(--transition),
border-color var(--transition), border-color var(--transition),
box-shadow var(--transition); box-shadow var(--transition),
transform var(--transition);
} }
.input-wrapper input::placeholder { .input-wrapper input::placeholder {
color: var(--text-placeholder); color: var(--text-placeholder);
transition: opacity var(--transition);
} }
.input-wrapper input:focus { .input-wrapper 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 3px var(--accent-glow); box-shadow: 0 0 0 4px var(--accent-glow);
transform: translateY(-1px);
}
.input-wrapper input:focus::placeholder {
opacity: 0.5;
} }
.input-wrapper input:focus~.input-icon, .input-wrapper input:focus~.input-icon,
@@ -303,6 +339,28 @@ body {
transition: opacity var(--transition); transition: opacity var(--transition);
} }
/* ===== Animations ===== */
.stagger-1 { animation: fadeInUp 0.5s ease-out 0.1s both; }
.stagger-2 { animation: fadeInUp 0.5s ease-out 0.2s both; }
.stagger-3 { animation: fadeInUp 0.5s ease-out 0.3s both; }
.stagger-4 { animation: fadeInUp 0.5s ease-out 0.4s both; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
50% { transform: translateX(6px); }
75% { transform: translateX(-6px); }
}
.shake {
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes slideDownAlert {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Form Alert ===== */ /* ===== Form Alert ===== */
.form-alert { .form-alert {
display: none; display: none;
@@ -318,6 +376,7 @@ body {
background: rgba(248, 113, 113, 0.1); background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2); border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error); color: var(--error);
animation: slideDownAlert 0.3s ease-out both;
} }
.form-alert.success { .form-alert.success {
@@ -325,19 +384,23 @@ body {
background: rgba(52, 211, 153, 0.1); background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2); border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success); color: var(--success);
animation: slideDownAlert 0.3s ease-out both;
} }
/* ===== Submit Button ===== */ /* ===== Submit Button ===== */
.btn-submit { .btn-submit {
position: relative;
overflow: hidden;
width: 100%; width: 100%;
padding: 0.85rem; padding: 0.95rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #8b5cf6); background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff; color: #fff;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -347,22 +410,55 @@ body {
transform var(--transition), transform var(--transition),
box-shadow var(--transition), box-shadow var(--transition),
opacity var(--transition); opacity var(--transition);
box-shadow: 0 4px 16px var(--accent-glow); box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-submit::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-submit:hover { .btn-submit:hover {
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 6px 24px var(--accent-glow); box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-submit:hover::before {
opacity: 1;
} }
.btn-submit:active { .btn-submit:active {
transform: translateY(0); transform: translateY(1px);
box-shadow: 0 2px 10px var(--accent-glow);
} }
.btn-submit:disabled { .btn-submit:disabled {
opacity: 0.7; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none;
}
/* ===== Ripple Effect ===== */
.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
} }
/* ===== Spinner ===== */ /* ===== Spinner ===== */

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель преподавателя</title> <title>Панель преподавателя</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -14,59 +14,186 @@
} }
:root { :root {
--bg-primary: #0f0f1a; /* Deep dark premium background */
--text-primary: #f0f0f5; --bg-primary: #0a0a0f;
--text-secondary: #9ca3af; --bg-card: rgba(255, 255, 255, 0.03);
--accent-hover: #818cf8; --bg-card-border: rgba(255, 255, 255, 0.05);
--bg-card: rgba(255, 255, 255, 0.05); /* Typography */
--bg-card-border: rgba(255, 255, 255, 0.08); --text-primary: #f8fafc;
--bg-input: rgba(255, 255, 255, 0.06); --text-secondary: #94a3b8;
--accent-glow: rgba(99, 102, 241, 0.35); /* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
} }
[data-theme="light"] { [data-theme="light"] {
--bg-primary: #e8eaef; --bg-primary: #f8fafc;
--bg-card: rgba(255, 255, 255, 0.7);
--bg-card-border: rgba(0, 0, 0, 0.08);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #374151; --text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5; --accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95); --accent-glow: rgba(99, 102, 241, 0.3);
--bg-card-border: rgba(0, 0, 0, 0.22); --accent-secondary: #d946ef;
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
transition: background 0.4s ease, color 0.4s ease; transition: background 0.4s ease, color 0.4s ease;
} }
.placeholder { /* ===== Animated Background ===== */
.background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
}
.shape {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.4;
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
transition: opacity 0.5s ease;
will-change: transform;
}
[data-theme="light"] .shape {
opacity: 0.15;
}
.shape-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--accent), transparent 60%);
top: -20%;
left: -10%;
animation-delay: 0s;
}
.shape-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
bottom: -20%;
right: -10%;
animation-delay: -5s;
}
@keyframes float {
0%,
100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-30px, 30px) scale(0.95);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.placeholder-card {
position: relative;
z-index: 1;
background: var(--bg-card);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid var(--bg-card-border);
border-radius: 24px;
padding: 4rem 3rem;
text-align: center; text-align: center;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
max-width: 400px;
width: 90%;
} }
.placeholder h1 { [data-theme="light"] .placeholder-card {
font-size: 1.5rem; box-shadow:
margin-bottom: 0.5rem; 0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
} }
.placeholder p { .placeholder-card::before {
color: var(--text-secondary); content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
.placeholder-card .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
color: var(--text-primary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: 0 0 30px var(--accent-glow);
} }
.placeholder a { .placeholder-card h1 {
color: var(--accent-hover); font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.placeholder-card p {
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.btn-logout {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 2rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
text-decoration: none; text-decoration: none;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
} }
.placeholder a:hover { .btn-logout:hover {
text-decoration: underline; transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
} }
/* Theme Toggle */ /* Theme Toggle */
@@ -111,10 +238,23 @@
</head> </head>
<body> <body>
<div class="placeholder"> <div class="background">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<div class="placeholder-card">
<div class="icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c3 3 9 3 12 0v-5" />
</svg>
</div>
<h1>Панель преподавателя</h1> <h1>Панель преподавателя</h1>
<p>Раздел в разработке</p> <p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a> <a href="/" class="btn-logout"
onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div> </div>
<script src="/theme-toggle.js"></script> <script src="/theme-toggle.js"></script>