сделал настройку временных слотов
This commit is contained in:
@@ -47,6 +47,8 @@ public class ClassroomController {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setName(request.getName().trim());
|
||||
classroom.setCapacity(request.getCapacity());
|
||||
classroom.setBuilding(cleanText(request.getBuilding()));
|
||||
classroom.setFloor(request.getFloor());
|
||||
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
|
||||
|
||||
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
|
||||
@@ -80,6 +82,14 @@ public class ClassroomController {
|
||||
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) {
|
||||
classroom.setIsAvailable(request.getIsAvailable());
|
||||
}
|
||||
@@ -103,7 +113,11 @@ public class ClassroomController {
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
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")
|
||||
public class ScheduleRuleAdminController {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
@@ -169,6 +171,10 @@ public class ScheduleRuleAdminController {
|
||||
}
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
|
||||
if (timeSlot.getTimeSlotScope() == null
|
||||
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
|
||||
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
|
||||
}
|
||||
User teacher = userRepository.findById(slotDto.teacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
Classroom classroom = classroomRepository.findById(slotDto.classroomId())
|
||||
|
||||
@@ -1,40 +1,159 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.dto.TimeSlotScopeDto;
|
||||
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.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/time-slots")
|
||||
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.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TimeSlotDto> getAll() {
|
||||
return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream()
|
||||
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
|
||||
.map(this::toDto)
|
||||
.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
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
||||
if (hasDuplicate(request, null)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
@@ -53,9 +172,8 @@ public class TimeSlotAdminController {
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())
|
||||
&& !timeSlot.getOrderNumber().equals(request.orderNumber())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
|
||||
if (hasDuplicate(request, id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
@@ -67,14 +185,21 @@ public class TimeSlotAdminController {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||
return "Выберите сетку времени";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
@@ -86,6 +211,8 @@ public class TimeSlotAdminController {
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
@@ -94,13 +221,83 @@ public class TimeSlotAdminController {
|
||||
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) {
|
||||
TimeSlotScope scope = timeSlot.getTimeSlotScope();
|
||||
return new TimeSlotDto(
|
||||
timeSlot.getId(),
|
||||
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.getEndTime(),
|
||||
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 {
|
||||
private String name;
|
||||
private Integer capacity;
|
||||
private String building;
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private List<Long> equipmentIds;
|
||||
|
||||
@@ -24,6 +26,22 @@ public class ClassroomRequest {
|
||||
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() {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@ public class ClassroomResponse {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer capacity;
|
||||
private String building;
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
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.name = name;
|
||||
this.capacity = capacity;
|
||||
this.building = building;
|
||||
this.floor = floor;
|
||||
this.isAvailable = isAvailable;
|
||||
this.equipments = equipments;
|
||||
}
|
||||
@@ -45,6 +50,22 @@ public class ClassroomResponse {
|
||||
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() {
|
||||
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(
|
||||
Long id,
|
||||
Integer orderNumber,
|
||||
Long scopeId,
|
||||
String scopeName,
|
||||
String scopeApplyMode,
|
||||
Integer scopeDayOfWeek,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
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)
|
||||
private Integer capacity;
|
||||
|
||||
@Column(length = 50)
|
||||
private String building;
|
||||
|
||||
private Integer floor;
|
||||
|
||||
@Column(name = "is_available", nullable = false)
|
||||
private Boolean isAvailable = true;
|
||||
|
||||
@@ -52,6 +57,22 @@ public class Classroom {
|
||||
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() {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public class TimeSlot {
|
||||
@Column(name = "order_number", nullable = false)
|
||||
private Integer orderNumber;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "time_slot_scope_id", nullable = false)
|
||||
private TimeSlotScope timeSlotScope;
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
private LocalTime startTime;
|
||||
|
||||
@@ -40,6 +44,14 @@ public class TimeSlot {
|
||||
this.orderNumber = orderNumber;
|
||||
}
|
||||
|
||||
public TimeSlotScope getTimeSlotScope() {
|
||||
return timeSlotScope;
|
||||
}
|
||||
|
||||
public void setTimeSlotScope(TimeSlotScope timeSlotScope) {
|
||||
this.timeSlotScope = timeSlotScope;
|
||||
}
|
||||
|
||||
public LocalTime getStartTime() {
|
||||
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 java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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.repository.GroupRepository;
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -22,21 +25,31 @@ public class ScheduleGeneratorService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
|
||||
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
private static final long MAX_RANGE_DAYS = 120;
|
||||
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||
|
||||
private final ScheduleRuleRepository scheduleRuleRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
|
||||
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
UserRepository userRepository,
|
||||
AcademicDateService academicDateService) {
|
||||
AcademicDateService academicDateService,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.academicDateService = academicDateService;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||
@@ -247,7 +260,7 @@ public class ScheduleGeneratorService {
|
||||
List<StudentGroup> groups,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||
List<StudentGroup> sortedGroups = groups.stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.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) {
|
||||
String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
|
||||
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 (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||
order_number INT NOT NULL,
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
duration_minutes INT NOT NULL,
|
||||
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_duration_positive CHECK (duration_minutes > 0),
|
||||
CONSTRAINT uq_time_slots_order UNIQUE (order_number),
|
||||
CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time)
|
||||
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0)
|
||||
);
|
||||
|
||||
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),
|
||||
(2, '09:40', '11: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),
|
||||
(6, '16:50', '18:20', 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 (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -522,7 +569,8 @@ FROM (VALUES
|
||||
('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)
|
||||
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 classrooms classroom ON classroom.name = data.classroom_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 specialties IS 'Специальности';
|
||||
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
|
||||
COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки';
|
||||
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
|
||||
COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату';
|
||||
COMMENT ON TABLE academic_years IS 'Учебные годы';
|
||||
COMMENT ON TABLE semesters 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` |
|
||||
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
|
||||
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком.
|
||||
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. Время пары берётся из базового слота правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из `/api/admin/time-slots`.
|
||||
|
||||
**Пример:**
|
||||
```http
|
||||
@@ -160,25 +160,51 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
|
||||
### `GET /api/admin/time-slots`
|
||||
|
||||
Список временных слотов занятий. CRUD доступен по:
|
||||
Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/time-slots` | Список слотов |
|
||||
| `GET` | `/api/admin/time-slots/effective?date=2026-05-02` | Эффективные слоты для даты |
|
||||
| `POST` | `/api/admin/time-slots` | Создать слот |
|
||||
| `PUT` | `/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
|
||||
{
|
||||
"orderNumber": 1,
|
||||
"scopeId": 1,
|
||||
"startTime": "08:00:00",
|
||||
"endTime": "09:30:00",
|
||||
"durationMinutes": 90
|
||||
}
|
||||
```
|
||||
|
||||
**Создание ручной сетки:**
|
||||
```json
|
||||
{
|
||||
"name": "Праздничная сетка"
|
||||
}
|
||||
```
|
||||
|
||||
**Ручное применение сетки к дате:**
|
||||
```json
|
||||
{
|
||||
"date": "2026-05-08",
|
||||
"scopeId": 3
|
||||
}
|
||||
```
|
||||
|
||||
Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
|
||||
|
||||
### Учебные годы, семестры и коды календарного графика
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
@@ -281,6 +307,8 @@ CRUD доступен по:
|
||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
### `GET /api/lesson-types`
|
||||
|
||||
Справочник типов занятий для конструктора правил расписания.
|
||||
@@ -436,7 +464,7 @@ CRUD доступен по:
|
||||
}
|
||||
```
|
||||
|
||||
Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа.
|
||||
Назначаемый график должен относиться к тому же учебному году, специальности, профилю и форме обучения, что и группа.
|
||||
|
||||
---
|
||||
|
||||
@@ -453,6 +481,8 @@ CRUD доступен по:
|
||||
"id": 1,
|
||||
"name": "101 Ленинская",
|
||||
"capacity": 120,
|
||||
"building": "Главный корпус",
|
||||
"floor": 2,
|
||||
"isAvailable": true,
|
||||
"equipments": [
|
||||
{ "id": 1, "name": "Проектор" },
|
||||
@@ -470,6 +500,8 @@ CRUD доступен по:
|
||||
{
|
||||
"name": "404 Лаборатория",
|
||||
"capacity": 30,
|
||||
"building": "Лабораторный корпус",
|
||||
"floor": 4,
|
||||
"isAvailable": true,
|
||||
"equipmentIds": [1, 2, 3]
|
||||
}
|
||||
|
||||
@@ -94,29 +94,34 @@
|
||||
5. Пропускает день, если код активности не разрешает обычные пары.
|
||||
6. Загружает правила группы или преподавателя.
|
||||
7. Симулирует уже проведённые занятия от `active_from_date`.
|
||||
8. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
||||
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||
9. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
||||
|
||||
Обычные пары генерируются только на коде `Т` (`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 |
|
||||
| 2 | 09:40 – 11:10 |
|
||||
| 3 | 11:40 – 13:10 |
|
||||
| 4 | 13:30 – 15:00 |
|
||||
| 4 | 13:20 – 14:50 |
|
||||
| 5 | 15:00 – 16:30 |
|
||||
| 6 | 16:40 – 18:10 |
|
||||
| 6 | 16:50 – 18:20 |
|
||||
| 7 | 18:30 – 20:00 |
|
||||
|
||||
### Валидация правил расписания
|
||||
|
||||
- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||
- **Связанные сущности:** временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
|
||||
---
|
||||
|
||||
@@ -125,14 +125,31 @@ erDiagram
|
||||
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 {
|
||||
BIGSERIAL id PK
|
||||
INT order_number UK
|
||||
BIGINT time_slot_scope_id FK
|
||||
INT order_number
|
||||
TIME start_time
|
||||
TIME end_time
|
||||
INT duration_minutes
|
||||
}
|
||||
|
||||
time_slot_date_assignments {
|
||||
BIGSERIAL id PK
|
||||
DATE assignment_date UK
|
||||
BIGINT time_slot_scope_id FK
|
||||
}
|
||||
|
||||
academic_years {
|
||||
BIGSERIAL id PK
|
||||
VARCHAR title UK
|
||||
@@ -244,6 +261,8 @@ erDiagram
|
||||
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_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"
|
||||
subgroups ||--o{ schedule_rule_slots : "subgroup_id"
|
||||
users ||--o{ schedule_rule_slots : "teacher_id"
|
||||
@@ -390,15 +409,38 @@ erDiagram
|
||||
| `subject_id` | BIGINT PK, FK → subjects (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` — Временные слоты занятий
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `order_number` | INT UNIQUE | Номер пары в дне |
|
||||
| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Сетка времени |
|
||||
| `order_number` | INT | Номер пары в дне |
|
||||
| `start_time` | TIME | Время начала |
|
||||
| `end_time` | TIME | Время окончания |
|
||||
| `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` — Учебные годы
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
@@ -481,7 +523,7 @@ erDiagram
|
||||
| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило |
|
||||
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
||||
| `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 | Подгруппа |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||
|
||||
@@ -43,7 +43,7 @@ frontend/
|
||||
│ │ ├── equipments.js # Управление оборудованием
|
||||
│ │ ├── edu-forms.js # Формы обучения
|
||||
│ │ ├── profiles.js # Профили обучения специальностей
|
||||
│ │ ├── schedule.js # Конструктор правил расписания и сетки пар
|
||||
│ │ ├── schedule.js # Конструктор правил расписания
|
||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||
│ │ ├── database.js # Управление тенантами
|
||||
│ │ └── departments-data.js # Создание кафедры/специальности
|
||||
@@ -66,9 +66,12 @@ frontend/
|
||||
│ │ ├── main.css # CSS-переменные, базовые стили
|
||||
│ │ └── layout.css # Sidebar, topbar, content
|
||||
│ ├── js/
|
||||
│ │ └── main.js # Навигация по вкладкам настроек
|
||||
│ │ ├── main.js # Навигация по вкладкам настроек
|
||||
│ │ └── views/
|
||||
│ │ └── time-slots.js # Настройка временных слотов
|
||||
│ └── views/
|
||||
│ └── general.html # Общие настройки (заглушка)
|
||||
│ ├── general.html # Общие настройки (заглушка)
|
||||
│ └── time-slots.html # Базовая, субботняя и ручные сетки времени
|
||||
│
|
||||
├── teacher/ # 👩🏫 Интерфейс преподавателя
|
||||
│ └── index.html # Недельный просмотр динамического расписания преподавателя
|
||||
@@ -109,18 +112,18 @@ frontend/
|
||||
| `equipments` | Оборудование | `/api/equipments` |
|
||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||
| `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` |
|
||||
| `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные |
|
||||
| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` |
|
||||
| `database` | Тенанты | `/api/database` |
|
||||
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `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` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
- Вкладка `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` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||
@@ -133,6 +136,7 @@ frontend/
|
||||
- Кнопка «Назад в панель» для возврата в `/admin/`
|
||||
- Текущие вкладки:
|
||||
- **Общие настройки** — заглушка (в разработке)
|
||||
- **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
min-width: 150px;
|
||||
height: 80px;
|
||||
height: 86px;
|
||||
}
|
||||
|
||||
.workload-table th {
|
||||
@@ -37,29 +37,85 @@
|
||||
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 .axis-cell {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
width: 140px;
|
||||
min-width: 140px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
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 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg-input);
|
||||
min-width: 120px;
|
||||
width: 120px;
|
||||
min-width: 140px;
|
||||
width: 140px;
|
||||
box-shadow: 1px 1px 0 var(--bg-card-border);
|
||||
}
|
||||
|
||||
@@ -96,6 +152,36 @@
|
||||
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 {
|
||||
background: var(--bg-card);
|
||||
@@ -138,6 +224,13 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lesson-meta {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Настройка scrollbar для контейнера сетки */
|
||||
.workload-grid-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник' },
|
||||
{ value: 2, label: 'Вторник' },
|
||||
{ value: 3, label: 'Среда' },
|
||||
{ value: 4, label: 'Четверг' },
|
||||
{ value: 5, label: 'Пятница' },
|
||||
{ value: 6, label: 'Суббота' },
|
||||
{ value: 7, label: 'Воскресенье' }
|
||||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||
{ value: 2, label: 'Вторник', shortLabel: 'Вт' },
|
||||
{ value: 3, label: 'Среда', shortLabel: 'Ср' },
|
||||
{ value: 4, label: 'Четверг', shortLabel: 'Чт' },
|
||||
{ value: 5, label: 'Пятница', shortLabel: 'Пт' },
|
||||
{ value: 6, label: 'Суббота', shortLabel: 'Сб' },
|
||||
{ value: 7, label: 'Воскресенье', shortLabel: 'Вс' }
|
||||
];
|
||||
|
||||
const SEMESTER_LABELS = {
|
||||
@@ -548,7 +548,7 @@ export async function initAcademicCalendar() {
|
||||
<tbody>
|
||||
${DAY_OPTIONS.map(day => `
|
||||
<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 => {
|
||||
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
||||
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
||||
@@ -804,8 +804,7 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
function shortDayLabel(value) {
|
||||
const label = dayLabel(value);
|
||||
return label === '-' ? '-' : label.slice(0, 2);
|
||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
|
||||
@@ -1,112 +1,275 @@
|
||||
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');
|
||||
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 yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
dateInput.value = `${yyyy}-${mm}-${dd}`;
|
||||
dateInput.value = [
|
||||
today.getFullYear(),
|
||||
String(today.getMonth() + 1).padStart(2, '0'),
|
||||
String(today.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
// Инициализация мультиселектов.
|
||||
function initFilters() {
|
||||
initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes');
|
||||
initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes');
|
||||
initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes');
|
||||
}
|
||||
|
||||
// Заполнение фильтров начальными данными.
|
||||
function bindEvents() {
|
||||
refreshButton?.addEventListener('click', loadScheduleForSelectedDate);
|
||||
dateInput?.addEventListener('change', loadScheduleForSelectedDate);
|
||||
|
||||
[
|
||||
['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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
setTableLoading('Загрузка аудиторий и расписания...');
|
||||
try {
|
||||
[classrooms, equipments, groups] = await Promise.all([
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/equipments'),
|
||||
api.get('/api/groups')
|
||||
]);
|
||||
classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name));
|
||||
populateFilters();
|
||||
await loadScheduleForSelectedDate();
|
||||
} catch (error) {
|
||||
setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий');
|
||||
}
|
||||
}
|
||||
|
||||
// Отрисовка mock-данных в нужной ориентации сетки.
|
||||
renderMockGrid();
|
||||
}
|
||||
async function loadScheduleForSelectedDate() {
|
||||
const date = dateInput?.value;
|
||||
if (!date) {
|
||||
showAlert('workload-alert', 'Выберите дату', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
// Корпуса.
|
||||
const buildingsContainer = document.getElementById('building-checkboxes');
|
||||
const buildings = [
|
||||
{ id: 1, name: "Корпус 1 (Главный)" },
|
||||
{ id: 2, name: "Корпус 2 (Физ-мат)" },
|
||||
{ id: 3, name: "Корпус 3 (Гуманитарный)" }
|
||||
];
|
||||
buildingsContainer.innerHTML = buildings.map(item => `
|
||||
hideAlert('workload-alert');
|
||||
setTableLoading('Загрузка расписания на выбранную дату...');
|
||||
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
|
||||
|
||||
if (!groups.length) {
|
||||
lessonsByRoomSlot = new Map();
|
||||
renderGrid();
|
||||
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = group => new URLSearchParams({
|
||||
groupId: String(group.id),
|
||||
startDate: date,
|
||||
endDate: date
|
||||
}).toString();
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
groups.map(group => api.get(`/api/schedule?${params(group)}`))
|
||||
);
|
||||
failedGroupLoads = responses.filter(response => response.status === 'rejected').length;
|
||||
const lessons = responses
|
||||
.filter(response => response.status === 'fulfilled')
|
||||
.flatMap(response => response.value || []);
|
||||
|
||||
lessonsByRoomSlot = groupLessons(lessons);
|
||||
renderGrid();
|
||||
|
||||
if (failedGroupLoads > 0) {
|
||||
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
renderCheckboxes(
|
||||
'building-checkboxes',
|
||||
buildingOptions(),
|
||||
item => item.value,
|
||||
item => item.label
|
||||
);
|
||||
renderCheckboxes(
|
||||
'capacity-checkboxes',
|
||||
CAPACITY_BUCKETS,
|
||||
item => item.id,
|
||||
item => item.name
|
||||
);
|
||||
renderCheckboxes(
|
||||
'equipment-checkboxes',
|
||||
equipmentOptions(),
|
||||
item => item.id,
|
||||
item => item.name
|
||||
);
|
||||
|
||||
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
|
||||
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
|
||||
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
|
||||
}
|
||||
|
||||
function buildingOptions() {
|
||||
const values = new Map();
|
||||
classrooms.forEach(room => {
|
||||
const key = buildingKey(room);
|
||||
values.set(key, key === NO_BUILDING ? 'Корпус не указан' : room.building);
|
||||
});
|
||||
return Array.from(values.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => naturalCompare(a.label, b.label));
|
||||
}
|
||||
|
||||
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="${item.id}">
|
||||
<input type="checkbox" value="${escapeHtml(valueFn(item))}">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">${item.name}</span>
|
||||
<span class="checkbox-label">${escapeHtml(labelFn(item))}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Вместимость.
|
||||
const capacityContainer = document.getElementById('capacity-checkboxes');
|
||||
const capacities = [
|
||||
{ id: 'small', name: "До 30 мест" },
|
||||
{ id: 'medium', name: "30 - 60 мест" },
|
||||
{ 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('');
|
||||
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, {
|
||||
...lesson,
|
||||
groupNames: [...(lesson.groupNames || [])],
|
||||
groupIds: [...(lesson.groupIds || [])]
|
||||
});
|
||||
});
|
||||
|
||||
// Оборудование.
|
||||
const equipmentContainer = document.getElementById('equipment-checkboxes');
|
||||
const equipmentList = [
|
||||
{ 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('');
|
||||
}
|
||||
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 renderMockGrid() {
|
||||
// В будущем данные будут загружаться из API.
|
||||
const timeslots = [
|
||||
"8:00-9:30",
|
||||
"9:40-11:10",
|
||||
"11:40-13:10",
|
||||
"13:20-14:50",
|
||||
"15:00-16:30",
|
||||
"16:50-18:20",
|
||||
"18:30-19:50",
|
||||
"20:00-21:20"
|
||||
];
|
||||
function renderGrid() {
|
||||
const visibleClassrooms = filteredClassrooms();
|
||||
renderHeader();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const auditoriums = [
|
||||
"201", "202", "204", "205", "206", "207", "208"
|
||||
];
|
||||
if (!visibleClassrooms.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
|
||||
renderSummary(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock-расписание по аудитории и времени.
|
||||
// Ключ: "roomId_timeSlotId", значение: объект занятия
|
||||
const mockSchedule = {
|
||||
"201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." },
|
||||
"201_9:40-11:10": { subject: "Физика", group: "ИВТ-21-1", teacher: "Атлетов А.Р." },
|
||||
"201_11:40-13:10": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
|
||||
"201_13:20-14:50": { subject: "Физика", group: "ИБ-41м", teacher: "Физик В.Г." },
|
||||
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);
|
||||
|
||||
"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: "Атлетов А.Р." },
|
||||
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);
|
||||
});
|
||||
|
||||
"205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." },
|
||||
};
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// В столбцах отображаются временные слоты, в строках — аудитории.
|
||||
const headerRow = document.getElementById('workload-header-row');
|
||||
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>
|
||||
@@ -114,46 +277,141 @@ function renderMockGrid() {
|
||||
</th>
|
||||
`;
|
||||
|
||||
timeslots.forEach(time => {
|
||||
timeSlots.forEach(slot => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = time;
|
||||
th.innerHTML = `
|
||||
<strong>${escapeHtml(slot.orderNumber || '')} пара</strong>
|
||||
<span>${escapeHtml(timeSlotLabel(slot))}</span>
|
||||
${slot.scopeApplyMode === 'DEFAULT' ? '' : `<small>${escapeHtml(scopeShort(slot))}</small>`}
|
||||
`;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
}
|
||||
|
||||
// Строки таблицы.
|
||||
const tbody = document.getElementById('workload-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
auditoriums.forEach((room) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// Ячейка аудитории.
|
||||
const tdRoom = document.createElement('td');
|
||||
tdRoom.className = 'axis-cell';
|
||||
tdRoom.textContent = room;
|
||||
tr.appendChild(tdRoom);
|
||||
|
||||
// Ячейки временных слотов для аудитории.
|
||||
timeslots.forEach(time => {
|
||||
const td = document.createElement('td');
|
||||
|
||||
const scheduleKey = `${room}_${time}`;
|
||||
const lesson = mockSchedule[scheduleKey];
|
||||
|
||||
if (lesson) {
|
||||
// Карточка занятия.
|
||||
td.innerHTML = `
|
||||
function renderLessonCard(lesson) {
|
||||
return `
|
||||
<div class="lesson-card">
|
||||
<div class="lesson-subject">${lesson.subject}</div>
|
||||
<div class="lesson-group">${lesson.group}</div>
|
||||
<div class="lesson-teacher">${lesson.teacher}</div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
tr.appendChild(td);
|
||||
});
|
||||
function filteredClassrooms() {
|
||||
const selectedBuildings = selectedValues('building-checkboxes');
|
||||
const selectedCapacities = selectedValues('capacity-checkboxes');
|
||||
const selectedEquipment = selectedValues('equipment-checkboxes');
|
||||
|
||||
tbody.appendChild(tr);
|
||||
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 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 subjects = [];
|
||||
let groups = [];
|
||||
@@ -70,10 +59,8 @@ export async function initSchedule() {
|
||||
|
||||
function bindEvents() {
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
refreshSlotsButton?.addEventListener('click', loadTimeSlots);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
timeSlotResetButton?.addEventListener('click', resetTimeSlotForm);
|
||||
|
||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||
const removeButton = event.target.closest('.schedule-slot-remove');
|
||||
@@ -84,9 +71,7 @@ export async function initSchedule() {
|
||||
});
|
||||
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
timeSlotForm.addEventListener('submit', saveTimeSlot);
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
|
||||
}
|
||||
|
||||
async function loadBaseLists() {
|
||||
@@ -293,97 +278,10 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadTimeSlots() {
|
||||
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
timeSlots = await api.get('/api/admin/time-slots');
|
||||
renderTimeSlots();
|
||||
} 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');
|
||||
const slots = await api.get('/api/admin/time-slots');
|
||||
timeSlots = slots
|
||||
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||||
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||||
}
|
||||
|
||||
function populateSubjectSelects() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* ===== Reset & Base ===== */
|
||||
/* ===== Сброс и база ===== */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -34,7 +34,7 @@
|
||||
--transition: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
/* ===== Light Theme ===== */
|
||||
/* ===== Светлая тема ===== */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-sidebar: rgba(255, 255, 255, 0.7);
|
||||
@@ -69,13 +69,13 @@ body {
|
||||
transition: background 0.4s ease, color 0.4s ease;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
/* ===== Анимации ===== */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ===== Theme Toggle ===== */
|
||||
/* ===== Переключатель темы ===== */
|
||||
.theme-toggle {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
@@ -116,7 +116,7 @@ body {
|
||||
right: 1.25rem;
|
||||
}
|
||||
|
||||
/* ===== Settings Placeholder ===== */
|
||||
/* ===== Заглушка настроек ===== */
|
||||
.settings-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -151,3 +151,395 @@ body {
|
||||
max-width: 400px;
|
||||
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>
|
||||
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<!-- Боковая панель -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
@@ -46,6 +46,16 @@
|
||||
</svg>
|
||||
<span>Общие настройки</span>
|
||||
</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>
|
||||
<div class="sidebar-footer">
|
||||
<a href="/admin/" class="btn-back">
|
||||
@@ -59,10 +69,10 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Sidebar overlay (mobile) -->
|
||||
<!-- Затемнение боковой панели на мобильных -->
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<!-- Основная область -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
@@ -77,7 +87,7 @@
|
||||
</header>
|
||||
|
||||
<section class="content" id="app-content">
|
||||
<!-- Content loaded via JS -->
|
||||
<!-- Контент загружается через JS -->
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
// Settings page main.js
|
||||
// Основной модуль страницы настроек
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
||||
|
||||
// Auth check
|
||||
// Проверка авторизации
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('role');
|
||||
if (!token || role !== 'ADMIN') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// Global initialization for Custom Selects
|
||||
// Глобальная инициализация кастомных списков
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAllCustomDropdowns(document.body);
|
||||
startDropdownAutoObserver();
|
||||
});
|
||||
|
||||
// Configuration
|
||||
// Настройка вкладок
|
||||
const ROUTES = {
|
||||
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;
|
||||
|
||||
// DOM Elements
|
||||
// DOM-элементы
|
||||
const appContent = document.getElementById('app-content');
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
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 main = document.querySelector('.main');
|
||||
|
||||
// Init sidebar state from localStorage
|
||||
// Состояние боковой панели из localStorage
|
||||
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// Menu Toggle (Hamburger)
|
||||
// Кнопка меню
|
||||
menuToggle.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.toggle('open');
|
||||
@@ -49,7 +57,7 @@ menuToggle.addEventListener('click', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Sidebar Close (X button)
|
||||
// Сворачивание боковой панели
|
||||
sidebarCloseBtn?.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
@@ -66,7 +74,7 @@ sidebarOverlay.addEventListener('click', () => {
|
||||
sidebarOverlay.classList.remove('open');
|
||||
});
|
||||
|
||||
// Navigation
|
||||
// Навигация
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -85,10 +93,14 @@ async function switchTab(tab) {
|
||||
try {
|
||||
appContent.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:2rem;">Загрузка...</div>';
|
||||
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();
|
||||
appContent.innerHTML = html;
|
||||
initAllCustomDropdowns(appContent);
|
||||
if (ROUTES[tab].init) {
|
||||
await ROUTES[tab].init();
|
||||
}
|
||||
|
||||
currentTab = tab;
|
||||
} catch (e) {
|
||||
@@ -96,10 +108,10 @@ async function switchTab(tab) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Close mobile menu if open
|
||||
// Закрываем мобильное меню после перехода
|
||||
sidebar.classList.remove('open');
|
||||
sidebarOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
// Load default tab
|
||||
// Вкладка по умолчанию
|
||||
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-header-row" style="margin-bottom: 1.5rem;">
|
||||
<h2>Загруженность аудиторий</h2>
|
||||
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
|
||||
@@ -8,7 +9,7 @@
|
||||
<label>Корпус</label>
|
||||
<div class="custom-multi-select">
|
||||
<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">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
@@ -23,7 +24,7 @@
|
||||
<label>Вместимость</label>
|
||||
<div class="custom-multi-select">
|
||||
<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">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
@@ -38,7 +39,7 @@
|
||||
<label>Оборудование</label>
|
||||
<div class="custom-multi-select">
|
||||
<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">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
@@ -55,6 +56,9 @@
|
||||
</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">
|
||||
<table class="workload-table" id="workload-table">
|
||||
|
||||
@@ -75,58 +75,3 @@
|
||||
</table>
|
||||
</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