Compare commits
27 Commits
92fc3a8238
...
Create-Les
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d06c69e2b | ||
|
|
684273de50 | ||
|
|
772b110762 | ||
|
|
18f47c6d3d | ||
|
|
007b4fb619 | ||
|
|
678cf94ad3 | ||
|
|
94aa164930 | ||
|
|
9776f4a56f | ||
|
|
df6ddeecd5 | ||
|
|
e44ac04cac | ||
|
|
0ac81284fa | ||
|
|
74d937f6dc | ||
|
|
eea444409e | ||
|
|
c552d14909 | ||
|
|
be46fa2be2 | ||
|
|
6993ac29d5 | ||
|
|
07419d541e | ||
|
|
86a29f6419 | ||
|
|
e9c08b4c75 | ||
|
|
ed8668c599 | ||
|
|
64d85eab55 | ||
|
|
c3d5246874 | ||
|
|
bcabb560a4 | ||
|
|
24f8c4d518 | ||
|
|
7815f3ed91 | ||
|
|
99556ae3e6 | ||
|
|
3fd4342dc3 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,17 +1,8 @@
|
|||||||
# Игнорируем данные БД (но не init-скрипты)
|
# Игнорируем данные БД (но не init-скрипты)
|
||||||
db/data/
|
db/data/
|
||||||
postgres_data/
|
|
||||||
|
|
||||||
# Игнорируем секреты
|
# Игнорируем секреты
|
||||||
.env
|
.env
|
||||||
GEMINI.md
|
|
||||||
AGENTS.md
|
|
||||||
|
|
||||||
# Игнорируем системные папки IDE (если редактируете с ПК)
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.DS_Store
|
|
||||||
.agent
|
|
||||||
|
|
||||||
# Игнорируем временные файлы сборки (на будущее)
|
# Игнорируем временные файлы сборки (на будущее)
|
||||||
backend/target/
|
backend/target/
|
||||||
|
|||||||
1
backend/src/main/java/com/magistr/app/README.md
Normal file
1
backend/src/main/java/com/magistr/app/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
КОММИТ12
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Оборудование удалено"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Дисциплина удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Привязка удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,13 @@ public class UserController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/teachers")
|
||||||
|
public List<UserResponse> getTeachers() {
|
||||||
|
return userRepository.findByRole(Role.TEACHER).stream()
|
||||||
|
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||||
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
if (request.getUsername() == null || request.getUsername().isBlank()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/main/java/com/magistr/app/model/Classroom.java
Normal file
70
backend/src/main/java/com/magistr/app/model/Classroom.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/main/java/com/magistr/app/model/Equipment.java
Normal file
39
backend/src/main/java/com/magistr/app/model/Equipment.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/src/main/java/com/magistr/app/model/Lesson.java
Normal file
89
backend/src/main/java/com/magistr/app/model/Lesson.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/main/java/com/magistr/app/model/Subject.java
Normal file
39
backend/src/main/java/com/magistr/app/model/Subject.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.magistr.app.repository;
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.model.User;
|
import com.magistr.app.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
Optional<User> findByUsername(String username);
|
Optional<User> findByUsername(String username);
|
||||||
|
|
||||||
|
List<User> findByRole(Role role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ services:
|
|||||||
image: postgres:alpine3.23
|
image: postgres:alpine3.23
|
||||||
container_name: db
|
container_name: db
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|||||||
196
db/init/init.sql
196
db/init/init.sql
@@ -1,10 +1,18 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Инициализация расширений
|
||||||
|
-- ==========================================
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Пользователи и роли
|
||||||
|
-- ==========================================
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT'
|
role VARCHAR(20) NOT NULL DEFAULT 'STUDENT',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
||||||
@@ -12,16 +20,196 @@ INSERT INTO users (username, password, role)
|
|||||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
|
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Образовательные формы
|
||||||
|
-- ==========================================
|
||||||
CREATE TABLE IF NOT EXISTS education_forms (
|
CREATE TABLE IF NOT EXISTS education_forms (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) UNIQUE NOT NULL
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет')
|
INSERT INTO education_forms (name) VALUES
|
||||||
|
('Бакалавриат'),
|
||||||
|
('Магистратура'),
|
||||||
|
('Специалитет')
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Учебные группы
|
||||||
|
-- ==========================================
|
||||||
CREATE TABLE IF NOT EXISTS student_groups (
|
CREATE TABLE IF NOT EXISTS student_groups (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
education_form_id BIGINT NOT NULL REFERENCES education_forms(id)
|
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||||||
|
course INT CHECK (course BETWEEN 1 AND 6),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Тестовая базовая группа для работы
|
||||||
|
INSERT INTO student_groups (name, education_form_id, course)
|
||||||
|
VALUES ('ИВТ-21-1', 1, 3)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS subgroups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
student_capacity INT,
|
||||||
|
UNIQUE(group_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Справочники
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Дисциплины
|
||||||
|
CREATE TABLE IF NOT EXISTS subjects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
code VARCHAR(20),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO subjects (name) VALUES
|
||||||
|
('Высшая математика'),
|
||||||
|
('Философия'),
|
||||||
|
('Информатика'),
|
||||||
|
('Базы данных'),
|
||||||
|
('Английский язык')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Типы занятий
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_types (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
color_code VARCHAR(7) DEFAULT '#3788d8', -- для цветовой индикации в календаре
|
||||||
|
duration_minutes INT DEFAULT 90
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO lesson_types (name, color_code) VALUES
|
||||||
|
('Лекция', '#FF6B6B'),
|
||||||
|
('Практика', '#4ECDC4'),
|
||||||
|
('Лабораторная работа', '#45B7D1')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Оборудование
|
||||||
|
CREATE TABLE IF NOT EXISTS equipments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
inventory_number VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO equipments (name) VALUES
|
||||||
|
('Проектор'),
|
||||||
|
('ПК'),
|
||||||
|
('Лаборатория'),
|
||||||
|
('Интерактивная доска'),
|
||||||
|
('Документ-камера'),
|
||||||
|
('Аудиосистема')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Аудитории
|
||||||
|
CREATE TABLE IF NOT EXISTS classrooms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
capacity INT NOT NULL CHECK (capacity > 0),
|
||||||
|
building VARCHAR(50),
|
||||||
|
floor INT,
|
||||||
|
is_available BOOLEAN DEFAULT TRUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||||||
|
('101 Ленинская', 120, 'Главный корпус', 1),
|
||||||
|
('202 IT Lab', 20, 'Корпус IT', 2),
|
||||||
|
('303 Обычная', 30, 'Главный корпус', 3)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||||
|
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||||
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||||
|
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
|
||||||
|
quantity INT DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
notes TEXT,
|
||||||
|
PRIMARY KEY (classroom_id, equipment_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Заполнение привязок оборудования с использованием подзапросов
|
||||||
|
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
||||||
|
SELECT c.id, e.id,
|
||||||
|
CASE
|
||||||
|
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
||||||
|
WHEN e.name = 'ПК' THEN 1
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
FROM classrooms c, equipments e
|
||||||
|
WHERE
|
||||||
|
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
||||||
|
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
||||||
|
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||||||
|
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Связи для преподавателей
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Привязка преподавателей к дисциплинам
|
||||||
|
CREATE TABLE IF NOT EXISTS teacher_subjects (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
qualification_level VARCHAR(50),
|
||||||
|
experience_years INT,
|
||||||
|
PRIMARY KEY(user_id, subject_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Какие типы занятий может вести преподаватель по дисциплине
|
||||||
|
CREATE TABLE IF NOT EXISTS teacher_lesson_types (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, subject_id, lesson_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Основная таблица Расписания (Lessons)
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS lessons (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
group_id BIGINT NOT NULL REFERENCES student_groups(id),
|
||||||
|
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
||||||
|
day VARCHAR(255) NOT NULL,
|
||||||
|
week VARCHAR(255) NOT NULL,
|
||||||
|
time VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Функция обновления timestamp
|
||||||
|
-- ==========================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Триггеры для обновления updated_at
|
||||||
|
CREATE TRIGGER update_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- Комментарии к таблицам и полям (для документации)
|
||||||
|
-- ==========================================
|
||||||
|
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
|
||||||
|
COMMENT ON TABLE lessons IS 'Основное расписание занятий';
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,354 +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>
|
|
||||||
</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();
|
|
||||||
})();
|
|
||||||
587
frontend/admin/css/components.css
Normal file
587
frontend/admin/css/components.css
Normal 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);
|
||||||
|
}
|
||||||
216
frontend/admin/css/layout.css
Normal file
216
frontend/admin/css/layout.css
Normal 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
110
frontend/admin/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -31,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
@@ -59,6 +63,29 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Формы обучения
|
Формы обучения
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="equipments">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||||
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||||
|
</svg>
|
||||||
|
Оборудование
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="classrooms">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||||
|
</svg>
|
||||||
|
Аудитории
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="subjects">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
Дисциплины
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="btn-logout" id="btn-logout">
|
<button class="btn-logout" id="btn-logout">
|
||||||
@@ -87,148 +114,17 @@
|
|||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 id="page-title">Управление пользователями</h1>
|
<h1 id="page-title">Загрузка...</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ===== Users Tab ===== -->
|
<!-- Dynamic Content Injected Here -->
|
||||||
<section class="content tab-content" id="tab-users">
|
<section class="content" id="app-content">
|
||||||
<div class="card create-card">
|
<!-- Content loaded via main.js -->
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/theme-toggle.js"></script>
|
<script src="/theme-toggle.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
57
frontend/admin/js/api.js
Normal file
57
frontend/admin/js/api.js
Normal 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
101
frontend/admin/js/main.js
Normal 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
102
frontend/admin/js/utils.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export const ESCAPE_MAP = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
185
frontend/admin/js/views/classrooms.js
Normal file
185
frontend/admin/js/views/classrooms.js
Normal 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();
|
||||||
|
}
|
||||||
71
frontend/admin/js/views/edu-forms.js
Normal file
71
frontend/admin/js/views/edu-forms.js
Normal 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();
|
||||||
|
}
|
||||||
88
frontend/admin/js/views/equipments.js
Normal file
88
frontend/admin/js/views/equipments.js
Normal 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();
|
||||||
|
}
|
||||||
108
frontend/admin/js/views/groups.js
Normal file
108
frontend/admin/js/views/groups.js
Normal 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();
|
||||||
|
}
|
||||||
153
frontend/admin/js/views/subjects.js
Normal file
153
frontend/admin/js/views/subjects.js
Normal 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();
|
||||||
|
}
|
||||||
67
frontend/admin/js/views/users.js
Normal file
67
frontend/admin/js/views/users.js
Normal 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();
|
||||||
|
}
|
||||||
109
frontend/admin/views/classrooms.html
Normal file
109
frontend/admin/views/classrooms.html
Normal 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">×</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>
|
||||||
34
frontend/admin/views/edu-forms.html
Normal file
34
frontend/admin/views/edu-forms.html
Normal 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>
|
||||||
34
frontend/admin/views/equipments.html
Normal file
34
frontend/admin/views/equipments.html
Normal 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>
|
||||||
49
frontend/admin/views/groups.html
Normal file
49
frontend/admin/views/groups.html
Normal 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>
|
||||||
76
frontend/admin/views/subjects.html
Normal file
76
frontend/admin/views/subjects.html
Normal 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>
|
||||||
47
frontend/admin/views/users.html
Normal file
47
frontend/admin/views/users.html
Normal 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>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form" novalidate>
|
<form id="login-form" novalidate>
|
||||||
<div class="form-group">
|
<div class="form-group stagger-1">
|
||||||
<label for="username">Имя пользователя</label>
|
<label for="username">Имя пользователя</label>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<span class="error-message" id="username-error"></span>
|
<span class="error-message" id="username-error"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group stagger-2">
|
||||||
<label for="password">Пароль</label>
|
<label for="password">Пароль</label>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
<span class="error-message" id="password-error"></span>
|
<span class="error-message" id="password-error"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-alert" id="form-alert" role="alert"></div>
|
<div class="form-alert stagger-3" id="form-alert" role="alert"></div>
|
||||||
|
|
||||||
<button type="submit" class="btn-submit" id="btn-submit">
|
<button type="submit" class="btn-submit stagger-4" id="btn-submit">
|
||||||
<span class="btn-text">Войти</span>
|
<span class="btn-text">Войти</span>
|
||||||
<span class="btn-loader" hidden>
|
<span class="btn-loader" hidden>
|
||||||
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24">
|
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -12,6 +12,24 @@
|
|||||||
const btnLoader = btnSubmit.querySelector('.btn-loader');
|
const btnLoader = btnSubmit.querySelector('.btn-loader');
|
||||||
const togglePassword = document.getElementById('toggle-password');
|
const togglePassword = document.getElementById('toggle-password');
|
||||||
|
|
||||||
|
// Ripple effect
|
||||||
|
btnSubmit.addEventListener('click', function(e) {
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const ripple = document.createElement('span');
|
||||||
|
ripple.classList.add('ripple');
|
||||||
|
ripple.style.left = `${x}px`;
|
||||||
|
ripple.style.top = `${y}px`;
|
||||||
|
|
||||||
|
this.appendChild(ripple);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
ripple.remove();
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
|
||||||
togglePassword.addEventListener('click', () => {
|
togglePassword.addEventListener('click', () => {
|
||||||
const isPassword = passwordInput.type === 'password';
|
const isPassword = passwordInput.type === 'password';
|
||||||
passwordInput.type = isPassword ? 'text' : 'password';
|
passwordInput.type = isPassword ? 'text' : 'password';
|
||||||
@@ -27,8 +45,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setFieldError(input, errorEl, message) {
|
function setFieldError(input, errorEl, message) {
|
||||||
input.closest('.form-group').classList.add('has-error');
|
const group = input.closest('.form-group');
|
||||||
|
group.classList.add('has-error');
|
||||||
errorEl.textContent = message;
|
errorEl.textContent = message;
|
||||||
|
|
||||||
|
// Shake animation
|
||||||
|
group.classList.remove('shake');
|
||||||
|
void group.offsetWidth; // trigger reflow
|
||||||
|
group.classList.add('shake');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAlert(message, type) {
|
function showAlert(message, type) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Панель студента</title>
|
<title>Панель студента</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -14,59 +13,168 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0f0f1a;
|
/* Deep dark premium background */
|
||||||
--text-primary: #f0f0f5;
|
--bg-primary: #0a0a0f;
|
||||||
--text-secondary: #9ca3af;
|
--bg-card: rgba(255, 255, 255, 0.03);
|
||||||
--accent-hover: #818cf8;
|
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||||
--bg-card: rgba(255, 255, 255, 0.05);
|
/* Typography */
|
||||||
--bg-card-border: rgba(255, 255, 255, 0.08);
|
--text-primary: #f8fafc;
|
||||||
--bg-input: rgba(255, 255, 255, 0.06);
|
--text-secondary: #94a3b8;
|
||||||
--accent-glow: rgba(99, 102, 241, 0.35);
|
/* Vibrant Accents */
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-hover: #a78bfa;
|
||||||
|
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||||
|
--accent-secondary: #ec4899;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #e8eaef;
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.7);
|
||||||
|
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #374151;
|
--text-secondary: #475569;
|
||||||
|
--accent: #6366f1;
|
||||||
--accent-hover: #4f46e5;
|
--accent-hover: #4f46e5;
|
||||||
--bg-card: rgba(255, 255, 255, 0.95);
|
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||||
--bg-card-border: rgba(0, 0, 0, 0.22);
|
--accent-secondary: #d946ef;
|
||||||
--bg-input: rgba(0, 0, 0, 0.08);
|
|
||||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
transition: background 0.4s ease, color 0.4s ease;
|
transition: background 0.4s ease, color 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
/* ===== Animated Background ===== */
|
||||||
|
.background {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(90px);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .shape { opacity: 0.15; }
|
||||||
|
|
||||||
|
.shape-1 {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: radial-gradient(circle, var(--accent), transparent 60%);
|
||||||
|
top: -20%;
|
||||||
|
left: -10%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
|
||||||
|
bottom: -20%;
|
||||||
|
right: -10%;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(-30px, 30px) scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(32px);
|
||||||
|
-webkit-backdrop-filter: blur(32px);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 4rem 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 {
|
.placeholder-card .icon {
|
||||||
font-size: 1.5rem;
|
display: inline-flex;
|
||||||
margin-bottom: 0.5rem;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
.placeholder p {
|
height: 80px;
|
||||||
color: var(--text-secondary);
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
|
||||||
|
color: var(--text-primary);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 0 30px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder a {
|
.placeholder-card h1 {
|
||||||
color: var(--accent-hover);
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder a:hover {
|
.btn-logout:hover {
|
||||||
text-decoration: underline;
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme Toggle */
|
/* Theme Toggle */
|
||||||
@@ -111,10 +219,21 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="placeholder">
|
<div class="background">
|
||||||
|
<div class="shape shape-1"></div>
|
||||||
|
<div class="shape shape-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="placeholder-card">
|
||||||
|
<div class="icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h1>Панель студента</h1>
|
<h1>Панель студента</h1>
|
||||||
<p>Раздел в разработке</p>
|
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
|
||||||
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
<a href="/" class="btn-logout" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/theme-toggle.js"></script>
|
<script src="/theme-toggle.js"></script>
|
||||||
|
|||||||
@@ -8,50 +8,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0f0f1a;
|
/* Deep dark premium background */
|
||||||
--bg-card: rgba(255, 255, 255, 0.05);
|
--bg-primary: #0a0a0f;
|
||||||
--bg-card-border: rgba(255, 255, 255, 0.08);
|
--bg-card: rgba(255, 255, 255, 0.03);
|
||||||
--bg-input: rgba(255, 255, 255, 0.06);
|
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||||
--bg-input-focus: rgba(255, 255, 255, 0.1);
|
--bg-input: rgba(255, 255, 255, 0.04);
|
||||||
--text-primary: #f0f0f5;
|
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
||||||
--text-secondary: #9ca3af;
|
|
||||||
--text-placeholder: #6b7280;
|
/* Typography */
|
||||||
--accent: #6366f1;
|
--text-primary: #f8fafc;
|
||||||
--accent-hover: #818cf8;
|
--text-secondary: #94a3b8;
|
||||||
--accent-glow: rgba(99, 102, 241, 0.35);
|
--text-placeholder: #475569;
|
||||||
--error: #f87171;
|
|
||||||
--success: #34d399;
|
/* Vibrant Accents */
|
||||||
--radius-sm: 8px;
|
--accent: #8b5cf6;
|
||||||
--radius-md: 14px;
|
--accent-hover: #a78bfa;
|
||||||
--radius-lg: 20px;
|
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||||
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
--accent-secondary: #ec4899;
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--error: #ef4444;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
|
||||||
|
/* Spatial */
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Light Theme ===== */
|
/* ===== Light Theme ===== */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #e8eaef;
|
--bg-primary: #f8fafc;
|
||||||
--bg-card: rgba(255, 255, 255, 0.95);
|
--bg-card: rgba(255, 255, 255, 0.7);
|
||||||
--bg-card-border: rgba(0, 0, 0, 0.22);
|
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--bg-input: rgba(0, 0, 0, 0.08);
|
--bg-input: rgba(0, 0, 0, 0.03);
|
||||||
--bg-input-focus: rgba(0, 0, 0, 0.12);
|
--bg-input-focus: rgba(0, 0, 0, 0.06);
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #374151;
|
--text-secondary: #475569;
|
||||||
--text-placeholder: #6b7280;
|
--text-placeholder: #94a3b8;
|
||||||
--accent: #6366f1;
|
--accent: #6366f1;
|
||||||
--accent-hover: #4f46e5;
|
--accent-hover: #4f46e5;
|
||||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||||
--error: #dc2626;
|
--accent-secondary: #d946ef;
|
||||||
--success: #16a34a;
|
--error: #ef4444;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .shape {
|
[data-theme="light"] .shape {
|
||||||
opacity: 0.25;
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .login-card {
|
[data-theme="light"] .login-card {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 32px rgba(0, 0, 0, 0.08),
|
0 20px 40px rgba(0, 0, 0, 0.05),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
inset 0 1px 0 rgba(255, 255, 255, 0.8),
|
||||||
|
inset 0 0 20px rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -83,38 +97,39 @@ body {
|
|||||||
.shape {
|
.shape {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(80px);
|
filter: blur(90px);
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
animation: float 20s ease-in-out infinite;
|
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
|
||||||
transition: opacity 0.4s ease;
|
transition: opacity 0.5s ease;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shape-1 {
|
.shape-1 {
|
||||||
width: 500px;
|
width: 600px;
|
||||||
height: 500px;
|
height: 600px;
|
||||||
background: radial-gradient(circle, #6366f1, transparent 70%);
|
background: radial-gradient(circle, var(--accent), transparent 60%);
|
||||||
top: -15%;
|
top: -20%;
|
||||||
left: -10%;
|
left: -10%;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shape-2 {
|
.shape-2 {
|
||||||
width: 400px;
|
width: 500px;
|
||||||
height: 400px;
|
height: 500px;
|
||||||
background: radial-gradient(circle, #8b5cf6, transparent 70%);
|
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
|
||||||
bottom: -10%;
|
bottom: -20%;
|
||||||
right: -10%;
|
right: -10%;
|
||||||
animation-delay: -7s;
|
animation-delay: -5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shape-3 {
|
.shape-3 {
|
||||||
width: 300px;
|
width: 400px;
|
||||||
height: 300px;
|
height: 400px;
|
||||||
background: radial-gradient(circle, #a78bfa, transparent 70%);
|
background: radial-gradient(circle, #3b82f6, transparent 60%);
|
||||||
top: 50%;
|
top: 40%;
|
||||||
left: 50%;
|
left: 60%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
animation-delay: -14s;
|
animation-delay: -10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
@@ -162,15 +177,29 @@ body {
|
|||||||
/* ===== Login Card (Glassmorphism) ===== */
|
/* ===== Login Card (Glassmorphism) ===== */
|
||||||
.login-card {
|
.login-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(32px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(32px);
|
||||||
border: 1px solid var(--bg-card-border);
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 2.5rem 2rem 2rem;
|
padding: 3rem 2.5rem 2.5rem;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
0 24px 48px rgba(0, 0, 0, 0.2),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||||
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
inset 0 0 32px rgba(255, 255, 255, 0.02);
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Header ===== */
|
/* ===== Header ===== */
|
||||||
@@ -241,9 +270,9 @@ body {
|
|||||||
|
|
||||||
.input-wrapper input {
|
.input-wrapper input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.8rem 0.8rem 0.8rem 2.75rem;
|
padding: 0.9rem 1rem 0.9rem 2.8rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--bg-card-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -252,17 +281,24 @@ body {
|
|||||||
transition:
|
transition:
|
||||||
background var(--transition),
|
background var(--transition),
|
||||||
border-color var(--transition),
|
border-color var(--transition),
|
||||||
box-shadow var(--transition);
|
box-shadow var(--transition),
|
||||||
|
transform var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper input::placeholder {
|
.input-wrapper input::placeholder {
|
||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper input:focus {
|
.input-wrapper input:focus {
|
||||||
background: var(--bg-input-focus);
|
background: var(--bg-input-focus);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input:focus::placeholder {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper input:focus~.input-icon,
|
.input-wrapper input:focus~.input-icon,
|
||||||
@@ -303,6 +339,28 @@ body {
|
|||||||
transition: opacity var(--transition);
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
.stagger-1 { animation: fadeInUp 0.5s ease-out 0.1s both; }
|
||||||
|
.stagger-2 { animation: fadeInUp 0.5s ease-out 0.2s both; }
|
||||||
|
.stagger-3 { animation: fadeInUp 0.5s ease-out 0.3s both; }
|
||||||
|
.stagger-4 { animation: fadeInUp 0.5s ease-out 0.4s both; }
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-6px); }
|
||||||
|
50% { transform: translateX(6px); }
|
||||||
|
75% { transform: translateX(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDownAlert {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Form Alert ===== */
|
/* ===== Form Alert ===== */
|
||||||
.form-alert {
|
.form-alert {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -318,6 +376,7 @@ body {
|
|||||||
background: rgba(248, 113, 113, 0.1);
|
background: rgba(248, 113, 113, 0.1);
|
||||||
border: 1px solid rgba(248, 113, 113, 0.2);
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
|
animation: slideDownAlert 0.3s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-alert.success {
|
.form-alert.success {
|
||||||
@@ -325,19 +384,23 @@ body {
|
|||||||
background: rgba(52, 211, 153, 0.1);
|
background: rgba(52, 211, 153, 0.1);
|
||||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
|
animation: slideDownAlert 0.3s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Submit Button ===== */
|
/* ===== Submit Button ===== */
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.85rem;
|
padding: 0.95rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -347,22 +410,55 @@ body {
|
|||||||
transform var(--transition),
|
transform var(--transition),
|
||||||
box-shadow var(--transition),
|
box-shadow var(--transition),
|
||||||
opacity var(--transition);
|
opacity var(--transition);
|
||||||
box-shadow: 0 4px 16px var(--accent-glow);
|
box-shadow: 0 4px 15px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: linear-gradient(rgba(255,255,255,0.2), transparent);
|
||||||
|
border-radius: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:hover {
|
.btn-submit:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 24px var(--accent-glow);
|
box-shadow: 0 8px 25px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover::before {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:active {
|
.btn-submit:active {
|
||||||
transform: translateY(0);
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:disabled {
|
.btn-submit:disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Ripple Effect ===== */
|
||||||
|
.ripple {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: scale(0);
|
||||||
|
animation: ripple 0.6s linear;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
to {
|
||||||
|
transform: scale(4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Spinner ===== */
|
/* ===== Spinner ===== */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Панель преподавателя</title>
|
<title>Панель преподавателя</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -14,59 +14,186 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0f0f1a;
|
/* Deep dark premium background */
|
||||||
--text-primary: #f0f0f5;
|
--bg-primary: #0a0a0f;
|
||||||
--text-secondary: #9ca3af;
|
--bg-card: rgba(255, 255, 255, 0.03);
|
||||||
--accent-hover: #818cf8;
|
--bg-card-border: rgba(255, 255, 255, 0.05);
|
||||||
--bg-card: rgba(255, 255, 255, 0.05);
|
/* Typography */
|
||||||
--bg-card-border: rgba(255, 255, 255, 0.08);
|
--text-primary: #f8fafc;
|
||||||
--bg-input: rgba(255, 255, 255, 0.06);
|
--text-secondary: #94a3b8;
|
||||||
--accent-glow: rgba(99, 102, 241, 0.35);
|
/* Vibrant Accents */
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-hover: #a78bfa;
|
||||||
|
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||||
|
--accent-secondary: #ec4899;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #e8eaef;
|
--bg-primary: #f8fafc;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.7);
|
||||||
|
--bg-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #374151;
|
--text-secondary: #475569;
|
||||||
|
--accent: #6366f1;
|
||||||
--accent-hover: #4f46e5;
|
--accent-hover: #4f46e5;
|
||||||
--bg-card: rgba(255, 255, 255, 0.95);
|
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||||
--bg-card-border: rgba(0, 0, 0, 0.22);
|
--accent-secondary: #d946ef;
|
||||||
--bg-input: rgba(0, 0, 0, 0.08);
|
|
||||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
transition: background 0.4s ease, color 0.4s ease;
|
transition: background 0.4s ease, color 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
/* ===== Animated Background ===== */
|
||||||
|
.background {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(90px);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: float 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .shape {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-1 {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: radial-gradient(circle, var(--accent), transparent 60%);
|
||||||
|
top: -20%;
|
||||||
|
left: -10%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: radial-gradient(circle, var(--accent-secondary), transparent 60%);
|
||||||
|
bottom: -20%;
|
||||||
|
right: -10%;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(-30px, 30px) scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(32px);
|
||||||
|
-webkit-backdrop-filter: blur(32px);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 4rem 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
animation: fadeInScale 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) both;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder h1 {
|
[data-theme="light"] .placeholder-card {
|
||||||
font-size: 1.5rem;
|
box-shadow:
|
||||||
margin-bottom: 0.5rem;
|
0 20px 40px rgba(0, 0, 0, 0.05),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder p {
|
.placeholder-card::before {
|
||||||
color: var(--text-secondary);
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-card .icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
|
||||||
|
color: var(--text-primary);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 0 30px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder a {
|
.placeholder-card h1 {
|
||||||
color: var(--accent-hover);
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder a:hover {
|
.btn-logout:hover {
|
||||||
text-decoration: underline;
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme Toggle */
|
/* Theme Toggle */
|
||||||
@@ -111,10 +238,23 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="placeholder">
|
<div class="background">
|
||||||
|
<div class="shape shape-1"></div>
|
||||||
|
<div class="shape shape-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="placeholder-card">
|
||||||
|
<div class="icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||||
|
<path d="M6 12v5c3 3 9 3 12 0v-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h1>Панель преподавателя</h1>
|
<h1>Панель преподавателя</h1>
|
||||||
<p>Раздел в разработке</p>
|
<p>Раздел в разработке.<br>Ожидайте обновлений!</p>
|
||||||
<a href="/" onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
<a href="/" class="btn-logout"
|
||||||
|
onclick="localStorage.removeItem('token'); localStorage.removeItem('role')">Выйти</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/theme-toggle.js"></script>
|
<script src="/theme-toggle.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user