Compare commits

..

22 Commits

Author SHA1 Message Date
Zuev
9d06c69e2b chore: update .gitignore 2026-02-26 01:29:13 +03:00
Zuev
684273de50 Fix CSS appearance warning and format JS 2026-02-26 00:39:48 +03:00
Zuev
772b110762 Refactor admin frontend into modular SPA 2026-02-26 00:27:10 +03:00
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
52 changed files with 3987 additions and 1278 deletions

9
.gitignore vendored
View File

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

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();
}
@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
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
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;
import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
List<User> findByRole(Role role);
}

View File

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

View File

@@ -1,10 +1,18 @@
-- ==========================================
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ==========================================
-- Пользователи и роли
-- ==========================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE 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)
@@ -12,16 +20,196 @@ INSERT INTO users (username, password, role)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
ON CONFLICT (username) DO NOTHING;
-- ==========================================
-- Образовательные формы
-- ==========================================
CREATE TABLE IF NOT EXISTS education_forms (
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;
-- ==========================================
-- Учебные группы
-- ==========================================
CREATE TABLE IF NOT EXISTS student_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
education_form_id BIGINT NOT NULL REFERENCES education_forms(id)
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

@@ -1,639 +0,0 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0f0f1a;
--bg-sidebar: rgba(255, 255, 255, 0.03);
--bg-card: rgba(255, 255, 255, 0.05);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--bg-input-focus: rgba(255, 255, 255, 0.1);
--bg-hover: rgba(255, 255, 255, 0.06);
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--text-placeholder: #6b7280;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99, 102, 241, 0.35);
--error: #f87171;
--success: #34d399;
--warning: #fbbf24;
--radius-sm: 8px;
--radius-md: 12px;
--transition: 0.2s ease;
}
/* ===== Light Theme ===== */
[data-theme="light"] {
--bg-primary: #e8eaef;
--bg-sidebar: rgba(255, 255, 255, 0.88);
--bg-card: rgba(255, 255, 255, 0.95);
--bg-card-border: rgba(0, 0, 0, 0.22);
--bg-input: rgba(0, 0, 0, 0.08);
--bg-input-focus: rgba(0, 0, 0, 0.12);
--bg-hover: rgba(0, 0, 0, 0.08);
--text-primary: #0f172a;
--text-secondary: #374151;
--text-placeholder: #6b7280;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.25);
--error: #dc2626;
--success: #16a34a;
--warning: #d97706;
}
[data-theme="light"] .form-group select option,
[data-theme="light"] .filter-row select option {
background: #fff;
color: #1a1a2e;
}
[data-theme="light"] .nav-item.active {
background: rgba(99, 102, 241, 0.18);
}
[data-theme="light"] .form-group input,
[data-theme="light"] .form-group select,
[data-theme="light"] .filter-row select {
border-color: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] tbody td {
border-bottom-color: rgba(0, 0, 0, 0.08);
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
transition: background 0.4s ease, color 0.4s ease;
}
/* ===== Sidebar ===== */
.sidebar {
width: 240px;
min-height: 100vh;
background: var(--bg-sidebar);
border-right: 1px solid var(--bg-card-border);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 10;
transition: background 0.4s ease, border-color 0.4s ease;
}
.sidebar-header {
padding: 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: background var(--transition), color var(--transition);
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(99, 102, 241, 0.12);
color: var(--accent-hover);
}
.sidebar-footer {
padding: 0.75rem;
border-top: 1px solid var(--bg-card-border);
}
.btn-logout {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
color: var(--error);
}
/* ===== Main ===== */
.main {
flex: 1;
margin-left: 240px;
min-height: 100vh;
}
.topbar {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--bg-card-border);
transition: border-color 0.4s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.topbar h1 {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: -0.02em;
flex: 1;
}
.content {
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ===== Cards ===== */
.card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 1.5rem;
transition: background 0.4s ease, border-color 0.4s ease;
}
.card h2 {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ===== Create Form ===== */
.form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.form-row .form-group {
flex: 1;
min-width: 160px;
}
.form-group label {
display: block;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.65rem 0.8rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.form-group input::placeholder {
color: var(--text-placeholder);
}
.form-group input:focus,
.form-group select:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
}
.form-group select option {
background: #1a1a2e;
color: var(--text-primary);
}
.btn-create {
padding: 0.65rem 1.5rem;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: transform var(--transition), box-shadow var(--transition);
box-shadow: 0 2px 10px var(--accent-glow);
}
.btn-create:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px var(--accent-glow);
}
.form-alert {
display: none;
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin-top: 0.75rem;
}
.form-alert.error {
display: block;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error);
}
.form-alert.success {
display: block;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success);
}
/* ===== Table ===== */
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--bg-card-border);
}
tbody td {
padding: 0.7rem 0.8rem;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
tbody tr {
transition: background var(--transition);
}
tbody tr:hover {
background: var(--bg-hover);
}
.loading-row {
text-align: center;
color: var(--text-secondary);
padding: 2rem !important;
}
/* ===== Role Badges ===== */
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-admin {
background: rgba(248, 113, 113, 0.15);
color: var(--error);
}
.badge-teacher {
background: rgba(251, 191, 36, 0.15);
color: var(--warning);
}
.badge-student {
background: rgba(52, 211, 153, 0.15);
color: var(--success);
}
/* ===== Education Form Badge ===== */
.badge-ef {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
}
/* ===== Card Header Row ===== */
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.card-header-row h2 {
margin-bottom: 0;
}
.filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-row label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.filter-row select {
padding: 0.45rem 2rem 0.45rem 0.7rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.filter-row select:focus {
background-color: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.filter-row select option {
background: #1a1a2e;
color: var(--text-primary);
}
/* ===== Tab Content ===== */
.tab-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Delete Button ===== */
.btn-delete {
padding: 0.35rem 0.7rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
border-radius: var(--radius-sm);
color: var(--error);
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background var(--transition);
}
.btn-delete:hover {
background: rgba(248, 113, 113, 0.2);
}
/* ===== Mobile Menu Toggle ===== */
.menu-toggle {
display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.menu-toggle:hover {
background: var(--bg-hover);
}
/* ===== Sidebar Overlay ===== */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9;
backdrop-filter: blur(2px);
}
/* ===== Theme Toggle Button ===== */
.theme-toggle {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
flex-shrink: 0;
}
.theme-toggle svg {
width: 18px;
height: 18px;
transition: transform 0.4s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px var(--accent-glow);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle--fixed {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 100;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.sidebar {
width: 240px;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 20;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.open {
display: block;
}
.menu-toggle {
display: flex;
}
.main {
margin-left: 0;
}
.topbar {
padding: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.topbar h1 {
font-size: 1.1rem;
}
.content {
padding: 1rem;
}
.card {
padding: 1rem;
}
.form-row {
flex-direction: column;
align-items: stretch;
}
.form-row .form-group {
min-width: 0;
}
.btn-create {
width: 100%;
text-align: center;
}
tbody td {
padding: 0.5rem 0.6rem;
font-size: 0.82rem;
}
thead th {
padding: 0.5rem 0.6rem;
font-size: 0.72rem;
}
.table-wrap {
margin: 0 -1rem;
padding: 0 1rem;
}
}
@media (max-width: 480px) {
.topbar h1 {
font-size: 0.95rem;
}
.badge {
font-size: 0.65rem;
padding: 0.15rem 0.45rem;
}
.btn-delete {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
}
}

View File

@@ -1,355 +0,0 @@
(() => {
'use strict';
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
window.location.href = '/';
return;
}
// ---- DOM refs ----
const pageTitle = document.getElementById('page-title');
const btnLogout = document.getElementById('btn-logout');
const menuToggle = document.getElementById('menu-toggle');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
// Users
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
// Groups
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const createGroupAlert = document.getElementById('create-group-alert');
const newGroupEfSelect = document.getElementById('new-group-ef');
const filterEfSelect = document.getElementById('filter-ef');
// Education Forms
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
const createEfAlert = document.getElementById('create-ef-alert');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const tabContents = document.querySelectorAll('.tab-content');
// ---- State ----
let allGroups = [];
let allEducationForms = [];
// ---- Tab Switching ----
const TAB_TITLES = {
users: 'Управление пользователями',
groups: 'Управление группами',
'edu-forms': 'Формы обучения',
};
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
switchTab(item.dataset.tab);
});
});
function switchTab(tab) {
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
tabContents.forEach(tc => tc.style.display = 'none');
const target = document.getElementById('tab-' + tab);
if (target) target.style.display = '';
pageTitle.textContent = TAB_TITLES[tab] || '';
if (tab === 'users') loadUsers();
if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); }
if (tab === 'edu-forms') loadEducationForms();
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// ---- Mobile Menu ----
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// ---- Helpers ----
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showAlert(el, msg, type) {
el.className = 'form-alert ' + type;
el.textContent = msg;
}
function hideAlert(el) {
el.className = 'form-alert';
el.textContent = '';
}
// ============================================================
// USERS
// ============================================================
async function loadUsers() {
try {
const res = await fetch('/api/users', {
headers: { 'Authorization': 'Bearer ' + token },
});
const users = await res.json();
renderUsers(users);
} catch (e) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderUsers(users) {
if (!users.length) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
return;
}
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
<td><button class="btn-delete" data-role="${u.role}">Добавить занятие</button></td>
</tr>`).join('');
}
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createAlert);
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) { showAlert(createAlert, 'Заполните все поля', 'error'); return; }
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ username, password, role }),
});
const data = await res.json();
if (res.ok) {
showAlert(createAlert, `Пользователь "${data.username}" создан`, 'success');
createForm.reset();
loadUsers();
} else {
showAlert(createAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createAlert, 'Ошибка соединения', 'error'); }
});
usersTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить пользователя?')) return;
try {
const res = await fetch('/api/users/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) loadUsers();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// EDUCATION FORMS
// ============================================================
async function loadEducationForms() {
try {
const res = await fetch('/api/education-forms', {
headers: { 'Authorization': 'Bearer ' + token },
});
allEducationForms = await res.json();
renderEfTable(allEducationForms);
populateEfSelects(allEducationForms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
function populateEfSelects(forms) {
// Group creation select
const currentVal = newGroupEfSelect.value;
newGroupEfSelect.innerHTML = forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentVal && forms.find(f => f.id == currentVal)) {
newGroupEfSelect.value = currentVal;
}
// Filter select
const currentFilter = filterEfSelect.value;
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createEfAlert);
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert(createEfAlert, 'Введите название', 'error'); return; }
try {
const res = await fetch('/api/education-forms', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) {
showAlert(createEfAlert, `Форма "${data.name}" создана`, 'success');
createEfForm.reset();
loadEducationForms();
} else {
showAlert(createEfAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createEfAlert, 'Ошибка соединения', 'error'); }
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить форму обучения?')) return;
try {
const res = await fetch('/api/education-forms/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadEducationForms();
} else {
const data = await res.json();
alert(data.message || 'Ошибка удаления');
}
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// GROUPS
// ============================================================
async function loadGroups() {
try {
const res = await fetch('/api/groups', {
headers: { 'Authorization': 'Bearer ' + token },
});
allGroups = await res.json();
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function applyGroupFilter() {
const filterId = filterEfSelect.value;
const filtered = filterId
? allGroups.filter(g => g.educationFormId == filterId)
: allGroups;
renderGroups(filtered);
}
filterEfSelect.addEventListener('change', applyGroupFilter);
function renderGroups(groups) {
if (!groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
<tr>
<td>${g.id}</td>
<td>${escapeHtml(g.name)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join('');
}
createGroupForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createGroupAlert);
const name = document.getElementById('new-group-name').value.trim();
const educationFormId = newGroupEfSelect.value;
if (!name) { showAlert(createGroupAlert, 'Введите название группы', 'error'); return; }
if (!educationFormId) { showAlert(createGroupAlert, 'Выберите форму обучения', 'error'); return; }
try {
const res = await fetch('/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name, educationFormId: Number(educationFormId) }),
});
const data = await res.json();
if (res.ok) {
showAlert(createGroupAlert, `Группа "${data.name}" создана`, 'success');
createGroupForm.reset();
loadGroups();
} else {
showAlert(createGroupAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createGroupAlert, 'Ошибка соединения', 'error'); }
});
groupsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить группу?')) return;
try {
const res = await fetch('/api/groups/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) loadGroups();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// LOGOUT & INIT
// ============================================================
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
window.location.href = '/';
});
loadUsers();
})();

View File

@@ -0,0 +1,587 @@
/* ===== Cards ===== */
.card {
background: var(--bg-card);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 1.75rem;
position: relative;
overflow: visible;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
animation: slideUpCard 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) both;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
opacity: 0;
transition: opacity var(--transition);
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
border-color: rgba(255, 255, 255, 0.12);
}
.card:hover::before {
opacity: 1;
}
.create-card {
z-index: 10;
}
.card h2 {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ===== Form Structure ===== */
.form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group {
flex: 1;
min-width: 160px;
}
.form-group label {
display: block;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-group input,
.form-group select {
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;
outline: none;
transition: all var(--transition);
}
.form-group input::placeholder {
color: var(--text-placeholder);
transition: opacity var(--transition);
}
.form-group input:focus,
.form-group select:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 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;
appearance: textfield;
}
/* Select Base Style */
.form-group select,
.filter-row select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
}
.form-group select option,
.filter-row select option {
background: #1a1a2e;
color: var(--text-primary);
}
/* Light theme selects */
[data-theme="light"] .form-group input,
[data-theme="light"] .form-group select,
[data-theme="light"] .filter-row select {
border-color: rgba(0, 0, 0, 0.15);
}
[data-theme="light"] .form-group select option,
[data-theme="light"] .filter-row select option {
background: #fff;
color: #1a1a2e;
}
/* Filter Row */
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.card-header-row h2 {
margin-bottom: 0;
}
.filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-row label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.filter-row select {
padding: 0.45rem 2rem 0.45rem 0.7rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.85rem;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.filter-row select:focus {
background-color: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* ===== Custom Multi-Select ===== */
.custom-multi-select {
position: relative;
user-select: none;
width: 100%;
}
.select-box {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
transition: all var(--transition);
}
.select-box:hover {
background: var(--bg-hover);
}
.select-box.active {
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
.dropdown-icon {
transition: transform var(--transition);
}
.select-box.active .dropdown-icon {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin-top: 0.5rem;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
padding: 1rem;
z-index: 100;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all var(--transition);
}
[data-theme="light"] .custom-multi-select .dropdown-menu {
background: rgba(255, 255, 255, 0.98);
}
.dropdown-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.checkbox-group-vertical {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 200px;
overflow-y: auto;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-primary);
padding: 0.25rem 0;
}
.checkbox-item input[type="checkbox"] {
cursor: pointer;
width: 1.1rem;
height: 1.1rem;
accent-color: var(--accent);
}
/* ===== Buttons ===== */
.btn-primary {
position: relative;
overflow: hidden;
padding: 0.75rem 1.75rem;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition);
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-primary::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-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-primary:hover::before {
opacity: 1;
}
.btn-primary:active {
transform: translateY(1px);
box-shadow: 0 2px 10px var(--accent-glow);
}
.btn-delete {
padding: 0.35rem 0.7rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
border-radius: var(--radius-sm);
color: var(--error);
font-family: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background var(--transition), transform var(--transition);
position: relative;
overflow: hidden;
}
.btn-delete:hover {
background: rgba(248, 113, 113, 0.2);
transform: scale(1.05);
}
.btn-icon-toggle {
background: transparent;
border: 1px solid var(--bg-card-border);
color: var(--text-secondary);
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(--bg-card);
border-color: var(--accent);
color: var(--accent);
transform: rotate(45deg);
box-shadow: 0 0 10px var(--accent-glow);
}
.btn-edit-classroom {
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;
transition: all var(--transition);
}
.btn-edit-classroom:hover {
background: rgba(99, 102, 241, 0.2);
transform: translateY(-1px);
}
/* ===== Alerts ===== */
.form-alert {
display: none;
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin-top: 0.75rem;
}
.form-alert.error {
display: block;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error);
animation: slideDownAlert 0.3s ease-out both;
}
.form-alert.success {
display: block;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
color: var(--success);
animation: slideDownAlert 0.3s ease-out both;
}
/* ===== Table ===== */
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--bg-card-border);
}
tbody td {
padding: 0.85rem 1rem;
font-size: 0.95rem;
border-bottom: 1px solid var(--bg-card-border);
transition: background var(--transition);
}
[data-theme="light"] tbody td {
border-bottom-color: rgba(0, 0, 0, 0.08);
}
tbody tr {
transition: background var(--transition);
animation: slideInRow 0.3s ease-out both;
}
/* Animation delays */
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 {
background: var(--bg-hover);
}
.loading-row {
text-align: center;
color: var(--text-secondary);
padding: 2rem !important;
}
/* ===== Badges ===== */
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-admin {
background: rgba(248, 113, 113, 0.15);
color: var(--error);
}
.badge-teacher {
background: rgba(251, 191, 36, 0.15);
color: var(--warning);
}
.badge-student {
background: rgba(52, 211, 153, 0.15);
color: var(--success);
}
.badge-ef {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
}
/* Classroom Status */
.badge-available {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.badge-unavailable {
background: rgba(248, 113, 113, 0.15);
color: var(--error);
}
.status-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== Modal ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition);
}
.modal-overlay.open {
display: flex;
opacity: 1;
}
.modal-content {
background: var(--bg-primary);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 2rem;
width: 90%;
max-width: 500px;
position: relative;
transform: scale(0.95);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.modal-overlay.open .modal-content {
transform: scale(1);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
transition: color var(--transition);
}
.modal-close:hover {
color: var(--error);
}

View File

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

110
frontend/admin/css/main.css Normal file
View File

@@ -0,0 +1,110 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Deep dark premium background */
--bg-primary: #0a0a0f;
--bg-sidebar: rgba(255, 255, 255, 0.02);
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-border: rgba(255, 255, 255, 0.05);
--bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06);
/* Typography */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-placeholder: #475569;
/* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
/* Status Colors */
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
/* Spatial */
--radius-sm: 10px;
--radius-md: 16px;
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* ===== Light Theme ===== */
[data-theme="light"] {
--bg-primary: #f8fafc;
--bg-sidebar: rgba(255, 255, 255, 0.7);
--bg-card: rgba(255, 255, 255, 0.75);
--bg-card-border: rgba(0, 0, 0, 0.08);
--bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-placeholder: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #d946ef;
--error: #ef4444;
--success: #10b981;
--warning: #f59e0b;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
transition: background 0.4s ease, color 0.4s ease;
}
/* ===== Animations ===== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUpCard {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDownAlert {
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); }
}
@keyframes admin-ripple {
to { transform: scale(4); opacity: 0; }
}
.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;
}

View File

@@ -8,7 +8,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="admin.css">
<!-- CSS Modules -->
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
</head>
<body>
@@ -31,7 +35,7 @@
</div>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-tab="users">
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
@@ -59,6 +63,29 @@
</svg>
Формы обучения
</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>
<div class="sidebar-footer">
<button class="btn-logout" id="btn-logout">
@@ -87,148 +114,17 @@
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 id="page-title">Управление пользователями</h1>
<h1 id="page-title">Загрузка...</h1>
</header>
<!-- ===== Users Tab ===== -->
<section class="content tab-content" id="tab-users">
<div class="card create-card">
<h2>Новый пользователь</h2>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все пользователи</h2>
<div class="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>Роль</th>
<th></th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Groups Tab ===== -->
<section class="content tab-content" id="tab-groups" style="display:none;">
<div class="card create-card">
<h2>Новая группа</h2>
<form id="create-group-form">
<div class="form-row">
<div class="form-group">
<label for="new-group-name">Название группы</label>
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
</div>
<div class="form-group">
<label for="new-group-ef">Форма обучения</label>
<select id="new-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Все группы</h2>
<div class="filter-row">
<label for="filter-ef">Фильтр:</label>
<select id="filter-ef">
<option value="">Все формы</option>
</select>
</div>
</div>
<div class="table-wrap">
<table id="groups-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Форма обучения</th>
<th></th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Education Forms Tab ===== -->
<section class="content tab-content" id="tab-edu-forms" style="display:none;">
<div class="card create-card">
<h2>Новая форма обучения</h2>
<form id="create-ef-form">
<div class="form-row">
<div class="form-group">
<label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-ef-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все формы обучения</h2>
<div class="table-wrap">
<table id="ef-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="ef-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Dynamic Content Injected Here -->
<section class="content" id="app-content">
<!-- Content loaded via main.js -->
</section>
</main>
<script src="/theme-toggle.js"></script>
<script src="admin.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

57
frontend/admin/js/api.js Normal file
View File

@@ -0,0 +1,57 @@
const token = localStorage.getItem('token');
export function getToken() {
return token;
}
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return token && role === 'ADMIN';
}
function getHeaders(contentType = 'application/json') {
const headers = {
'Authorization': `Bearer ${token}`
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
export async function apiFetch(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: getHeaders(body ? 'application/json' : null)
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
// Si status is 401 or 403, we should probably redirect to login,
// but for now let's just throw an error or handle it in the view.
let data;
try {
data = await response.json();
} catch (e) {
data = null;
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
// Shortcut methods
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
put: (url, body) => apiFetch(url, 'PUT', body),
delete: (url, body = null) => apiFetch(url, 'DELETE', body)
};

101
frontend/admin/js/main.js Normal file
View File

@@ -0,0 +1,101 @@
import { isAuthenticatedAsAdmin } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
import { initEduForms } from './views/edu-forms.js';
import { initEquipments } from './views/equipments.js';
import { initClassrooms } from './views/classrooms.js';
import { initSubjects } from './views/subjects.js';
// Configuration
const ROUTES = {
users: { title: 'Управление пользователями', file: 'views/users.html', init: initUsers },
groups: { title: 'Управление группами', file: 'views/groups.html', init: initGroups },
'edu-forms': { title: 'Формы обучения', file: 'views/edu-forms.html', init: initEduForms },
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const btnLogout = document.getElementById('btn-logout');
// Initial auth check
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
// Setup Global Effects
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
// Menu Toggle
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Logout
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
window.location.href = '/';
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
// Load template
try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Failed to load view');
const html = await response.text();
appContent.innerHTML = html;
// Initialize logic for the tab
if (ROUTES[tab].init) {
ROUTES[tab].init();
}
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e);
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Load default tab
switchTab('users');

102
frontend/admin/js/utils.js Normal file
View File

@@ -0,0 +1,102 @@
export const ESCAPE_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
export function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, m => ESCAPE_MAP[m]);
}
export function showAlert(elementId, msg, type) {
const el = document.getElementById(elementId);
if (!el) return;
el.className = 'form-alert ' + type;
el.textContent = msg;
}
export function hideAlert(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
el.className = 'form-alert';
el.textContent = '';
}
export function applyRippleEffect() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .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);
});
}
export 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;
// Remove old listeners to prevent duplication if re-initialized
const newBox = box.cloneNode(true);
box.parentNode.replaceChild(newBox, box);
newBox.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');
newBox.classList.add('active');
}
});
menu.addEventListener('click', (e) => {
e.stopPropagation();
});
container.addEventListener('change', () => {
updateSelectText(checkboxContainerId, textId);
});
}
export 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}`;
}
}
export function closeAllDropdownsOnOutsideClick() {
document.addEventListener('click', () => {
document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active'));
});
}

View File

@@ -0,0 +1,185 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody');
const createClassroomForm = document.getElementById('create-classroom-form');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
const modalEditClassroom = document.getElementById('modal-edit-classroom');
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
const editClassroomForm = document.getElementById('edit-classroom-form');
let allEquipments = [];
let editingClassroomData = null;
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');
async function loadInitialData() {
try {
allEquipments = await fetchEquipments();
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
await loadClassrooms();
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadClassrooms() {
try {
const classrooms = await api.get('/api/classrooms');
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms || !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}">Изменить</button>
<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Удалить</button>
</td>
</tr>`;
}).join('');
}
createClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-classroom-alert');
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('create-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.post('/api/classrooms', { name, capacity, equipmentIds, isAvailable: true });
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" добавлена`, 'success');
createClassroomForm.reset();
updateSelectText('equipment-checkboxes', 'equipment-select-text');
loadClassrooms();
} catch (e) {
showAlert('create-classroom-alert', e.message || 'Ошибка создания', '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 {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { alert('Ошибка удаления'); }
}
if (btnToggleStatus) {
const id = btnToggleStatus.dataset.id;
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms();
} catch (err) { alert('Ошибка изменения статуса'); }
}
if (btnEdit) {
openEditClassroomModal(btnEdit.dataset.id);
}
});
async function openEditClassroomModal(id) {
try {
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
const classrooms = await api.get('/api/classrooms');
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;
const existingEquipIds = editingClassroomData.equipments.map(e => e.id);
renderEquipmentCheckboxes(allEquipments, 'edit-equipment-checkboxes', 'edit-equipment-select-text', existingEquipIds);
hideAlert('edit-classroom-alert');
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('edit-classroom-alert');
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('edit-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.put('/api/classrooms/' + id, {
name,
capacity,
equipmentIds,
isAvailable: editingClassroomData.isAvailable
});
modalEditClassroom.classList.remove('open');
// We show alert on the main create form area or we could use toast
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" обновлена`, 'success');
loadClassrooms();
} catch (e) {
showAlert('edit-classroom-alert', e.message || 'Ошибка обновления', 'error');
}
});
loadInitialData();
}

View File

@@ -0,0 +1,71 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export let allEducationForms = [];
export async function fetchEducationForms() {
try {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error("Failed to fetch education forms", e);
return [];
}
}
export async function initEduForms() {
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
async function loadEF() {
try {
const forms = await fetchEducationForms();
renderEfTable(forms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms || !forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-ef-alert');
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/education-forms', { name });
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
createEfForm.reset();
loadEF();
} catch (e) {
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
}
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить форму обучения?')) return;
try {
await api.delete('/api/education-forms/' + btn.dataset.id);
loadEF();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEF();
}

View File

@@ -0,0 +1,88 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
export let allEquipments = [];
export async function fetchEquipments() {
try {
allEquipments = await api.get('/api/equipments');
return allEquipments;
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
container.innerHTML = equipments.map(eq => {
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
</label>
`}).join('');
updateSelectText(containerId, textId);
}
export async function initEquipments() {
const equipmentsTbody = document.getElementById('equipments-tbody');
const createEquipmentForm = document.getElementById('create-equipment-form');
async function loadEquipments() {
try {
const equipments = await fetchEquipments();
renderEquipments(equipments);
} catch (e) {
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEquipments(equipments) {
if (!equipments || !equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
createEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-equipment-alert');
const name = document.getElementById('new-equipment-name').value.trim();
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/equipments', { name });
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
createEquipmentForm.reset();
loadEquipments();
} catch (e) {
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
}
});
equipmentsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить оборудование?')) return;
try {
await api.delete('/api/equipments/' + btn.dataset.id);
loadEquipments();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEquipments();
}

View File

@@ -0,0 +1,108 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { fetchEducationForms } from './edu-forms.js';
export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const newGroupEfSelect = document.getElementById('new-group-ef');
const filterEfSelect = document.getElementById('filter-ef');
let allGroups = [];
let educationForms = [];
async function loadInitialData() {
try {
educationForms = await fetchEducationForms();
populateEfSelects(educationForms);
await loadGroups();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadGroups() {
try {
allGroups = await api.get('/api/groups');
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function applyGroupFilter() {
const filterId = filterEfSelect.value;
const filtered = filterId
? allGroups.filter(g => g.educationFormId == filterId)
: allGroups;
renderGroups(filtered);
}
filterEfSelect.addEventListener('change', applyGroupFilter);
function populateEfSelects(forms) {
// Group creation select
const currentVal = newGroupEfSelect.value;
newGroupEfSelect.innerHTML = forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentVal && forms.find(f => f.id == currentVal)) {
newGroupEfSelect.value = currentVal;
}
// Filter select
const currentFilter = filterEfSelect.value;
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
}
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
<tr>
<td>${g.id}</td>
<td>${escapeHtml(g.name)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join('');
}
createGroupForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-group-alert');
const name = document.getElementById('new-group-name').value.trim();
const educationFormId = newGroupEfSelect.value;
if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; }
try {
const data = await api.post('/api/groups', { name, educationFormId: Number(educationFormId) });
showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success');
createGroupForm.reset();
loadGroups();
} catch (e) {
showAlert('create-group-alert', e.message || 'Ошибка создания', 'error');
}
});
groupsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить группу?')) return;
try {
await api.delete('/api/groups/' + btn.dataset.id);
loadGroups();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadInitialData();
}

View File

@@ -0,0 +1,153 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const assignTeacherForm = document.getElementById('assign-teacher-form');
const assignTeacherSelect = document.getElementById('assign-teacher-select');
const assignSubjectSelect = document.getElementById('assign-subject-select');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody');
let allSubjects = [];
let allTeachers = [];
async function loadInitialData() {
await Promise.all([loadSubjects(), loadTeachers()]);
await loadTeacherSubjects();
}
async function loadSubjects() {
try {
allSubjects = await api.get('/api/subjects');
renderSubjects(allSubjects);
populateSubjectSelect(allSubjects);
} catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects || !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 {
allTeachers = await api.get('/api/users/teachers');
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers || !teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const tsData = await api.get('/api/teacher-subjects');
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray || !tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim();
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/subjects', { name });
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success');
createSubjectForm.reset();
loadSubjects();
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
});
subjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить дисциплину?')) return;
try {
await api.delete('/api/subjects/' + btn.dataset.id);
loadSubjects();
loadTeacherSubjects(); // May have deleted a subject that was assigned
} catch (e) { alert(e.message || 'Ошибка удаления'); }
});
assignTeacherForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('assign-teacher-alert');
const userId = assignTeacherSelect.value;
const subjectId = assignSubjectSelect.value;
if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; }
try {
await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) });
showAlert('assign-teacher-alert', 'Привязка создана', 'success');
loadTeacherSubjects();
} catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); }
});
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try {
await api.delete('/api/teacher-subjects', {
userId: Number(btn.dataset.userId),
subjectId: Number(btn.dataset.subjectId)
});
loadTeacherSubjects();
} catch (e) { alert(e.message || 'Ошибка удаления'); }
});
loadInitialData();
}

View File

@@ -0,0 +1,67 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
async function loadUsers() {
try {
const users = await api.get('/api/users');
renderUsers(users);
} catch (e) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки: ' + escapeHtml(e.message) + '</td></tr>';
}
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
return;
}
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
</tr>`).join('');
}
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-alert');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) { showAlert('create-alert', 'Заполните все поля', 'error'); return; }
try {
const data = await api.post('/api/users', { username, password, role });
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
createForm.reset();
loadUsers();
} catch (e) {
showAlert('create-alert', e.message || 'Ошибка соединения', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить пользователя?')) return;
try {
await api.delete('/api/users/' + btn.dataset.id);
loadUsers();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
// Initial load
loadUsers();
}

View File

@@ -0,0 +1,109 @@
<!-- ===== Classrooms Tab ===== -->
<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-primary">Добавить</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 style="text-align: right;">Действия</th>
</tr>
</thead>
<tbody id="classrooms-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Classroom Modal -->
<div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content card">
<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-primary" style="width: 100%;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,34 @@
<!-- ===== Education Forms Tab ===== -->
<div class="card create-card">
<h2>Новая форма обучения</h2>
<form id="create-ef-form">
<div class="form-row">
<div class="form-group">
<label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-ef-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все формы обучения</h2>
<div class="table-wrap">
<table id="ef-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="ef-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,34 @@
<!-- ===== Equipments Tab ===== -->
<div class="card create-card">
<h2>Новое оборудование</h2>
<form id="create-equipment-form">
<div class="form-row">
<div class="form-group">
<label for="new-equipment-name">Название оборудования</label>
<input type="text" id="new-equipment-name" placeholder="Проектор" required>
</div>
<button type="submit" class="btn-primary">Добавить</button>
</div>
<div class="form-alert" id="create-equipment-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Справочник оборудования</h2>
<div class="table-wrap">
<table id="equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="equipments-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<!-- ===== Groups Tab ===== -->
<div class="card create-card">
<h2>Новая группа</h2>
<form id="create-group-form">
<div class="form-row">
<div class="form-group">
<label for="new-group-name">Название группы</label>
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
</div>
<div class="form-group">
<label for="new-group-ef">Форма обучения</label>
<select id="new-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Все группы</h2>
<div class="filter-row">
<label for="filter-ef">Фильтр:</label>
<select id="filter-ef">
<option value="">Все формы</option>
</select>
</div>
</div>
<div class="table-wrap">
<table id="groups-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Форма обучения</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,76 @@
<!-- ===== Subjects Tab ===== -->
<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-primary">Добавить</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-primary">Привязать</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>

View File

@@ -0,0 +1,47 @@
<!-- ===== Users Tab ===== -->
<div class="card create-card">
<h2>Новый пользователь</h2>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все пользователи</h2>
<div class="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>Роль</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -37,7 +37,7 @@
</div>
<form id="login-form" novalidate>
<div class="form-group">
<div class="form-group stagger-1">
<label for="username">Имя пользователя</label>
<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">
@@ -49,7 +49,7 @@
<span class="error-message" id="username-error"></span>
</div>
<div class="form-group">
<div class="form-group stagger-2">
<label for="password">Пароль</label>
<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">
@@ -67,9 +67,9 @@
<span class="error-message" id="password-error"></span>
</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-loader" hidden>
<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 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', () => {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
@@ -27,8 +45,14 @@
}
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;
// Shake animation
group.classList.remove('shake');
void group.offsetWidth; // trigger reflow
group.classList.add('shake');
}
function showAlert(message, type) {

View File

@@ -1,11 +1,10 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
* {
margin: 0;
@@ -14,59 +13,168 @@
}
:root {
--bg-primary: #0f0f1a;
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--accent-hover: #818cf8;
--bg-card: rgba(255, 255, 255, 0.05);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--accent-glow: rgba(99, 102, 241, 0.35);
/* Deep dark premium background */
--bg-primary: #0a0a0f;
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-border: rgba(255, 255, 255, 0.05);
/* Typography */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
/* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
}
[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-secondary: #374151;
--text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95);
--bg-card-border: rgba(0, 0, 0, 0.22);
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
--accent-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #d946ef;
}
body {
font-family: 'Inter', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
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;
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%;
}
[data-theme="light"] .placeholder-card {
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.placeholder-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);
}
.placeholder h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.placeholder p {
color: var(--text-secondary);
.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;
box-shadow: 0 0 30px var(--accent-glow);
}
.placeholder a {
color: var(--accent-hover);
.placeholder-card h1 {
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;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
}
.placeholder a:hover {
text-decoration: underline;
.btn-logout:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
}
/* Theme Toggle */
@@ -111,10 +219,21 @@
</head>
<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>
<p>Раздел в разработке</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" class="btn-logout" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div>
<script src="/theme-toggle.js"></script>

View File

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

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
* {
margin: 0;
@@ -14,59 +14,186 @@
}
:root {
--bg-primary: #0f0f1a;
--text-primary: #f0f0f5;
--text-secondary: #9ca3af;
--accent-hover: #818cf8;
--bg-card: rgba(255, 255, 255, 0.05);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--accent-glow: rgba(99, 102, 241, 0.35);
/* Deep dark premium background */
--bg-primary: #0a0a0f;
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-border: rgba(255, 255, 255, 0.05);
/* Typography */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
/* Vibrant Accents */
--accent: #8b5cf6;
--accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899;
}
[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-secondary: #374151;
--text-secondary: #475569;
--accent: #6366f1;
--accent-hover: #4f46e5;
--bg-card: rgba(255, 255, 255, 0.95);
--bg-card-border: rgba(0, 0, 0, 0.22);
--bg-input: rgba(0, 0, 0, 0.08);
--accent-glow: rgba(99, 102, 241, 0.25);
--accent-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #d946ef;
}
body {
font-family: 'Inter', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
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;
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 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
[data-theme="light"] .placeholder-card {
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.placeholder p {
color: var(--text-secondary);
.placeholder-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);
}
.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;
box-shadow: 0 0 30px var(--accent-glow);
}
.placeholder a {
color: var(--accent-hover);
.placeholder-card h1 {
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;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--accent-glow);
}
.placeholder a:hover {
text-decoration: underline;
.btn-logout:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
}
/* Theme Toggle */
@@ -111,10 +238,23 @@
</head>
<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>
<p>Раздел в разработке</p>
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
<a href="/" class="btn-logout"
onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
</div>
<script src="/theme-toggle.js"></script>