сделал настройку временных слотов
This commit is contained in:
@@ -47,6 +47,8 @@ public class ClassroomController {
|
|||||||
Classroom classroom = new Classroom();
|
Classroom classroom = new Classroom();
|
||||||
classroom.setName(request.getName().trim());
|
classroom.setName(request.getName().trim());
|
||||||
classroom.setCapacity(request.getCapacity());
|
classroom.setCapacity(request.getCapacity());
|
||||||
|
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||||
|
classroom.setFloor(request.getFloor());
|
||||||
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
||||||
|
|
||||||
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
||||||
@@ -80,6 +82,14 @@ public class ClassroomController {
|
|||||||
classroom.setCapacity(request.getCapacity());
|
classroom.setCapacity(request.getCapacity());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.getBuilding() != null) {
|
||||||
|
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getFloor() != null) {
|
||||||
|
classroom.setFloor(request.getFloor());
|
||||||
|
}
|
||||||
|
|
||||||
if (request.getIsAvailable() != null) {
|
if (request.getIsAvailable() != null) {
|
||||||
classroom.setIsAvailable(request.getIsAvailable());
|
classroom.setIsAvailable(request.getIsAvailable());
|
||||||
}
|
}
|
||||||
@@ -103,7 +113,11 @@ public class ClassroomController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ClassroomResponse mapToResponse(Classroom c) {
|
private ClassroomResponse mapToResponse(Classroom c) {
|
||||||
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
|
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
|
||||||
new java.util.ArrayList<>(c.getEquipments()));
|
new java.util.ArrayList<>(c.getEquipments()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String cleanText(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import java.util.*;
|
|||||||
@RequestMapping("/api/admin/schedule-rules")
|
@RequestMapping("/api/admin/schedule-rules")
|
||||||
public class ScheduleRuleAdminController {
|
public class ScheduleRuleAdminController {
|
||||||
|
|
||||||
|
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||||
|
|
||||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||||
private final SubjectRepository subjectRepository;
|
private final SubjectRepository subjectRepository;
|
||||||
private final SemesterRepository semesterRepository;
|
private final SemesterRepository semesterRepository;
|
||||||
@@ -169,6 +171,10 @@ public class ScheduleRuleAdminController {
|
|||||||
}
|
}
|
||||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||||
|
if (timeSlot.getTimeSlotScope() == null
|
||||||
|
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
|
||||||
|
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
|
||||||
|
}
|
||||||
User teacher = userRepository.findById(slotDto.teacherId())
|
User teacher = userRepository.findById(slotDto.teacherId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||||
|
|||||||
@@ -1,40 +1,159 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
|
||||||
import com.magistr.app.dto.TimeSlotDto;
|
import com.magistr.app.dto.TimeSlotDto;
|
||||||
|
import com.magistr.app.dto.TimeSlotScopeDto;
|
||||||
import com.magistr.app.model.TimeSlot;
|
import com.magistr.app.model.TimeSlot;
|
||||||
|
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||||
|
import com.magistr.app.model.TimeSlotScope;
|
||||||
|
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||||
import com.magistr.app.repository.TimeSlotRepository;
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/time-slots")
|
@RequestMapping("/api/admin/time-slots")
|
||||||
public class TimeSlotAdminController {
|
public class TimeSlotAdminController {
|
||||||
|
|
||||||
private final TimeSlotRepository timeSlotRepository;
|
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||||
|
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||||
|
private static final String APPLY_MODE_MANUAL = "MANUAL";
|
||||||
|
|
||||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) {
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
|
|
||||||
|
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||||
|
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||||
|
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||||
this.timeSlotRepository = timeSlotRepository;
|
this.timeSlotRepository = timeSlotRepository;
|
||||||
|
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||||
|
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TimeSlotDto> getAll() {
|
public List<TimeSlotDto> getAll() {
|
||||||
return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream()
|
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
|
||||||
.map(this::toDto)
|
.map(this::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/effective")
|
||||||
|
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
|
||||||
|
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/scopes")
|
||||||
|
public List<TimeSlotScopeDto> getScopes() {
|
||||||
|
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
|
||||||
|
.map(this::toScopeDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/scopes")
|
||||||
|
public ResponseEntity<?> createScope(@RequestBody TimeSlotScopeDto request) {
|
||||||
|
if (request.name() == null || request.name().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSlotScope scope = new TimeSlotScope();
|
||||||
|
scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
scope.setName(request.name().trim());
|
||||||
|
scope.setApplyMode(APPLY_MODE_MANUAL);
|
||||||
|
scope.setDayOfWeek(null);
|
||||||
|
scope.setSystemScope(false);
|
||||||
|
scope.setDisplayOrder(nextManualDisplayOrder());
|
||||||
|
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/scopes/{id}")
|
||||||
|
public ResponseEntity<?> updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) {
|
||||||
|
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
|
||||||
|
if (scope == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(scope.getSystemScope())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять"));
|
||||||
|
}
|
||||||
|
if (request.name() == null || request.name().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
|
||||||
|
}
|
||||||
|
scope.setName(request.name().trim());
|
||||||
|
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/scopes/{id}")
|
||||||
|
public ResponseEntity<?> deleteScope(@PathVariable Long id) {
|
||||||
|
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
|
||||||
|
if (scope == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(scope.getSystemScope())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить"));
|
||||||
|
}
|
||||||
|
timeSlotScopeRepository.delete(scope);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Сетка времени удалена"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/date-assignments")
|
||||||
|
public List<TimeSlotDateAssignmentDto> getDateAssignments(@RequestParam(required = false) LocalDate startDate,
|
||||||
|
@RequestParam(required = false) LocalDate endDate) {
|
||||||
|
List<TimeSlotDateAssignment> assignments = startDate != null && endDate != null
|
||||||
|
? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate)
|
||||||
|
: dateAssignmentRepository.findAllByOrderByDateAsc();
|
||||||
|
return assignments.stream().map(this::toAssignmentDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/date-assignments")
|
||||||
|
public ResponseEntity<?> saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) {
|
||||||
|
if (request.date() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна"));
|
||||||
|
}
|
||||||
|
if (request.scopeId() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна"));
|
||||||
|
}
|
||||||
|
TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null);
|
||||||
|
if (scope == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена"));
|
||||||
|
}
|
||||||
|
if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date())
|
||||||
|
.orElseGet(TimeSlotDateAssignment::new);
|
||||||
|
assignment.setDate(request.date());
|
||||||
|
assignment.setTimeSlotScope(scope);
|
||||||
|
return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/date-assignments/{id}")
|
||||||
|
public ResponseEntity<?> deleteDateAssignment(@PathVariable Long id) {
|
||||||
|
if (!dateAssignmentRepository.existsById(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
dateAssignmentRepository.deleteById(id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено"));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||||
String validationError = validate(request);
|
String validationError = validate(request);
|
||||||
if (validationError != null) {
|
if (validationError != null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||||
}
|
}
|
||||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) {
|
if (hasDuplicate(request, null)) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||||
}
|
}
|
||||||
|
|
||||||
TimeSlot timeSlot = new TimeSlot();
|
TimeSlot timeSlot = new TimeSlot();
|
||||||
@@ -53,9 +172,8 @@ public class TimeSlotAdminController {
|
|||||||
if (validationError != null) {
|
if (validationError != null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||||
}
|
}
|
||||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())
|
if (hasDuplicate(request, id)) {
|
||||||
&& !timeSlot.getOrderNumber().equals(request.orderNumber())) {
|
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(timeSlot, request);
|
apply(timeSlot, request);
|
||||||
@@ -67,14 +185,21 @@ public class TimeSlotAdminController {
|
|||||||
if (!timeSlotRepository.existsById(id)) {
|
if (!timeSlotRepository.existsById(id)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
timeSlotRepository.deleteById(id);
|
try {
|
||||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
timeSlotRepository.deleteById(id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validate(TimeSlotDto request) {
|
private String validate(TimeSlotDto request) {
|
||||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||||
return "Номер пары должен быть больше нуля";
|
return "Номер пары должен быть больше нуля";
|
||||||
}
|
}
|
||||||
|
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||||
|
return "Выберите сетку времени";
|
||||||
|
}
|
||||||
if (request.startTime() == null || request.endTime() == null) {
|
if (request.startTime() == null || request.endTime() == null) {
|
||||||
return "Время начала и окончания обязательно";
|
return "Время начала и окончания обязательно";
|
||||||
}
|
}
|
||||||
@@ -86,6 +211,8 @@ public class TimeSlotAdminController {
|
|||||||
|
|
||||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||||
timeSlot.setOrderNumber(request.orderNumber());
|
timeSlot.setOrderNumber(request.orderNumber());
|
||||||
|
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||||
timeSlot.setStartTime(request.startTime());
|
timeSlot.setStartTime(request.startTime());
|
||||||
timeSlot.setEndTime(request.endTime());
|
timeSlot.setEndTime(request.endTime());
|
||||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||||
@@ -94,13 +221,83 @@ public class TimeSlotAdminController {
|
|||||||
timeSlot.setDurationMinutes(duration);
|
timeSlot.setDurationMinutes(duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
|
||||||
|
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
|
||||||
|
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||||
|
TimeSlotScope defaultScope = defaultScope();
|
||||||
|
TimeSlotScope effectiveScope = effectiveScope(date);
|
||||||
|
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||||
|
if (effectiveScope.getId().equals(defaultScope.getId())) {
|
||||||
|
return defaultSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||||
|
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||||
|
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||||
|
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||||
|
return new ArrayList<>(slotsByOrder.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSlotScope effectiveScope(LocalDate date) {
|
||||||
|
return dateAssignmentRepository.findByDate(date)
|
||||||
|
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||||
|
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||||
|
APPLY_MODE_WEEKDAY,
|
||||||
|
date.getDayOfWeek().getValue()))
|
||||||
|
.orElseGet(this::defaultScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSlotScope defaultScope() {
|
||||||
|
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextManualDisplayOrder() {
|
||||||
|
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
|
||||||
|
.map(TimeSlotScope::getDisplayOrder)
|
||||||
|
.filter(value -> value != null)
|
||||||
|
.max(Integer::compareTo)
|
||||||
|
.orElse(100) + 10;
|
||||||
|
}
|
||||||
|
|
||||||
private TimeSlotDto toDto(TimeSlot timeSlot) {
|
private TimeSlotDto toDto(TimeSlot timeSlot) {
|
||||||
|
TimeSlotScope scope = timeSlot.getTimeSlotScope();
|
||||||
return new TimeSlotDto(
|
return new TimeSlotDto(
|
||||||
timeSlot.getId(),
|
timeSlot.getId(),
|
||||||
timeSlot.getOrderNumber(),
|
timeSlot.getOrderNumber(),
|
||||||
|
scope == null ? null : scope.getId(),
|
||||||
|
scope == null ? null : scope.getName(),
|
||||||
|
scope == null ? null : scope.getApplyMode(),
|
||||||
|
scope == null ? null : scope.getDayOfWeek(),
|
||||||
timeSlot.getStartTime(),
|
timeSlot.getStartTime(),
|
||||||
timeSlot.getEndTime(),
|
timeSlot.getEndTime(),
|
||||||
timeSlot.getDurationMinutes()
|
timeSlot.getDurationMinutes()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) {
|
||||||
|
return new TimeSlotScopeDto(
|
||||||
|
scope.getId(),
|
||||||
|
scope.getCode(),
|
||||||
|
scope.getName(),
|
||||||
|
scope.getApplyMode(),
|
||||||
|
scope.getDayOfWeek(),
|
||||||
|
scope.getSystemScope(),
|
||||||
|
scope.getDisplayOrder()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) {
|
||||||
|
TimeSlotScope scope = assignment.getTimeSlotScope();
|
||||||
|
return new TimeSlotDateAssignmentDto(
|
||||||
|
assignment.getId(),
|
||||||
|
assignment.getDate(),
|
||||||
|
scope.getId(),
|
||||||
|
scope.getName()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import java.util.List;
|
|||||||
public class ClassroomRequest {
|
public class ClassroomRequest {
|
||||||
private String name;
|
private String name;
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
private String building;
|
||||||
|
private Integer floor;
|
||||||
private Boolean isAvailable;
|
private Boolean isAvailable;
|
||||||
private List<Long> equipmentIds;
|
private List<Long> equipmentIds;
|
||||||
|
|
||||||
@@ -24,6 +26,22 @@ public class ClassroomRequest {
|
|||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBuilding() {
|
||||||
|
return building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuilding(String building) {
|
||||||
|
this.building = building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFloor() {
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFloor(Integer floor) {
|
||||||
|
this.floor = floor;
|
||||||
|
}
|
||||||
|
|
||||||
public Boolean getIsAvailable() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
return isAvailable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ public class ClassroomResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String name;
|
private String name;
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
private String building;
|
||||||
|
private Integer floor;
|
||||||
private Boolean isAvailable;
|
private Boolean isAvailable;
|
||||||
private List<Equipment> equipments;
|
private List<Equipment> equipments;
|
||||||
|
|
||||||
public ClassroomResponse() {
|
public ClassroomResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
|
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||||
|
Boolean isAvailable, List<Equipment> equipments) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
|
this.building = building;
|
||||||
|
this.floor = floor;
|
||||||
this.isAvailable = isAvailable;
|
this.isAvailable = isAvailable;
|
||||||
this.equipments = equipments;
|
this.equipments = equipments;
|
||||||
}
|
}
|
||||||
@@ -45,6 +50,22 @@ public class ClassroomResponse {
|
|||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBuilding() {
|
||||||
|
return building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuilding(String building) {
|
||||||
|
this.building = building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFloor() {
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFloor(Integer floor) {
|
||||||
|
this.floor = floor;
|
||||||
|
}
|
||||||
|
|
||||||
public Boolean getIsAvailable() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
return isAvailable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
public record TimeSlotDateAssignmentDto(
|
||||||
|
Long id,
|
||||||
|
LocalDate date,
|
||||||
|
Long scopeId,
|
||||||
|
String scopeName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import java.time.LocalTime;
|
|||||||
public record TimeSlotDto(
|
public record TimeSlotDto(
|
||||||
Long id,
|
Long id,
|
||||||
Integer orderNumber,
|
Integer orderNumber,
|
||||||
|
Long scopeId,
|
||||||
|
String scopeName,
|
||||||
|
String scopeApplyMode,
|
||||||
|
Integer scopeDayOfWeek,
|
||||||
LocalTime startTime,
|
LocalTime startTime,
|
||||||
LocalTime endTime,
|
LocalTime endTime,
|
||||||
Integer durationMinutes
|
Integer durationMinutes
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public record TimeSlotScopeDto(
|
||||||
|
Long id,
|
||||||
|
String code,
|
||||||
|
String name,
|
||||||
|
String applyMode,
|
||||||
|
Integer dayOfWeek,
|
||||||
|
Boolean systemScope,
|
||||||
|
Integer displayOrder
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ public class Classroom {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Integer capacity;
|
private Integer capacity;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String building;
|
||||||
|
|
||||||
|
private Integer floor;
|
||||||
|
|
||||||
@Column(name = "is_available", nullable = false)
|
@Column(name = "is_available", nullable = false)
|
||||||
private Boolean isAvailable = true;
|
private Boolean isAvailable = true;
|
||||||
|
|
||||||
@@ -52,6 +57,22 @@ public class Classroom {
|
|||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBuilding() {
|
||||||
|
return building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuilding(String building) {
|
||||||
|
this.building = building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFloor() {
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFloor(Integer floor) {
|
||||||
|
this.floor = floor;
|
||||||
|
}
|
||||||
|
|
||||||
public Boolean getIsAvailable() {
|
public Boolean getIsAvailable() {
|
||||||
return isAvailable;
|
return isAvailable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class TimeSlot {
|
|||||||
@Column(name = "order_number", nullable = false)
|
@Column(name = "order_number", nullable = false)
|
||||||
private Integer orderNumber;
|
private Integer orderNumber;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "time_slot_scope_id", nullable = false)
|
||||||
|
private TimeSlotScope timeSlotScope;
|
||||||
|
|
||||||
@Column(name = "start_time", nullable = false)
|
@Column(name = "start_time", nullable = false)
|
||||||
private LocalTime startTime;
|
private LocalTime startTime;
|
||||||
|
|
||||||
@@ -40,6 +44,14 @@ public class TimeSlot {
|
|||||||
this.orderNumber = orderNumber;
|
this.orderNumber = orderNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TimeSlotScope getTimeSlotScope() {
|
||||||
|
return timeSlotScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeSlotScope(TimeSlotScope timeSlotScope) {
|
||||||
|
this.timeSlotScope = timeSlotScope;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalTime getStartTime() {
|
public LocalTime getStartTime() {
|
||||||
return startTime;
|
return startTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "time_slot_date_assignments")
|
||||||
|
public class TimeSlotDateAssignment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "assignment_date", nullable = false, unique = true)
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "time_slot_scope_id", nullable = false)
|
||||||
|
private TimeSlotScope timeSlotScope;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getDate() {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDate(LocalDate date) {
|
||||||
|
this.date = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSlotScope getTimeSlotScope() {
|
||||||
|
return timeSlotScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeSlotScope(TimeSlotScope timeSlotScope) {
|
||||||
|
this.timeSlotScope = timeSlotScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "time_slot_scopes")
|
||||||
|
public class TimeSlotScope {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "apply_mode", nullable = false, length = 20)
|
||||||
|
private String applyMode;
|
||||||
|
|
||||||
|
@Column(name = "day_of_week")
|
||||||
|
private Integer dayOfWeek;
|
||||||
|
|
||||||
|
@Column(name = "system_scope", nullable = false)
|
||||||
|
private Boolean systemScope = false;
|
||||||
|
|
||||||
|
@Column(name = "display_order", nullable = false)
|
||||||
|
private Integer displayOrder = 100;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApplyMode() {
|
||||||
|
return applyMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApplyMode(String applyMode) {
|
||||||
|
this.applyMode = applyMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDayOfWeek() {
|
||||||
|
return dayOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDayOfWeek(Integer dayOfWeek) {
|
||||||
|
this.dayOfWeek = dayOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getSystemScope() {
|
||||||
|
return systemScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSystemScope(Boolean systemScope) {
|
||||||
|
this.systemScope = systemScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDisplayOrder() {
|
||||||
|
return displayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisplayOrder(Integer displayOrder) {
|
||||||
|
this.displayOrder = displayOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlotDateAssignment, Long> {
|
||||||
|
|
||||||
|
Optional<TimeSlotDateAssignment> findByDate(LocalDate date);
|
||||||
|
|
||||||
|
List<TimeSlotDateAssignment> findAllByOrderByDateAsc();
|
||||||
|
|
||||||
|
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
|
||||||
|
|
||||||
|
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||||
|
}
|
||||||
@@ -4,10 +4,15 @@ import com.magistr.app.model.TimeSlot;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
||||||
|
|
||||||
List<TimeSlot> findAllByOrderByOrderNumberAsc();
|
List<TimeSlot> findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc();
|
||||||
|
|
||||||
boolean existsByOrderNumber(Integer orderNumber);
|
List<TimeSlot> findAllByTimeSlotScopeIdOrderByOrderNumberAsc(Long scopeId);
|
||||||
|
|
||||||
|
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
||||||
|
|
||||||
|
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.TimeSlotScope;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Long> {
|
||||||
|
|
||||||
|
List<TimeSlotScope> findAllByOrderByDisplayOrderAscNameAsc();
|
||||||
|
|
||||||
|
Optional<TimeSlotScope> findByCode(String code);
|
||||||
|
|
||||||
|
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||||
|
|
||||||
|
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import com.magistr.app.dto.RenderedLessonDto;
|
|||||||
import com.magistr.app.model.*;
|
import com.magistr.app.model.*;
|
||||||
import com.magistr.app.repository.GroupRepository;
|
import com.magistr.app.repository.GroupRepository;
|
||||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -22,21 +25,31 @@ public class ScheduleGeneratorService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
||||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||||
private static final long MAX_RANGE_DAYS = 120;
|
private static final long MAX_RANGE_DAYS = 120;
|
||||||
|
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||||
|
|
||||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AcademicDateService academicDateService;
|
private final AcademicDateService academicDateService;
|
||||||
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
GroupRepository groupRepository,
|
GroupRepository groupRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
AcademicDateService academicDateService) {
|
AcademicDateService academicDateService,
|
||||||
|
TimeSlotRepository timeSlotRepository,
|
||||||
|
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||||
|
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.academicDateService = academicDateService;
|
this.academicDateService = academicDateService;
|
||||||
|
this.timeSlotRepository = timeSlotRepository;
|
||||||
|
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||||
|
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||||
@@ -247,7 +260,7 @@ public class ScheduleGeneratorService {
|
|||||||
List<StudentGroup> groups,
|
List<StudentGroup> groups,
|
||||||
int consumedBeforeLesson,
|
int consumedBeforeLesson,
|
||||||
int remainingAfterLesson) {
|
int remainingAfterLesson) {
|
||||||
TimeSlot timeSlot = slot.getTimeSlot();
|
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||||
List<StudentGroup> sortedGroups = groups.stream()
|
List<StudentGroup> sortedGroups = groups.stream()
|
||||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -283,6 +296,25 @@ public class ScheduleGeneratorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
||||||
|
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
||||||
|
return baseSlot;
|
||||||
|
}
|
||||||
|
return effectiveScope(date)
|
||||||
|
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
|
||||||
|
scope.getId(),
|
||||||
|
baseSlot.getOrderNumber()))
|
||||||
|
.orElse(baseSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
|
||||||
|
return dateAssignmentRepository.findByDate(date)
|
||||||
|
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||||
|
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||||
|
APPLY_MODE_WEEKDAY,
|
||||||
|
date.getDayOfWeek().getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
public static String dayName(DayOfWeek dayOfWeek) {
|
public static String dayName(DayOfWeek dayOfWeek) {
|
||||||
String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
|
String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
|
||||||
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1);
|
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1);
|
||||||
|
|||||||
@@ -236,20 +236,55 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types (
|
|||||||
-- Динамическое расписание: календарь, слоты и правила
|
-- Динамическое расписание: календарь, слоты и правила
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_slot_scopes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
apply_mode VARCHAR(20) NOT NULL,
|
||||||
|
day_of_week INT NULL,
|
||||||
|
system_scope BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
display_order INT NOT NULL DEFAULT 100,
|
||||||
|
CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')),
|
||||||
|
CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7),
|
||||||
|
CONSTRAINT chk_time_slot_scopes_weekday CHECK (
|
||||||
|
(apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL)
|
||||||
|
OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default
|
||||||
|
ON time_slot_scopes(apply_mode)
|
||||||
|
WHERE apply_mode = 'DEFAULT';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
||||||
|
ON time_slot_scopes(day_of_week)
|
||||||
|
WHERE apply_mode = 'WEEKDAY';
|
||||||
|
|
||||||
|
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
||||||
|
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
||||||
|
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS time_slots (
|
CREATE TABLE IF NOT EXISTS time_slots (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||||
order_number INT NOT NULL,
|
order_number INT NOT NULL,
|
||||||
start_time TIME NOT NULL,
|
start_time TIME NOT NULL,
|
||||||
end_time TIME NOT NULL,
|
end_time TIME NOT NULL,
|
||||||
duration_minutes INT NOT NULL,
|
duration_minutes INT NOT NULL,
|
||||||
CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
|
CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
|
||||||
CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
|
CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
|
||||||
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0),
|
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0)
|
||||||
CONSTRAINT uq_time_slots_order UNIQUE (order_number),
|
|
||||||
CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
||||||
|
ON time_slots(time_slot_scope_id, order_number);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||||||
|
|
||||||
|
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
||||||
|
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
||||||
|
FROM (VALUES
|
||||||
(1, '08:00', '09:30', 90),
|
(1, '08:00', '09:30', 90),
|
||||||
(2, '09:40', '11:10', 90),
|
(2, '09:40', '11:10', 90),
|
||||||
(3, '11:40', '13:10', 90),
|
(3, '11:40', '13:10', 90),
|
||||||
@@ -257,7 +292,19 @@ INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VA
|
|||||||
(5, '15:00', '16:30', 90),
|
(5, '15:00', '16:30', 90),
|
||||||
(6, '16:50', '18:20', 90),
|
(6, '16:50', '18:20', 90),
|
||||||
(7, '18:30', '20:00', 90)
|
(7, '18:30', '20:00', 90)
|
||||||
ON CONFLICT (start_time, end_time) DO NOTHING;
|
) AS data(order_number, start_time, end_time, duration_minutes)
|
||||||
|
CROSS JOIN time_slot_scopes scope
|
||||||
|
WHERE scope.code IN ('default', 'saturday')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
assignment_date DATE NOT NULL UNIQUE,
|
||||||
|
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope
|
||||||
|
ON time_slot_date_assignments(time_slot_scope_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_years (
|
CREATE TABLE IF NOT EXISTS academic_years (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -522,7 +569,8 @@ FROM (VALUES
|
|||||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
||||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
||||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||||||
JOIN time_slots ts ON ts.order_number = data.time_slot_order
|
JOIN time_slot_scopes scope ON scope.code = 'default'
|
||||||
|
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
||||||
JOIN users teacher ON teacher.username = data.teacher_username
|
JOIN users teacher ON teacher.username = data.teacher_username
|
||||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
||||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
||||||
@@ -555,7 +603,9 @@ COMMENT ON TABLE users IS 'Пользователи системы (студен
|
|||||||
COMMENT ON TABLE departments IS 'Кафедры';
|
COMMENT ON TABLE departments IS 'Кафедры';
|
||||||
COMMENT ON TABLE specialties IS 'Специальности';
|
COMMENT ON TABLE specialties IS 'Специальности';
|
||||||
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
|
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
|
||||||
|
COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки';
|
||||||
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
|
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
|
||||||
|
COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату';
|
||||||
COMMENT ON TABLE academic_years IS 'Учебные годы';
|
COMMENT ON TABLE academic_years IS 'Учебные годы';
|
||||||
COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
||||||
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
||||||
|
|||||||
38
docs/API.md
38
docs/API.md
@@ -116,7 +116,7 @@
|
|||||||
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
|
||||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||||
|
|
||||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком.
|
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`.
|
||||||
|
|
||||||
**Пример:**
|
**Пример:**
|
||||||
```http
|
```http
|
||||||
@@ -160,25 +160,51 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
|
|
||||||
### `GET /api/admin/time-slots`
|
### `GET /api/admin/time-slots`
|
||||||
|
|
||||||
Список временных слотов занятий. CRUD доступен по:
|
Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.
|
||||||
|
|
||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/time-slots` | Список слотов |
|
| `GET` | `/api/admin/time-slots` | Список слотов |
|
||||||
|
| `GET` | `/api/admin/time-slots/effective?date=2026-05-02` | Эффективные слоты для даты |
|
||||||
| `POST` | `/api/admin/time-slots` | Создать слот |
|
| `POST` | `/api/admin/time-slots` | Создать слот |
|
||||||
| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот |
|
| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот |
|
||||||
| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот |
|
| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот |
|
||||||
|
| `GET` | `/api/admin/time-slots/scopes` | Список сеток времени |
|
||||||
|
| `POST` | `/api/admin/time-slots/scopes` | Создать ручную сетку |
|
||||||
|
| `PUT` | `/api/admin/time-slots/scopes/{id}` | Переименовать ручную сетку |
|
||||||
|
| `DELETE` | `/api/admin/time-slots/scopes/{id}` | Удалить ручную сетку |
|
||||||
|
| `GET` | `/api/admin/time-slots/date-assignments` | Ручные назначения дат |
|
||||||
|
| `POST` | `/api/admin/time-slots/date-assignments` | Применить ручную сетку к дате |
|
||||||
|
| `DELETE` | `/api/admin/time-slots/date-assignments/{id}` | Убрать ручное назначение |
|
||||||
|
|
||||||
**Тело создания/обновления:**
|
**Тело создания/обновления:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"orderNumber": 1,
|
"orderNumber": 1,
|
||||||
|
"scopeId": 1,
|
||||||
"startTime": "08:00:00",
|
"startTime": "08:00:00",
|
||||||
"endTime": "09:30:00",
|
"endTime": "09:30:00",
|
||||||
"durationMinutes": 90
|
"durationMinutes": 90
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Создание ручной сетки:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Праздничная сетка"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ручное применение сетки к дате:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-05-08",
|
||||||
|
"scopeId": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
|
||||||
|
|
||||||
### Учебные годы, семестры и коды календарного графика
|
### Учебные годы, семестры и коды календарного графика
|
||||||
|
|
||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
@@ -281,6 +307,8 @@ CRUD доступен по:
|
|||||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
|
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
|
||||||
|
|
||||||
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
### `GET /api/lesson-types`
|
### `GET /api/lesson-types`
|
||||||
|
|
||||||
Справочник типов занятий для конструктора правил расписания.
|
Справочник типов занятий для конструктора правил расписания.
|
||||||
@@ -436,7 +464,7 @@ CRUD доступен по:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа.
|
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -453,6 +481,8 @@ CRUD доступен по:
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "101 Ленинская",
|
"name": "101 Ленинская",
|
||||||
"capacity": 120,
|
"capacity": 120,
|
||||||
|
"building": "Главный корпус",
|
||||||
|
"floor": 2,
|
||||||
"isAvailable": true,
|
"isAvailable": true,
|
||||||
"equipments": [
|
"equipments": [
|
||||||
{ "id": 1, "name": "Проектор" },
|
{ "id": 1, "name": "Проектор" },
|
||||||
@@ -470,6 +500,8 @@ CRUD доступен по:
|
|||||||
{
|
{
|
||||||
"name": "404 Лаборатория",
|
"name": "404 Лаборатория",
|
||||||
"capacity": 30,
|
"capacity": 30,
|
||||||
|
"building": "Лабораторный корпус",
|
||||||
|
"floor": 4,
|
||||||
"isAvailable": true,
|
"isAvailable": true,
|
||||||
"equipmentIds": [1, 2, 3]
|
"equipmentIds": [1, 2, 3]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,29 +94,34 @@
|
|||||||
5. Пропускает день, если код активности не разрешает обычные пары.
|
5. Пропускает день, если код активности не разрешает обычные пары.
|
||||||
6. Загружает правила группы или преподавателя.
|
6. Загружает правила группы или преподавателя.
|
||||||
7. Симулирует уже проведённые занятия от `active_from_date`.
|
7. Симулирует уже проведённые занятия от `active_from_date`.
|
||||||
8. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||||
|
9. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
||||||
|
|
||||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||||
|
|
||||||
### Временны́е слоты
|
### Временные слоты
|
||||||
|
|
||||||
Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты:
|
Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты.
|
||||||
|
|
||||||
|
Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание.
|
||||||
|
|
||||||
|
При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки:
|
||||||
|
|
||||||
| № | Время |
|
| № | Время |
|
||||||
|---|-------|
|
|---|-------|
|
||||||
| 1 | 08:00 – 09:30 |
|
| 1 | 08:00 – 09:30 |
|
||||||
| 2 | 09:40 – 11:10 |
|
| 2 | 09:40 – 11:10 |
|
||||||
| 3 | 11:40 – 13:10 |
|
| 3 | 11:40 – 13:10 |
|
||||||
| 4 | 13:30 – 15:00 |
|
| 4 | 13:20 – 14:50 |
|
||||||
| 5 | 15:00 – 16:30 |
|
| 5 | 15:00 – 16:30 |
|
||||||
| 6 | 16:40 – 18:10 |
|
| 6 | 16:50 – 18:20 |
|
||||||
| 7 | 18:30 – 20:00 |
|
| 7 | 18:30 – 20:00 |
|
||||||
|
|
||||||
### Валидация правил расписания
|
### Валидация правил расписания
|
||||||
|
|
||||||
- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа.
|
- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа.
|
||||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||||
- **Связанные сущности:** временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ erDiagram
|
|||||||
VARCHAR name
|
VARCHAR name
|
||||||
BIGINT code UK
|
BIGINT code UK
|
||||||
}
|
}
|
||||||
|
|
||||||
specialties {
|
specialties {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
VARCHAR name
|
VARCHAR name
|
||||||
@@ -125,14 +125,31 @@ erDiagram
|
|||||||
BIGINT lesson_type_id FK,PK
|
BIGINT lesson_type_id FK,PK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
time_slot_scopes {
|
||||||
|
BIGSERIAL id PK
|
||||||
|
VARCHAR code UK
|
||||||
|
VARCHAR name
|
||||||
|
VARCHAR apply_mode
|
||||||
|
INT day_of_week
|
||||||
|
BOOLEAN system_scope
|
||||||
|
INT display_order
|
||||||
|
}
|
||||||
|
|
||||||
time_slots {
|
time_slots {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
INT order_number UK
|
BIGINT time_slot_scope_id FK
|
||||||
|
INT order_number
|
||||||
TIME start_time
|
TIME start_time
|
||||||
TIME end_time
|
TIME end_time
|
||||||
INT duration_minutes
|
INT duration_minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
time_slot_date_assignments {
|
||||||
|
BIGSERIAL id PK
|
||||||
|
DATE assignment_date UK
|
||||||
|
BIGINT time_slot_scope_id FK
|
||||||
|
}
|
||||||
|
|
||||||
academic_years {
|
academic_years {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
VARCHAR title UK
|
VARCHAR title UK
|
||||||
@@ -244,6 +261,8 @@ erDiagram
|
|||||||
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
||||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||||
|
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
|
||||||
|
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
||||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||||
subgroups ||--o{ schedule_rule_slots : "subgroup_id"
|
subgroups ||--o{ schedule_rule_slots : "subgroup_id"
|
||||||
users ||--o{ schedule_rule_slots : "teacher_id"
|
users ||--o{ schedule_rule_slots : "teacher_id"
|
||||||
@@ -390,15 +409,38 @@ erDiagram
|
|||||||
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
|
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
|
||||||
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
|
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
|
||||||
|
|
||||||
|
#### `time_slot_scopes` — Сетки времени
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `id` | BIGSERIAL PK | ID |
|
||||||
|
| `code` | VARCHAR(50) UNIQUE | Системный код сетки |
|
||||||
|
| `name` | VARCHAR(120) | Название в интерфейсе |
|
||||||
|
| `apply_mode` | VARCHAR(20) | `DEFAULT`, `WEEKDAY`, `MANUAL` |
|
||||||
|
| `day_of_week` | INT NULL | День недели для автоматической сетки |
|
||||||
|
| `system_scope` | BOOLEAN | Защищает базовую и субботнюю сетки от удаления |
|
||||||
|
| `display_order` | INT | Порядок в списках |
|
||||||
|
|
||||||
|
Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботняя сетка` (`WEEKDAY`, `day_of_week = 6`). Дополнительные сетки создаются как `MANUAL` и применяются только через ручные назначения дат.
|
||||||
|
|
||||||
#### `time_slots` — Временные слоты занятий
|
#### `time_slots` — Временные слоты занятий
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
| `id` | BIGSERIAL PK | ID |
|
| `id` | BIGSERIAL PK | ID |
|
||||||
| `order_number` | INT UNIQUE | Номер пары в дне |
|
| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Сетка времени |
|
||||||
|
| `order_number` | INT | Номер пары в дне |
|
||||||
| `start_time` | TIME | Время начала |
|
| `start_time` | TIME | Время начала |
|
||||||
| `end_time` | TIME | Время окончания |
|
| `end_time` | TIME | Время окончания |
|
||||||
| `duration_minutes` | INT | Длительность в минутах |
|
| `duration_minutes` | INT | Длительность в минутах |
|
||||||
|
|
||||||
|
Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может быть только один слот с номером пары.
|
||||||
|
|
||||||
|
#### `time_slot_date_assignments` — Ручные назначения сеток времени
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `id` | BIGSERIAL PK | ID |
|
||||||
|
| `assignment_date` | DATE UNIQUE | Дата ручного применения |
|
||||||
|
| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Ручная сетка времени |
|
||||||
|
|
||||||
#### `academic_years` — Учебные годы
|
#### `academic_years` — Учебные годы
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
@@ -481,7 +523,7 @@ erDiagram
|
|||||||
| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило |
|
| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило |
|
||||||
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
||||||
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
||||||
| `time_slot_id` | BIGINT FK → time_slots | Временной слот |
|
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
|
||||||
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа |
|
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа |
|
||||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ frontend/
|
|||||||
│ │ ├── equipments.js # Управление оборудованием
|
│ │ ├── equipments.js # Управление оборудованием
|
||||||
│ │ ├── edu-forms.js # Формы обучения
|
│ │ ├── edu-forms.js # Формы обучения
|
||||||
│ │ ├── profiles.js # Профили обучения специальностей
|
│ │ ├── profiles.js # Профили обучения специальностей
|
||||||
│ │ ├── schedule.js # Конструктор правил расписания и сетки пар
|
│ │ ├── schedule.js # Конструктор правил расписания
|
||||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||||
│ │ ├── database.js # Управление тенантами
|
│ │ ├── database.js # Управление тенантами
|
||||||
│ │ └── departments-data.js # Создание кафедры/специальности
|
│ │ └── departments-data.js # Создание кафедры/специальности
|
||||||
@@ -66,9 +66,12 @@ frontend/
|
|||||||
│ │ ├── main.css # CSS-переменные, базовые стили
|
│ │ ├── main.css # CSS-переменные, базовые стили
|
||||||
│ │ └── layout.css # Sidebar, topbar, content
|
│ │ └── layout.css # Sidebar, topbar, content
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ └── main.js # Навигация по вкладкам настроек
|
│ │ ├── main.js # Навигация по вкладкам настроек
|
||||||
|
│ │ └── views/
|
||||||
|
│ │ └── time-slots.js # Настройка временных слотов
|
||||||
│ └── views/
|
│ └── views/
|
||||||
│ └── general.html # Общие настройки (заглушка)
|
│ ├── general.html # Общие настройки (заглушка)
|
||||||
|
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
|
||||||
│
|
│
|
||||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||||
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
||||||
@@ -109,18 +112,18 @@ frontend/
|
|||||||
| `equipments` | Оборудование | `/api/equipments` |
|
| `equipments` | Оборудование | `/api/equipments` |
|
||||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||||
| `subjects` | Дисциплины | `/api/subjects` |
|
| `subjects` | Дисциплины | `/api/subjects` |
|
||||||
| `schedule` | Конструктор правил динамического расписания и сетка пар | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` |
|
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` |
|
||||||
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
|
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
|
||||||
| `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные |
|
| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` |
|
||||||
| `database` | Тенанты | `/api/database` |
|
| `database` | Тенанты | `/api/database` |
|
||||||
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||||
- Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты.
|
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила.
|
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила.
|
||||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
||||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||||
@@ -133,6 +136,7 @@ frontend/
|
|||||||
- Кнопка «Назад в панель» для возврата в `/admin/`
|
- Кнопка «Назад в панель» для возврата в `/admin/`
|
||||||
- Текущие вкладки:
|
- Текущие вкладки:
|
||||||
- **Общие настройки** — заглушка (в разработке)
|
- **Общие настройки** — заглушка (в разработке)
|
||||||
|
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
height: 80px;
|
height: 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workload-table th {
|
.workload-table th {
|
||||||
@@ -37,29 +37,85 @@
|
|||||||
box-shadow: 0 1px 0 var(--bg-card-border);
|
box-shadow: 0 1px 0 var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workload-table th strong,
|
||||||
|
.workload-table th span,
|
||||||
|
.workload-table th small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table th strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table th span {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table th small {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0.35rem auto 0;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(139, 92, 246, 0.16);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.workload-table .time-cell,
|
.workload-table .time-cell,
|
||||||
.workload-table .axis-cell {
|
.workload-table .axis-cell {
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
width: 120px;
|
width: 140px;
|
||||||
min-width: 120px;
|
min-width: 140px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
box-shadow: 1px 0 0 var(--bg-card-border);
|
box-shadow: 1px 0 0 var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workload-table .axis-cell strong,
|
||||||
|
.workload-table .axis-cell span,
|
||||||
|
.workload-table .axis-cell small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table .axis-cell strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table .axis-cell span,
|
||||||
|
.workload-table .axis-cell small {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-table .axis-cell.room-unavailable {
|
||||||
|
color: var(--error);
|
||||||
|
background: color-mix(in srgb, var(--error) 8%, var(--bg-input));
|
||||||
|
}
|
||||||
|
|
||||||
.workload-table .top-left-cell {
|
.workload-table .top-left-cell {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
min-width: 120px;
|
min-width: 140px;
|
||||||
width: 120px;
|
width: 140px;
|
||||||
box-shadow: 1px 1px 0 var(--bg-card-border);
|
box-shadow: 1px 1px 0 var(--bg-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +152,36 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workload-summary {
|
||||||
|
margin: -0.75rem 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-filter-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-busy-cell {
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-free-cell {
|
||||||
|
background: color-mix(in srgb, var(--success) 4%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.free-slot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Карточки занятий внутри ячеек сетки */
|
/* Карточки занятий внутри ячеек сетки */
|
||||||
.lesson-card {
|
.lesson-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -138,6 +224,13 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-meta {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
/* Настройка scrollbar для контейнера сетки */
|
/* Настройка scrollbar для контейнера сетки */
|
||||||
.workload-grid-container::-webkit-scrollbar {
|
.workload-grid-container::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { api } from '../api.js';
|
|||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 1, label: 'Понедельник' },
|
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||||
{ value: 2, label: 'Вторник' },
|
{ value: 2, label: 'Вторник', shortLabel: 'Вт' },
|
||||||
{ value: 3, label: 'Среда' },
|
{ value: 3, label: 'Среда', shortLabel: 'Ср' },
|
||||||
{ value: 4, label: 'Четверг' },
|
{ value: 4, label: 'Четверг', shortLabel: 'Чт' },
|
||||||
{ value: 5, label: 'Пятница' },
|
{ value: 5, label: 'Пятница', shortLabel: 'Пт' },
|
||||||
{ value: 6, label: 'Суббота' },
|
{ value: 6, label: 'Суббота', shortLabel: 'Сб' },
|
||||||
{ value: 7, label: 'Воскресенье' }
|
{ value: 7, label: 'Воскресенье', shortLabel: 'Вс' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const SEMESTER_LABELS = {
|
const SEMESTER_LABELS = {
|
||||||
@@ -548,7 +548,7 @@ export async function initAcademicCalendar() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${DAY_OPTIONS.map(day => `
|
${DAY_OPTIONS.map(day => `
|
||||||
<tr>
|
<tr>
|
||||||
<th class="calendar-day-head">${escapeHtml(day.label.slice(0, 2))}</th>
|
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
|
||||||
${weeks.map(week => {
|
${weeks.map(week => {
|
||||||
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
||||||
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
||||||
@@ -804,8 +804,7 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shortDayLabel(value) {
|
function shortDayLabel(value) {
|
||||||
const label = dayLabel(value);
|
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
|
||||||
return label === '-' ? '-' : label.slice(0, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortDate(value) {
|
function formatShortDate(value) {
|
||||||
|
|||||||
@@ -1,159 +1,417 @@
|
|||||||
import { initMultiSelect } from '../utils.js';
|
import { api } from '../api.js';
|
||||||
|
import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
|
||||||
|
|
||||||
export function initAuditoriumWorkload() {
|
const NO_BUILDING = '__no_building__';
|
||||||
// Дата по умолчанию — текущий день.
|
|
||||||
|
const DAY_SHORT_LABELS = {
|
||||||
|
1: 'Пн',
|
||||||
|
2: 'Вт',
|
||||||
|
3: 'Ср',
|
||||||
|
4: 'Чт',
|
||||||
|
5: 'Пт',
|
||||||
|
6: 'Сб',
|
||||||
|
7: 'Вс'
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPACITY_BUCKETS = [
|
||||||
|
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
|
||||||
|
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
|
||||||
|
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
|
||||||
|
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function initAuditoriumWorkload() {
|
||||||
const dateInput = document.getElementById('workload-date');
|
const dateInput = document.getElementById('workload-date');
|
||||||
if (dateInput) {
|
const refreshButton = document.getElementById('workload-refresh');
|
||||||
|
const headerRow = document.getElementById('workload-header-row');
|
||||||
|
const tbody = document.getElementById('workload-tbody');
|
||||||
|
const summary = document.getElementById('workload-summary');
|
||||||
|
|
||||||
|
let classrooms = [];
|
||||||
|
let timeSlots = [];
|
||||||
|
let equipments = [];
|
||||||
|
let groups = [];
|
||||||
|
let lessonsByRoomSlot = new Map();
|
||||||
|
let failedGroupLoads = 0;
|
||||||
|
|
||||||
|
setDefaultDate();
|
||||||
|
initFilters();
|
||||||
|
bindEvents();
|
||||||
|
await loadInitialData();
|
||||||
|
|
||||||
|
function setDefaultDate() {
|
||||||
|
if (!dateInput || dateInput.value) return;
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yyyy = today.getFullYear();
|
dateInput.value = [
|
||||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
today.getFullYear(),
|
||||||
const dd = String(today.getDate()).padStart(2, '0');
|
String(today.getMonth() + 1).padStart(2, '0'),
|
||||||
dateInput.value = `${yyyy}-${mm}-${dd}`;
|
String(today.getDate()).padStart(2, '0')
|
||||||
|
].join('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация мультиселектов.
|
function initFilters() {
|
||||||
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
||||||
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
|
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
|
||||||
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
||||||
|
}
|
||||||
|
|
||||||
// Заполнение фильтров начальными данными.
|
function bindEvents() {
|
||||||
populateFilters();
|
refreshButton?.addEventListener('click', loadScheduleForSelectedDate);
|
||||||
|
dateInput?.addEventListener('change', loadScheduleForSelectedDate);
|
||||||
|
|
||||||
// Отрисовка mock-данных в нужной ориентации сетки.
|
[
|
||||||
renderMockGrid();
|
['building-checkboxes', 'building-text', 'Все корпуса'],
|
||||||
}
|
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
|
||||||
|
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
|
||||||
|
].forEach(([containerId, textId, emptyText]) => {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
container?.addEventListener('change', () => {
|
||||||
|
updateFilterText(containerId, textId, emptyText);
|
||||||
|
renderGrid();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function populateFilters() {
|
async function loadInitialData() {
|
||||||
// Корпуса.
|
setTableLoading('Загрузка аудиторий и расписания...');
|
||||||
const buildingsContainer = document.getElementById('building-checkboxes');
|
try {
|
||||||
const buildings = [
|
[classrooms, equipments, groups] = await Promise.all([
|
||||||
{ id: 1, name: "Корпус 1 (Главный)" },
|
api.get('/api/classrooms'),
|
||||||
{ id: 2, name: "Корпус 2 (Физ-мат)" },
|
api.get('/api/equipments'),
|
||||||
{ id: 3, name: "Корпус 3 (Гуманитарный)" }
|
api.get('/api/groups')
|
||||||
];
|
]);
|
||||||
buildingsContainer.innerHTML = buildings.map(item => `
|
classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name));
|
||||||
<label class="checkbox-item">
|
populateFilters();
|
||||||
<input type="checkbox" value="${item.id}">
|
await loadScheduleForSelectedDate();
|
||||||
<span class="checkmark"></span>
|
} catch (error) {
|
||||||
<span class="checkbox-label">${item.name}</span>
|
setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий');
|
||||||
</label>
|
}
|
||||||
`).join('');
|
}
|
||||||
|
|
||||||
// Вместимость.
|
async function loadScheduleForSelectedDate() {
|
||||||
const capacityContainer = document.getElementById('capacity-checkboxes');
|
const date = dateInput?.value;
|
||||||
const capacities = [
|
if (!date) {
|
||||||
{ id: 'small', name: "До 30 мест" },
|
showAlert('workload-alert', 'Выберите дату', 'error');
|
||||||
{ id: 'medium', name: "30 - 60 мест" },
|
return;
|
||||||
{ id: 'large', name: "60 - 100 мест" },
|
}
|
||||||
{ id: 'xlarge', name: "Более 100 мест" }
|
|
||||||
];
|
|
||||||
capacityContainer.innerHTML = capacities.map(item => `
|
|
||||||
<label class="checkbox-item">
|
|
||||||
<input type="checkbox" value="${item.id}">
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
<span class="checkbox-label">${item.name}</span>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Оборудование.
|
hideAlert('workload-alert');
|
||||||
const equipmentContainer = document.getElementById('equipment-checkboxes');
|
setTableLoading('Загрузка расписания на выбранную дату...');
|
||||||
const equipmentList = [
|
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
|
||||||
{ id: 1, name: "Проектор" },
|
|
||||||
{ id: 2, name: "Компьютерные места" },
|
|
||||||
{ id: 3, name: "Интерактивная доска" },
|
|
||||||
{ id: 4, name: "Микрофон" }
|
|
||||||
];
|
|
||||||
equipmentContainer.innerHTML = equipmentList.map(item => `
|
|
||||||
<label class="checkbox-item">
|
|
||||||
<input type="checkbox" value="${item.id}">
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
<span class="checkbox-label">${item.name}</span>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMockGrid() {
|
if (!groups.length) {
|
||||||
// В будущем данные будут загружаться из API.
|
lessonsByRoomSlot = new Map();
|
||||||
const timeslots = [
|
renderGrid();
|
||||||
"8:00-9:30",
|
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
|
||||||
"9:40-11:10",
|
return;
|
||||||
"11:40-13:10",
|
}
|
||||||
"13:20-14:50",
|
|
||||||
"15:00-16:30",
|
|
||||||
"16:50-18:20",
|
|
||||||
"18:30-19:50",
|
|
||||||
"20:00-21:20"
|
|
||||||
];
|
|
||||||
|
|
||||||
const auditoriums = [
|
const params = group => new URLSearchParams({
|
||||||
"201", "202", "204", "205", "206", "207", "208"
|
groupId: String(group.id),
|
||||||
];
|
startDate: date,
|
||||||
|
endDate: date
|
||||||
|
}).toString();
|
||||||
|
|
||||||
// Mock-расписание по аудитории и времени.
|
const responses = await Promise.allSettled(
|
||||||
// Ключ: "roomId_timeSlotId", значение: объект занятия
|
groups.map(group => api.get(`/api/schedule?${params(group)}`))
|
||||||
const mockSchedule = {
|
);
|
||||||
"201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." },
|
failedGroupLoads = responses.filter(response => response.status === 'rejected').length;
|
||||||
"201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." },
|
const lessons = responses
|
||||||
"201_11:40-13:10": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
|
.filter(response => response.status === 'fulfilled')
|
||||||
"201_13:20-14:50": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
|
.flatMap(response => response.value || []);
|
||||||
|
|
||||||
"202_9:40-11:10": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." },
|
|
||||||
"202_13:20-14:50": { subject: "Математика", group: "ИВТ-21-1", teacher: "Рутина Л.П." },
|
|
||||||
"202_15:00-16:30": { subject: "Химия", group: "ИВТ-21-1", teacher: "Химоза Я.В." },
|
|
||||||
"202_16:50-18:20": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." },
|
|
||||||
|
|
||||||
"205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." },
|
lessonsByRoomSlot = groupLessons(lessons);
|
||||||
};
|
renderGrid();
|
||||||
|
|
||||||
// В столбцах отображаются временные слоты, в строках — аудитории.
|
if (failedGroupLoads > 0) {
|
||||||
const headerRow = document.getElementById('workload-header-row');
|
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
|
||||||
headerRow.innerHTML = `
|
}
|
||||||
<th class="top-left-cell">
|
}
|
||||||
<span class="top-label">Время</span>
|
|
||||||
<span class="bottom-label">Аудитория</span>
|
|
||||||
</th>
|
|
||||||
`;
|
|
||||||
|
|
||||||
timeslots.forEach(time => {
|
|
||||||
const th = document.createElement('th');
|
|
||||||
th.textContent = time;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Строки таблицы.
|
function populateFilters() {
|
||||||
const tbody = document.getElementById('workload-tbody');
|
renderCheckboxes(
|
||||||
tbody.innerHTML = '';
|
'building-checkboxes',
|
||||||
|
buildingOptions(),
|
||||||
auditoriums.forEach((room) => {
|
item => item.value,
|
||||||
const tr = document.createElement('tr');
|
item => item.label
|
||||||
|
);
|
||||||
// Ячейка аудитории.
|
renderCheckboxes(
|
||||||
const tdRoom = document.createElement('td');
|
'capacity-checkboxes',
|
||||||
tdRoom.className = 'axis-cell';
|
CAPACITY_BUCKETS,
|
||||||
tdRoom.textContent = room;
|
item => item.id,
|
||||||
tr.appendChild(tdRoom);
|
item => item.name
|
||||||
|
);
|
||||||
|
renderCheckboxes(
|
||||||
|
'equipment-checkboxes',
|
||||||
|
equipmentOptions(),
|
||||||
|
item => item.id,
|
||||||
|
item => item.name
|
||||||
|
);
|
||||||
|
|
||||||
// Ячейки временных слотов для аудитории.
|
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
|
||||||
timeslots.forEach(time => {
|
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
|
||||||
const td = document.createElement('td');
|
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
|
||||||
|
}
|
||||||
const scheduleKey = `${room}_${time}`;
|
|
||||||
const lesson = mockSchedule[scheduleKey];
|
function buildingOptions() {
|
||||||
|
const values = new Map();
|
||||||
if (lesson) {
|
classrooms.forEach(room => {
|
||||||
// Карточка занятия.
|
const key = buildingKey(room);
|
||||||
td.innerHTML = `
|
values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building);
|
||||||
<div class="lesson-card">
|
});
|
||||||
<div class="lesson-subject">${lesson.subject}</div>
|
return Array.from(values.entries())
|
||||||
<div class="lesson-group">${lesson.group}</div>
|
.map(([value, label]) => ({ value, label }))
|
||||||
<div class="lesson-teacher">${lesson.teacher}</div>
|
.sort((a, b) => naturalCompare(a.label, b.label));
|
||||||
</div>
|
}
|
||||||
`;
|
|
||||||
|
function equipmentOptions() {
|
||||||
|
const source = equipments.length
|
||||||
|
? equipments
|
||||||
|
: classrooms.flatMap(room => room.equipments || []);
|
||||||
|
const values = new Map();
|
||||||
|
source.forEach(item => {
|
||||||
|
if (item?.id && item?.name) values.set(String(item.id), item.name);
|
||||||
|
});
|
||||||
|
return Array.from(values.entries())
|
||||||
|
.map(([id, name]) => ({ id, name }))
|
||||||
|
.sort((a, b) => naturalCompare(a.name, b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCheckboxes(containerId, items, valueFn, labelFn) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = '<div class="workload-filter-empty">Нет данных</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = items.map(item => `
|
||||||
|
<label class="checkbox-item">
|
||||||
|
<input type="checkbox" value="${escapeHtml(valueFn(item))}">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="checkbox-label">${escapeHtml(labelFn(item))}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLessons(lessons) {
|
||||||
|
const unique = new Map();
|
||||||
|
lessons.forEach(lesson => {
|
||||||
|
if (!lesson.classroomId || !lesson.timeSlotId) return;
|
||||||
|
const key = [
|
||||||
|
lesson.date,
|
||||||
|
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
|
||||||
|
lesson.classroomId,
|
||||||
|
lesson.timeSlotId,
|
||||||
|
lesson.subjectId || '',
|
||||||
|
lesson.teacherId || ''
|
||||||
|
].join(':');
|
||||||
|
const existing = unique.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames);
|
||||||
|
existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
unique.set(key, {
|
||||||
tr.appendChild(td);
|
...lesson,
|
||||||
|
groupNames: [...(lesson.groupNames || [])],
|
||||||
|
groupIds: [...(lesson.groupIds || [])]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
const grouped = new Map();
|
||||||
});
|
unique.forEach(lesson => {
|
||||||
|
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key).push(lesson);
|
||||||
|
});
|
||||||
|
grouped.forEach(items => {
|
||||||
|
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const visibleClassrooms = filteredClassrooms();
|
||||||
|
renderHeader();
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!visibleClassrooms.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
|
||||||
|
renderSummary(0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleClassrooms.forEach(room => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const roomCell = document.createElement('td');
|
||||||
|
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
|
||||||
|
roomCell.innerHTML = `
|
||||||
|
<strong>${escapeHtml(room.name)}</strong>
|
||||||
|
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
|
||||||
|
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
|
||||||
|
`;
|
||||||
|
tr.appendChild(roomCell);
|
||||||
|
|
||||||
|
timeSlots.forEach(slot => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
|
||||||
|
td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
|
||||||
|
td.innerHTML = lessons.length
|
||||||
|
? lessons.map(renderLessonCard).join('')
|
||||||
|
: '<span class="free-slot">Свободно</span>';
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const occupiedCells = countOccupiedCells(visibleClassrooms);
|
||||||
|
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader() {
|
||||||
|
headerRow.innerHTML = `
|
||||||
|
<th class="top-left-cell">
|
||||||
|
<span class="top-label">Время</span>
|
||||||
|
<span class="bottom-label">Аудитория</span>
|
||||||
|
</th>
|
||||||
|
`;
|
||||||
|
|
||||||
|
timeSlots.forEach(slot => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.innerHTML = `
|
||||||
|
<strong>${escapeHtml(slot.orderNumber || '')} пара</strong>
|
||||||
|
<span>${escapeHtml(timeSlotLabel(slot))}</span>
|
||||||
|
${slot.scopeApplyMode === 'DEFAULT' ? '' : `<small>${escapeHtml(scopeShort(slot))}</small>`}
|
||||||
|
`;
|
||||||
|
headerRow.appendChild(th);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLessonCard(lesson) {
|
||||||
|
return `
|
||||||
|
<div class="lesson-card">
|
||||||
|
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
|
||||||
|
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
||||||
|
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
||||||
|
<div class="lesson-meta">${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredClassrooms() {
|
||||||
|
const selectedBuildings = selectedValues('building-checkboxes');
|
||||||
|
const selectedCapacities = selectedValues('capacity-checkboxes');
|
||||||
|
const selectedEquipment = selectedValues('equipment-checkboxes');
|
||||||
|
|
||||||
|
return classrooms.filter(room => {
|
||||||
|
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
|
||||||
|
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS
|
||||||
|
.filter(bucket => selectedCapacities.includes(bucket.id))
|
||||||
|
.some(bucket => bucket.match(room.capacity));
|
||||||
|
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
|
||||||
|
(room.equipments || []).some(item => String(item.id) === String(id))
|
||||||
|
);
|
||||||
|
return buildingMatches && capacityMatches && equipmentMatches;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedValues(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return [];
|
||||||
|
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
||||||
|
.map(input => input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterText(containerId, textId, emptyText) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const text = document.getElementById(textId);
|
||||||
|
if (!container || !text) return;
|
||||||
|
const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked'));
|
||||||
|
if (!checked.length) {
|
||||||
|
text.textContent = emptyText;
|
||||||
|
} else if (checked.length === 1) {
|
||||||
|
text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText;
|
||||||
|
} else {
|
||||||
|
text.textContent = `Выбрано: ${checked.length}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableLoading(message) {
|
||||||
|
renderHeader();
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
|
||||||
|
if (summary) summary.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableError(message) {
|
||||||
|
renderHeader();
|
||||||
|
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
|
||||||
|
showAlert('workload-alert', message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(roomCount, occupiedCells, totalCells) {
|
||||||
|
if (!summary) return;
|
||||||
|
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
|
||||||
|
const date = dateInput?.value ? formatDate(dateInput.value) : '-';
|
||||||
|
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
|
||||||
|
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
|
||||||
|
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOccupiedCells(rooms) {
|
||||||
|
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
|
||||||
|
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
|
||||||
|
).length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomSlotKey(roomId, timeSlotId) {
|
||||||
|
return `${roomId}:${timeSlotId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildingKey(room) {
|
||||||
|
return room.building ? String(room.building) : NO_BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNames(first = [], second = []) {
|
||||||
|
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIds(first = [], second = []) {
|
||||||
|
return Array.from(new Set([...first, ...second].filter(id => id != null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeSlotLabel(slot) {
|
||||||
|
return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIsoDay() {
|
||||||
|
if (!dateInput?.value) return null;
|
||||||
|
const date = new Date(`${dateInput.value}T00:00:00`);
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeShort(slot) {
|
||||||
|
if (slot.scopeApplyMode === 'WEEKDAY') {
|
||||||
|
return dayShort(slot.scopeDayOfWeek);
|
||||||
|
}
|
||||||
|
return 'Ручн.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayShort(dayOfWeek) {
|
||||||
|
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTime(value) {
|
||||||
|
return value ? String(value).slice(0, 5) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
const [year, month, day] = String(value).split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalCompare(a, b) {
|
||||||
|
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,17 +37,6 @@ export async function initSchedule() {
|
|||||||
const rulesTbody = document.getElementById('schedule-tbody');
|
const rulesTbody = document.getElementById('schedule-tbody');
|
||||||
const refreshRulesButton = document.getElementById('schedule-refresh');
|
const refreshRulesButton = document.getElementById('schedule-refresh');
|
||||||
|
|
||||||
const timeSlotForm = document.getElementById('time-slot-form');
|
|
||||||
const timeSlotFormTitle = document.getElementById('time-slot-form-title');
|
|
||||||
const timeSlotIdInput = document.getElementById('time-slot-id');
|
|
||||||
const timeSlotOrderInput = document.getElementById('time-slot-order');
|
|
||||||
const timeSlotStartInput = document.getElementById('time-slot-start');
|
|
||||||
const timeSlotEndInput = document.getElementById('time-slot-end');
|
|
||||||
const timeSlotDurationInput = document.getElementById('time-slot-duration');
|
|
||||||
const timeSlotResetButton = document.getElementById('time-slot-reset');
|
|
||||||
const timeSlotsTbody = document.getElementById('time-slots-tbody');
|
|
||||||
const refreshSlotsButton = document.getElementById('time-slots-refresh');
|
|
||||||
|
|
||||||
let rules = [];
|
let rules = [];
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
let groups = [];
|
let groups = [];
|
||||||
@@ -70,10 +59,8 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
refreshRulesButton?.addEventListener('click', loadRules);
|
refreshRulesButton?.addEventListener('click', loadRules);
|
||||||
refreshSlotsButton?.addEventListener('click', loadTimeSlots);
|
|
||||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||||
timeSlotResetButton?.addEventListener('click', resetTimeSlotForm);
|
|
||||||
|
|
||||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||||
const removeButton = event.target.closest('.schedule-slot-remove');
|
const removeButton = event.target.closest('.schedule-slot-remove');
|
||||||
@@ -84,9 +71,7 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ruleForm.addEventListener('submit', saveRule);
|
ruleForm.addEventListener('submit', saveRule);
|
||||||
timeSlotForm.addEventListener('submit', saveTimeSlot);
|
|
||||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||||
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBaseLists() {
|
async function loadBaseLists() {
|
||||||
@@ -293,97 +278,10 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTimeSlots() {
|
async function loadTimeSlots() {
|
||||||
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
const slots = await api.get('/api/admin/time-slots');
|
||||||
try {
|
timeSlots = slots
|
||||||
timeSlots = await api.get('/api/admin/time-slots');
|
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||||||
renderTimeSlots();
|
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||||
} catch (error) {
|
|
||||||
timeSlotsTbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTimeSlots() {
|
|
||||||
if (!timeSlots.length) {
|
|
||||||
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Сетка пар не настроена</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timeSlotsTbody.innerHTML = timeSlots.map(slot => `
|
|
||||||
<tr>
|
|
||||||
<td>${slot.orderNumber}</td>
|
|
||||||
<td>${escapeHtml(trimTime(slot.startTime))}</td>
|
|
||||||
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
|
||||||
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
|
||||||
<td>
|
|
||||||
<button class="btn-edit-classroom btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
|
||||||
<button class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTimeSlot(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
const payload = {
|
|
||||||
id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null,
|
|
||||||
orderNumber: Number(timeSlotOrderInput.value),
|
|
||||||
startTime: timeSlotStartInput.value,
|
|
||||||
endTime: timeSlotEndInput.value,
|
|
||||||
durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (!payload.orderNumber || !payload.startTime || !payload.endTime) {
|
|
||||||
throw new Error('Заполните номер пары и время');
|
|
||||||
}
|
|
||||||
const id = timeSlotIdInput.value;
|
|
||||||
await (id
|
|
||||||
? api.put('/api/admin/time-slots/' + id, payload)
|
|
||||||
: api.post('/api/admin/time-slots', payload));
|
|
||||||
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
|
|
||||||
resetTimeSlotForm();
|
|
||||||
await loadTimeSlots();
|
|
||||||
renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]);
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTimeSlotTableClick(event) {
|
|
||||||
const editButton = event.target.closest('.btn-edit-time-slot');
|
|
||||||
const deleteButton = event.target.closest('.btn-delete-time-slot');
|
|
||||||
|
|
||||||
if (editButton) {
|
|
||||||
const slot = timeSlots.find(item => item.id == editButton.dataset.id);
|
|
||||||
if (!slot) return;
|
|
||||||
timeSlotFormTitle.textContent = 'Редактирование временного слота';
|
|
||||||
timeSlotIdInput.value = slot.id;
|
|
||||||
timeSlotOrderInput.value = slot.orderNumber || '';
|
|
||||||
timeSlotStartInput.value = trimTime(slot.startTime);
|
|
||||||
timeSlotEndInput.value = trimTime(slot.endTime);
|
|
||||||
timeSlotDurationInput.value = slot.durationMinutes || '';
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteButton) {
|
|
||||||
if (!confirm('Удалить временной слот?')) return;
|
|
||||||
try {
|
|
||||||
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
|
||||||
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
|
||||||
await loadTimeSlots();
|
|
||||||
renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]);
|
|
||||||
} catch (error) {
|
|
||||||
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTimeSlotForm() {
|
|
||||||
timeSlotForm.reset();
|
|
||||||
timeSlotFormTitle.textContent = 'Новый временной слот';
|
|
||||||
timeSlotIdInput.value = '';
|
|
||||||
hideAlert('time-slot-alert');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSubjectSelects() {
|
function populateSubjectSelects() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* ===== Reset & Base ===== */
|
/* ===== Сброс и база ===== */
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Light Theme ===== */
|
/* ===== Светлая тема ===== */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #f8fafc;
|
--bg-primary: #f8fafc;
|
||||||
--bg-sidebar: rgba(255, 255, 255, 0.7);
|
--bg-sidebar: rgba(255, 255, 255, 0.7);
|
||||||
@@ -69,13 +69,13 @@ body {
|
|||||||
transition: background 0.4s ease, color 0.4s ease;
|
transition: background 0.4s ease, color 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Animations ===== */
|
/* ===== Анимации ===== */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Theme Toggle ===== */
|
/* ===== Переключатель темы ===== */
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
@@ -116,7 +116,7 @@ body {
|
|||||||
right: 1.25rem;
|
right: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Settings Placeholder ===== */
|
/* ===== Заглушка настроек ===== */
|
||||||
.settings-placeholder {
|
.settings-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -151,3 +151,395 @@ body {
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Рабочие области настроек ===== */
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
animation: fadeIn 0.25s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-row,
|
||||||
|
.card-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-row h2,
|
||||||
|
.settings-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-row h2 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-row p {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid-tables {
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1.3fr) minmax(100px, 0.7fr) repeat(3, minmax(120px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-compact {
|
||||||
|
grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-assignment {
|
||||||
|
grid-template-columns: minmax(150px, 0.8fr) minmax(190px, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus,
|
||||||
|
.form-field select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-input-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-delete {
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition), background var(--transition), border-color var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-color: var(--bg-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: rgba(239, 68, 68, 0.13);
|
||||||
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .btn-delete {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled,
|
||||||
|
.btn-secondary:disabled,
|
||||||
|
.btn-delete:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-alert {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-alert.success,
|
||||||
|
.form-alert.error {
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-alert.success {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-alert.error {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table-compact {
|
||||||
|
min-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th,
|
||||||
|
.settings-table td {
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table td {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 42px;
|
||||||
|
min-height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(236, 72, 153, 0.14);
|
||||||
|
color: #f9a8d4;
|
||||||
|
border: 1px solid rgba(236, 72, 153, 0.25);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-badge-base {
|
||||||
|
min-width: 86px;
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
border-color: rgba(139, 92, 246, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-trigger:hover,
|
||||||
|
.custom-select-trigger:focus,
|
||||||
|
.custom-select-wrapper.open .custom-select-trigger {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-input-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-icon {
|
||||||
|
display: flex;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.open .custom-select-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(10, 10, 15, 0.96);
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.24);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition: opacity var(--transition), transform var(--transition), visibility var(--transition);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .custom-select-menu {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.open .custom-select-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-item {
|
||||||
|
padding: 0.65rem 0.7rem;
|
||||||
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-item:hover:not(.disabled),
|
||||||
|
.custom-select-item.selected {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-item.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.settings-grid,
|
||||||
|
.settings-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .btn-primary {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-header-row,
|
||||||
|
.card-header-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-row .btn-primary,
|
||||||
|
.card-header-row .btn-secondary,
|
||||||
|
.settings-form .btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Sidebar -->
|
<!-- Боковая панель -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
@@ -46,6 +46,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Общие настройки</span>
|
<span>Общие настройки</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="time-slots">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<polyline points="12 7 12 12 16 14" />
|
||||||
|
<path d="M4 4l3 3" />
|
||||||
|
<path d="M20 4l-3 3" />
|
||||||
|
</svg>
|
||||||
|
<span>Временные слоты</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<a href="/admin/" class="btn-back">
|
<a href="/admin/" class="btn-back">
|
||||||
@@ -59,10 +69,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar overlay (mobile) -->
|
<!-- Затемнение боковой панели на мобильных -->
|
||||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Основная область -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||||
@@ -77,7 +87,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="content" id="app-content">
|
<section class="content" id="app-content">
|
||||||
<!-- Content loaded via JS -->
|
<!-- Контент загружается через JS -->
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
// Settings page main.js
|
// Основной модуль страницы настроек
|
||||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
||||||
|
|
||||||
// Auth check
|
// Проверка авторизации
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
if (!token || role !== 'ADMIN') {
|
if (!token || role !== 'ADMIN') {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global initialization for Custom Selects
|
// Глобальная инициализация кастомных списков
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initAllCustomDropdowns(document.body);
|
initAllCustomDropdowns(document.body);
|
||||||
startDropdownAutoObserver();
|
startDropdownAutoObserver();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configuration
|
// Настройка вкладок
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
general: { title: 'Общие настройки', file: 'views/general.html' },
|
general: { title: 'Общие настройки', file: 'views/general.html' },
|
||||||
|
'time-slots': {
|
||||||
|
title: 'Временные слоты',
|
||||||
|
file: 'views/time-slots.html',
|
||||||
|
init: async () => {
|
||||||
|
const module = await import('./views/time-slots.js');
|
||||||
|
await module.initTimeSlotsSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentTab = null;
|
let currentTab = null;
|
||||||
|
|
||||||
// DOM Elements
|
// DOM-элементы
|
||||||
const appContent = document.getElementById('app-content');
|
const appContent = document.getElementById('app-content');
|
||||||
const pageTitle = document.getElementById('page-title');
|
const pageTitle = document.getElementById('page-title');
|
||||||
const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
||||||
@@ -31,13 +39,13 @@ const menuToggle = document.getElementById('menu-toggle');
|
|||||||
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
|
||||||
const main = document.querySelector('.main');
|
const main = document.querySelector('.main');
|
||||||
|
|
||||||
// Init sidebar state from localStorage
|
// Состояние боковой панели из localStorage
|
||||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
main.classList.add('sidebar-collapsed');
|
main.classList.add('sidebar-collapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu Toggle (Hamburger)
|
// Кнопка меню
|
||||||
menuToggle.addEventListener('click', () => {
|
menuToggle.addEventListener('click', () => {
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
sidebar.classList.toggle('open');
|
sidebar.classList.toggle('open');
|
||||||
@@ -49,7 +57,7 @@ menuToggle.addEventListener('click', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sidebar Close (X button)
|
// Сворачивание боковой панели
|
||||||
sidebarCloseBtn?.addEventListener('click', () => {
|
sidebarCloseBtn?.addEventListener('click', () => {
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
sidebar.classList.remove('open');
|
sidebar.classList.remove('open');
|
||||||
@@ -66,7 +74,7 @@ sidebarOverlay.addEventListener('click', () => {
|
|||||||
sidebarOverlay.classList.remove('open');
|
sidebarOverlay.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigation
|
// Навигация
|
||||||
navItems.forEach(item => {
|
navItems.forEach(item => {
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -85,10 +93,14 @@ async function switchTab(tab) {
|
|||||||
try {
|
try {
|
||||||
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
|
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
|
||||||
const response = await fetch(ROUTES[tab].file);
|
const response = await fetch(ROUTES[tab].file);
|
||||||
if (!response.ok) throw new Error('Failed to load view');
|
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
appContent.innerHTML = html;
|
appContent.innerHTML = html;
|
||||||
|
initAllCustomDropdowns(appContent);
|
||||||
|
if (ROUTES[tab].init) {
|
||||||
|
await ROUTES[tab].init();
|
||||||
|
}
|
||||||
|
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,10 +108,10 @@ async function switchTab(tab) {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close mobile menu if open
|
// Закрываем мобильное меню после перехода
|
||||||
sidebar.classList.remove('open');
|
sidebar.classList.remove('open');
|
||||||
sidebarOverlay.classList.remove('open');
|
sidebarOverlay.classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load default tab
|
// Вкладка по умолчанию
|
||||||
switchTab('general');
|
switchTab('general');
|
||||||
|
|||||||
358
frontend/admin/settings/js/views/time-slots.js
Normal file
358
frontend/admin/settings/js/views/time-slots.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { api } from '../../../js/api.js';
|
||||||
|
import { escapeHtml, hideAlert, showAlert } from '../../../js/utils.js';
|
||||||
|
|
||||||
|
const APPLY_MODE_LABELS = {
|
||||||
|
DEFAULT: 'применяется по умолчанию',
|
||||||
|
WEEKDAY: 'применяется автоматически',
|
||||||
|
MANUAL: 'применяется вручную'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_SHORT_LABELS = {
|
||||||
|
1: 'Пн',
|
||||||
|
2: 'Вт',
|
||||||
|
3: 'Ср',
|
||||||
|
4: 'Чт',
|
||||||
|
5: 'Пт',
|
||||||
|
6: 'Сб',
|
||||||
|
7: 'Вс'
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function initTimeSlotsSettings() {
|
||||||
|
const scopeSelect = document.getElementById('time-slot-scope-select');
|
||||||
|
const scopeSummary = document.getElementById('time-slot-scope-summary');
|
||||||
|
const scopeNameInput = document.getElementById('time-slot-scope-name');
|
||||||
|
const scopeAddButton = document.getElementById('time-slot-scope-add');
|
||||||
|
const scopeDeleteButton = document.getElementById('time-slot-scope-delete');
|
||||||
|
|
||||||
|
const assignmentForm = document.getElementById('time-slot-assignment-form');
|
||||||
|
const assignmentDateInput = document.getElementById('time-slot-assignment-date');
|
||||||
|
const assignmentScopeSelect = document.getElementById('time-slot-assignment-scope');
|
||||||
|
const assignmentsTbody = document.getElementById('time-slot-assignments-tbody');
|
||||||
|
|
||||||
|
const form = document.getElementById('time-slot-form');
|
||||||
|
const title = document.getElementById('time-slot-form-title');
|
||||||
|
const idInput = document.getElementById('time-slot-id');
|
||||||
|
const orderInput = document.getElementById('time-slot-order');
|
||||||
|
const startInput = document.getElementById('time-slot-start');
|
||||||
|
const endInput = document.getElementById('time-slot-end');
|
||||||
|
const durationInput = document.getElementById('time-slot-duration');
|
||||||
|
const resetButton = document.getElementById('time-slot-reset');
|
||||||
|
const refreshButton = document.getElementById('time-slots-refresh');
|
||||||
|
const tbody = document.getElementById('time-slots-tbody');
|
||||||
|
|
||||||
|
let scopes = [];
|
||||||
|
let slots = [];
|
||||||
|
let assignments = [];
|
||||||
|
let activeScopeId = null;
|
||||||
|
|
||||||
|
scopeSelect?.addEventListener('change', () => {
|
||||||
|
activeScopeId = Number(scopeSelect.value);
|
||||||
|
resetForm();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
scopeAddButton?.addEventListener('click', createScope);
|
||||||
|
scopeDeleteButton?.addEventListener('click', deleteActiveScope);
|
||||||
|
assignmentForm?.addEventListener('submit', saveAssignment);
|
||||||
|
form?.addEventListener('submit', saveSlot);
|
||||||
|
resetButton?.addEventListener('click', resetForm);
|
||||||
|
refreshButton?.addEventListener('click', loadAll);
|
||||||
|
startInput?.addEventListener('change', fillDurationIfEmpty);
|
||||||
|
endInput?.addEventListener('change', fillDurationIfEmpty);
|
||||||
|
tbody?.addEventListener('click', handleSlotTableClick);
|
||||||
|
assignmentsTbody?.addEventListener('click', handleAssignmentTableClick);
|
||||||
|
|
||||||
|
await loadAll();
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
||||||
|
assignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||||
|
hideAlert('time-slot-alert');
|
||||||
|
hideAlert('time-slot-assignment-alert');
|
||||||
|
try {
|
||||||
|
[scopes, slots, assignments] = await Promise.all([
|
||||||
|
api.get('/api/admin/time-slots/scopes'),
|
||||||
|
api.get('/api/admin/time-slots'),
|
||||||
|
api.get('/api/admin/time-slots/date-assignments')
|
||||||
|
]);
|
||||||
|
if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) {
|
||||||
|
activeScopeId = defaultScope()?.id || scopes[0]?.id || null;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
assignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет данных</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderScopeSelects();
|
||||||
|
renderScopeSummary();
|
||||||
|
renderSlots();
|
||||||
|
renderAssignments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScopeSelects() {
|
||||||
|
scopeSelect.innerHTML = scopes.map(scope => `
|
||||||
|
<option value="${scope.id}" ${scope.id === activeScopeId ? 'selected' : ''}>${escapeHtml(scopeOptionLabel(scope))}</option>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL');
|
||||||
|
assignmentScopeSelect.innerHTML = manualScopes.length
|
||||||
|
? manualScopes.map(scope => `<option value="${scope.id}">${escapeHtml(scope.name)}</option>`).join('')
|
||||||
|
: '<option value="">Нет ручных сеток</option>';
|
||||||
|
assignmentScopeSelect.disabled = manualScopes.length === 0;
|
||||||
|
|
||||||
|
const activeScope = selectedScope();
|
||||||
|
scopeDeleteButton.disabled = !activeScope || activeScope.systemScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScopeSummary() {
|
||||||
|
const scope = selectedScope();
|
||||||
|
if (!scope) {
|
||||||
|
scopeSummary.innerHTML = '<span class="muted">Сетки времени не настроены</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode;
|
||||||
|
const day = scope.applyMode === 'WEEKDAY' ? `, ${dayShort(scope.dayOfWeek)}` : '';
|
||||||
|
const slotCount = slots.filter(slot => slot.scopeId === scope.id).length;
|
||||||
|
scopeSummary.innerHTML = `
|
||||||
|
<span class="scope-badge ${scope.applyMode === 'DEFAULT' ? 'scope-badge-base' : ''}">${escapeHtml(scopeBadgeLabel(scope))}</span>
|
||||||
|
<span>${escapeHtml(mode + day)}. Слотов: ${slotCount}.</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlots() {
|
||||||
|
const scopeSlots = slots
|
||||||
|
.filter(slot => slot.scopeId === activeScopeId)
|
||||||
|
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||||
|
|
||||||
|
if (!scopeSlots.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading-row">В выбранной сетке нет слотов</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = scopeSlots.map(slot => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(String(slot.orderNumber ?? '-'))}</td>
|
||||||
|
<td>${escapeHtml(trimTime(slot.startTime))}</td>
|
||||||
|
<td>${escapeHtml(trimTime(slot.endTime))}</td>
|
||||||
|
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button type="button" class="btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
|
||||||
|
<button type="button" class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssignments() {
|
||||||
|
if (!assignments.length) {
|
||||||
|
assignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ручных назначений нет</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsTbody.innerHTML = assignments.map(assignment => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(formatDate(assignment.date))}</td>
|
||||||
|
<td>${escapeHtml(assignment.scopeName || '-')}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button type="button" class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Убрать</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScope() {
|
||||||
|
const name = scopeNameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showAlert('time-slot-alert', 'Введите название ручной сетки', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = await api.post('/api/admin/time-slots/scopes', { name });
|
||||||
|
scopeNameInput.value = '';
|
||||||
|
activeScopeId = saved.id;
|
||||||
|
showAlert('time-slot-alert', 'Сетка времени создана', 'success');
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-alert', error.message || 'Ошибка создания сетки времени', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteActiveScope() {
|
||||||
|
const scope = selectedScope();
|
||||||
|
if (!scope || scope.systemScope) return;
|
||||||
|
if (!confirm(`Удалить сетку "${scope.name}" вместе с её слотами и ручными назначениями?`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/admin/time-slots/scopes/' + scope.id);
|
||||||
|
activeScopeId = defaultScope()?.id || null;
|
||||||
|
resetForm();
|
||||||
|
showAlert('time-slot-alert', 'Сетка времени удалена', 'success');
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-alert', error.message || 'Ошибка удаления сетки времени', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
hideAlert('time-slot-assignment-alert');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
date: assignmentDateInput.value,
|
||||||
|
scopeId: assignmentScopeSelect.value ? Number(assignmentScopeSelect.value) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.date || !payload.scopeId) {
|
||||||
|
showAlert('time-slot-assignment-alert', 'Выберите дату и ручную сетку', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/admin/time-slots/date-assignments', payload);
|
||||||
|
assignmentForm.reset();
|
||||||
|
showAlert('time-slot-assignment-alert', 'Сетка применена к дате', 'success');
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-assignment-alert', error.message || 'Ошибка назначения сетки', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignmentTableClick(event) {
|
||||||
|
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||||||
|
if (!deleteButton) return;
|
||||||
|
if (!confirm('Убрать ручное применение сетки на эту дату?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/admin/time-slots/date-assignments/' + deleteButton.dataset.id);
|
||||||
|
showAlert('time-slot-assignment-alert', 'Ручное назначение удалено', 'success');
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-assignment-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSlot(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
hideAlert('time-slot-alert');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: idInput.value ? Number(idInput.value) : null,
|
||||||
|
orderNumber: Number(orderInput.value),
|
||||||
|
scopeId: activeScopeId,
|
||||||
|
startTime: startInput.value,
|
||||||
|
endTime: endInput.value,
|
||||||
|
durationMinutes: durationInput.value ? Number(durationInput.value) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateSlotPayload(payload);
|
||||||
|
const id = idInput.value;
|
||||||
|
await (id
|
||||||
|
? api.put('/api/admin/time-slots/' + id, payload)
|
||||||
|
: api.post('/api/admin/time-slots', payload));
|
||||||
|
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
|
||||||
|
resetForm();
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSlotTableClick(event) {
|
||||||
|
const editButton = event.target.closest('.btn-edit-time-slot');
|
||||||
|
const deleteButton = event.target.closest('.btn-delete-time-slot');
|
||||||
|
|
||||||
|
if (editButton) {
|
||||||
|
const slot = slots.find(item => String(item.id) === String(editButton.dataset.id));
|
||||||
|
if (!slot) return;
|
||||||
|
activeScopeId = slot.scopeId;
|
||||||
|
title.textContent = 'Редактирование слота';
|
||||||
|
idInput.value = slot.id;
|
||||||
|
orderInput.value = slot.orderNumber || '';
|
||||||
|
startInput.value = trimTime(slot.startTime);
|
||||||
|
endInput.value = trimTime(slot.endTime);
|
||||||
|
durationInput.value = slot.durationMinutes || '';
|
||||||
|
hideAlert('time-slot-alert');
|
||||||
|
render();
|
||||||
|
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteButton) {
|
||||||
|
if (!confirm('Удалить временной слот?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
|
||||||
|
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
|
||||||
|
resetForm();
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSlotPayload(payload) {
|
||||||
|
if (!payload.scopeId) {
|
||||||
|
throw new Error('Выберите сетку времени');
|
||||||
|
}
|
||||||
|
if (!payload.orderNumber || payload.orderNumber <= 0) {
|
||||||
|
throw new Error('Укажите номер пары');
|
||||||
|
}
|
||||||
|
if (!payload.startTime || !payload.endTime) {
|
||||||
|
throw new Error('Укажите время начала и окончания');
|
||||||
|
}
|
||||||
|
if (payload.startTime >= payload.endTime) {
|
||||||
|
throw new Error('Время начала должно быть раньше времени окончания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDurationIfEmpty() {
|
||||||
|
if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return;
|
||||||
|
const [startHour, startMinute] = startInput.value.split(':').map(Number);
|
||||||
|
const [endHour, endMinute] = endInput.value.split(':').map(Number);
|
||||||
|
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.reset();
|
||||||
|
idInput.value = '';
|
||||||
|
title.textContent = 'Новый слот';
|
||||||
|
hideAlert('time-slot-alert');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedScope() {
|
||||||
|
return scopes.find(scope => scope.id === activeScopeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScope() {
|
||||||
|
return scopes.find(scope => scope.applyMode === 'DEFAULT') || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeOptionLabel(scope) {
|
||||||
|
const suffix = scope.applyMode === 'WEEKDAY'
|
||||||
|
? `, ${dayShort(scope.dayOfWeek)}`
|
||||||
|
: '';
|
||||||
|
return `${scope.name} - ${APPLY_MODE_LABELS[scope.applyMode] || scope.applyMode}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeBadgeLabel(scope) {
|
||||||
|
if (scope.applyMode === 'DEFAULT') return 'Базовая';
|
||||||
|
if (scope.applyMode === 'WEEKDAY') return dayShort(scope.dayOfWeek);
|
||||||
|
return 'Ручная';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayShort(dayOfWeek) {
|
||||||
|
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTime(value) {
|
||||||
|
return value ? String(value).slice(0, 5) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const [year, month, day] = String(value).split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
124
frontend/admin/settings/views/time-slots.html
Normal file
124
frontend/admin/settings/views/time-slots.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<section class="settings-section">
|
||||||
|
<div class="settings-header-row">
|
||||||
|
<div>
|
||||||
|
<h2>Временные слоты</h2>
|
||||||
|
<p>Настройка базовой, субботней и ручных сеток времени.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-primary" id="time-slots-refresh">Обновить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<article class="settings-card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h3>Сетка времени</h3>
|
||||||
|
<button type="button" class="btn-secondary" id="time-slot-scope-delete">Удалить сетку</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-form settings-form-compact">
|
||||||
|
<label class="form-field" for="time-slot-scope-select">
|
||||||
|
<span>Область действия</span>
|
||||||
|
<select id="time-slot-scope-select"></select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field" for="time-slot-scope-name">
|
||||||
|
<span>Новая ручная сетка</span>
|
||||||
|
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-primary" id="time-slot-scope-add">Добавить сетку</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scope-summary" id="time-slot-scope-summary"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>Ручное применение</h3>
|
||||||
|
<form id="time-slot-assignment-form" class="settings-form settings-form-assignment" novalidate>
|
||||||
|
<label class="form-field" for="time-slot-assignment-date">
|
||||||
|
<span>Дата</span>
|
||||||
|
<input type="date" id="time-slot-assignment-date" required>
|
||||||
|
</label>
|
||||||
|
<label class="form-field" for="time-slot-assignment-scope">
|
||||||
|
<span>Сетка</span>
|
||||||
|
<select id="time-slot-assignment-scope"></select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">Применить</button>
|
||||||
|
<div class="form-alert" id="time-slot-assignment-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h3 id="time-slot-form-title">Новый слот</h3>
|
||||||
|
<button type="button" class="btn-secondary" id="time-slot-reset">Очистить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="time-slot-form" class="settings-form" novalidate>
|
||||||
|
<input type="hidden" id="time-slot-id">
|
||||||
|
|
||||||
|
<label class="form-field" for="time-slot-order">
|
||||||
|
<span>Пара</span>
|
||||||
|
<input type="number" id="time-slot-order" min="1" step="1" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field" for="time-slot-start">
|
||||||
|
<span>Начало</span>
|
||||||
|
<input type="time" id="time-slot-start" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field" for="time-slot-end">
|
||||||
|
<span>Окончание</span>
|
||||||
|
<input type="time" id="time-slot-end" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field" for="time-slot-duration">
|
||||||
|
<span>Минут</span>
|
||||||
|
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary">Сохранить слот</button>
|
||||||
|
<div class="form-alert" id="time-slot-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="settings-grid settings-grid-tables">
|
||||||
|
<article class="settings-card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пара</th>
|
||||||
|
<th>Начало</th>
|
||||||
|
<th>Окончание</th>
|
||||||
|
<th>Минут</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="time-slots-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="settings-table settings-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Сетка</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="time-slot-assignments-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header-row" style="margin-bottom: 1.5rem;">
|
<div class="card-header-row" style="margin-bottom: 1.5rem;">
|
||||||
<h2>Загруженность аудиторий</h2>
|
<h2>Загруженность аудиторий</h2>
|
||||||
|
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
<div class="filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
<label>Корпус</label>
|
<label>Корпус</label>
|
||||||
<div class="custom-multi-select">
|
<div class="custom-multi-select">
|
||||||
<div class="select-box" id="building-box">
|
<div class="select-box" id="building-box">
|
||||||
<span class="select-text" id="building-text">Выберите корпуса...</span>
|
<span class="select-text" id="building-text">Все корпуса</span>
|
||||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
<label>Вместимость</label>
|
<label>Вместимость</label>
|
||||||
<div class="custom-multi-select">
|
<div class="custom-multi-select">
|
||||||
<div class="select-box" id="capacity-box">
|
<div class="select-box" id="capacity-box">
|
||||||
<span class="select-text" id="capacity-text">Выберите вместимость...</span>
|
<span class="select-text" id="capacity-text">Любая вместимость</span>
|
||||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<label>Оборудование</label>
|
<label>Оборудование</label>
|
||||||
<div class="custom-multi-select">
|
<div class="custom-multi-select">
|
||||||
<div class="select-box" id="equipment-box">
|
<div class="select-box" id="equipment-box">
|
||||||
<span class="select-text" id="equipment-text">Выберите оборудование...</span>
|
<span class="select-text" id="equipment-text">Любое оборудование</span>
|
||||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-alert" id="workload-alert" role="alert"></div>
|
||||||
|
<div class="workload-summary" id="workload-summary"></div>
|
||||||
|
|
||||||
<!-- Контейнер таблицы -->
|
<!-- Контейнер таблицы -->
|
||||||
<div class="workload-grid-container">
|
<div class="workload-grid-container">
|
||||||
<table class="workload-table" id="workload-table">
|
<table class="workload-table" id="workload-table">
|
||||||
|
|||||||
@@ -75,58 +75,3 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card create-card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2 id="time-slot-form-title">Новый временной слот</h2>
|
|
||||||
<button type="button" class="btn-edit-classroom" id="time-slot-reset">Очистить форму</button>
|
|
||||||
</div>
|
|
||||||
<form id="time-slot-form" novalidate>
|
|
||||||
<input type="hidden" id="time-slot-id">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="time-slot-order">Номер пары</label>
|
|
||||||
<input type="number" id="time-slot-order" min="1" step="1" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="time-slot-start">Начало</label>
|
|
||||||
<input type="time" id="time-slot-start" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="time-slot-end">Окончание</label>
|
|
||||||
<input type="time" id="time-slot-end" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="time-slot-duration">Минут</label>
|
|
||||||
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Сохранить слот</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-alert" id="time-slot-alert" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header-row">
|
|
||||||
<h2>Сетка пар</h2>
|
|
||||||
<button class="btn-primary" id="time-slots-refresh">Обновить</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="time-slots-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Пара</th>
|
|
||||||
<th>Начало</th>
|
|
||||||
<th>Окончание</th>
|
|
||||||
<th>Минут</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="time-slots-tbody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user