сделал настройку временных слотов

This commit is contained in:
Zuev
2026-05-01 20:55:49 +03:00
parent bf02efb8b8
commit 3cb311f469
32 changed files with 2130 additions and 383 deletions

View File

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

View File

@@ -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())

View File

@@ -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();
}
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
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()
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TimeSlotDateAssignmentDto(
Long id,
LocalDate date,
Long scopeId,
String scopeName
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 'Коды активностей календарного учебного графика';

View File

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

View File

@@ -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` обязателен и хранится в слоте правила.
---

View File

@@ -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(17) | День недели: 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 | Аудитория |

View File

@@ -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 слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам.
---

View File

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

View File

@@ -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) {

View File

@@ -1,159 +1,417 @@
import { initMultiSelect } from '../utils.js';
import { api } from '../api.js';
import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
export function initAuditoriumWorkload() {
// Дата по умолчанию — текущий день.
const NO_BUILDING = '__no_building__';
const DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
7: 'Вс'
};
const CAPACITY_BUCKETS = [
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
{ id: 'large', name: '61 - 100 мест', match: capacity => Number(capacity || 0) > 60 && Number(capacity || 0) <= 100 },
{ id: 'xlarge', name: 'Более 100 мест', match: capacity => Number(capacity || 0) > 100 }
];
export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date');
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('-');
}
// Инициализация мультиселектов.
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 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');
}
// Заполнение фильтров начальными данными.
populateFilters();
function bindEvents() {
refreshButton?.addEventListener('click', loadScheduleForSelectedDate);
dateInput?.addEventListener('change', loadScheduleForSelectedDate);
// Отрисовка mock-данных в нужной ориентации сетки.
renderMockGrid();
}
[
['building-checkboxes', 'building-text', 'Все корпуса'],
['capacity-checkboxes', 'capacity-text', 'Любая вместимость'],
['equipment-checkboxes', 'equipment-text', 'Любое оборудование']
].forEach(([containerId, textId, emptyText]) => {
const container = document.getElementById(containerId);
container?.addEventListener('change', () => {
updateFilterText(containerId, textId, emptyText);
renderGrid();
});
});
}
function populateFilters() {
// Корпуса.
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 => `
<label class="checkbox-item">
<input type="checkbox" value="${item.id}">
<span class="checkmark"></span>
<span class="checkbox-label">${item.name}</span>
</label>
`).join('');
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 || 'Ошибка загрузки данных загруженности аудиторий');
}
}
// Вместимость.
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('');
async function loadScheduleForSelectedDate() {
const date = dateInput?.value;
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
return;
}
// Оборудование.
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('');
}
hideAlert('workload-alert');
setTableLoading('Загрузка расписания на выбранную дату...');
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
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"
];
if (!groups.length) {
lessonsByRoomSlot = new Map();
renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
return;
}
const auditoriums = [
"201", "202", "204", "205", "206", "207", "208"
];
const params = group => new URLSearchParams({
groupId: String(group.id),
startDate: date,
endDate: date
}).toString();
// 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: "Физик В.Г." },
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 || []);
"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: "Атлетов А.Р." },
lessonsByRoomSlot = groupLessons(lessons);
renderGrid();
"205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." },
};
if (failedGroupLoads > 0) {
showAlert('workload-alert', `Часть групп не загрузилась: ${failedGroupLoads}. Показаны доступные данные.`, 'error');
}
}
// В столбцах отображаются временные слоты, в строках — аудитории.
const headerRow = document.getElementById('workload-header-row');
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
</th>
`;
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
);
timeslots.forEach(time => {
const th = document.createElement('th');
th.textContent = time;
headerRow.appendChild(th);
});
updateFilterText('building-checkboxes', 'building-text', 'Все корпуса');
updateFilterText('capacity-checkboxes', 'capacity-text', 'Любая вместимость');
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
}
// Строки таблицы.
const tbody = document.getElementById('workload-tbody');
tbody.innerHTML = '';
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));
}
auditoriums.forEach((room) => {
const tr = document.createElement('tr');
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));
}
// Ячейка аудитории.
const tdRoom = document.createElement('td');
tdRoom.className = 'axis-cell';
tdRoom.textContent = room;
tr.appendChild(tdRoom);
function renderCheckboxes(containerId, items, valueFn, labelFn) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) {
container.innerHTML = '<div class="workload-filter-empty">Нет данных</div>';
return;
}
container.innerHTML = items.map(item => `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(valueFn(item))}">
<span class="checkmark"></span>
<span class="checkbox-label">${escapeHtml(labelFn(item))}</span>
</label>
`).join('');
}
// Ячейки временных слотов для аудитории.
timeslots.forEach(time => {
const td = document.createElement('td');
const scheduleKey = `${room}_${time}`;
const lesson = mockSchedule[scheduleKey];
if (lesson) {
// Карточка занятия.
td.innerHTML = `
<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>
`;
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;
}
tr.appendChild(td);
unique.set(key, {
...lesson,
groupNames: [...(lesson.groupNames || [])],
groupIds: [...(lesson.groupIds || [])]
});
});
tbody.appendChild(tr);
});
const grouped = new Map();
unique.forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
});
return grouped;
}
function renderGrid() {
const visibleClassrooms = filteredClassrooms();
renderHeader();
tbody.innerHTML = '';
if (!visibleClassrooms.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
renderSummary(0, 0, 0);
return;
}
visibleClassrooms.forEach(room => {
const tr = document.createElement('tr');
const roomCell = document.createElement('td');
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
roomCell.innerHTML = `
<strong>${escapeHtml(room.name)}</strong>
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
`;
tr.appendChild(roomCell);
timeSlots.forEach(slot => {
const td = document.createElement('td');
const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
td.innerHTML = lessons.length
? lessons.map(renderLessonCard).join('')
: '<span class="free-slot">Свободно</span>';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
const occupiedCells = countOccupiedCells(visibleClassrooms);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
}
function renderHeader() {
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
</th>
`;
timeSlots.forEach(slot => {
const th = document.createElement('th');
th.innerHTML = `
<strong>${escapeHtml(slot.orderNumber || '')} пара</strong>
<span>${escapeHtml(timeSlotLabel(slot))}</span>
${slot.scopeApplyMode === 'DEFAULT' ? '' : `<small>${escapeHtml(scopeShort(slot))}</small>`}
`;
headerRow.appendChild(th);
});
}
function renderLessonCard(lesson) {
return `
<div class="lesson-card">
<div class="lesson-subject">${escapeHtml(lesson.subjectName || 'Без дисциплины')}</div>
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
<div class="lesson-meta">${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}</div>
</div>
`;
}
function filteredClassrooms() {
const selectedBuildings = selectedValues('building-checkboxes');
const selectedCapacities = selectedValues('capacity-checkboxes');
const selectedEquipment = selectedValues('equipment-checkboxes');
return classrooms.filter(room => {
const buildingMatches = !selectedBuildings.length || selectedBuildings.includes(buildingKey(room));
const capacityMatches = !selectedCapacities.length || CAPACITY_BUCKETS
.filter(bucket => selectedCapacities.includes(bucket.id))
.some(bucket => bucket.match(room.capacity));
const equipmentMatches = !selectedEquipment.length || selectedEquipment.every(id =>
(room.equipments || []).some(item => String(item.id) === String(id))
);
return buildingMatches && capacityMatches && equipmentMatches;
});
}
function selectedValues(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => input.value);
}
function updateFilterText(containerId, textId, emptyText) {
const container = document.getElementById(containerId);
const text = document.getElementById(textId);
if (!container || !text) return;
const checked = Array.from(container.querySelectorAll('input[type="checkbox"]:checked'));
if (!checked.length) {
text.textContent = emptyText;
} else if (checked.length === 1) {
text.textContent = checked[0].closest('.checkbox-item')?.textContent.trim() || emptyText;
} else {
text.textContent = `Выбрано: ${checked.length}`;
}
}
function setTableLoading(message) {
renderHeader();
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
if (summary) summary.textContent = '';
}
function setTableError(message) {
renderHeader();
tbody.innerHTML = `<tr><td colspan="${Math.max(timeSlots.length, 1) + 1}" class="loading-row">${escapeHtml(message)}</td></tr>`;
showAlert('workload-alert', message, 'error');
}
function renderSummary(roomCount, occupiedCells, totalCells) {
if (!summary) return;
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const date = dateInput?.value ? formatDate(dateInput.value) : '-';
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
function countOccupiedCells(rooms) {
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
).length, 0);
}
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
}
function buildingKey(room) {
return room.building ? String(room.building) : NO_BUILDING;
}
function mergeNames(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
}
function mergeIds(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(id => id != null)));
}
function timeSlotLabel(slot) {
return `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}`;
}
function selectedIsoDay() {
if (!dateInput?.value) return null;
const date = new Date(`${dateInput.value}T00:00:00`);
const day = date.getDay();
return day === 0 ? 7 : day;
}
function scopeShort(slot) {
if (slot.scopeApplyMode === 'WEEKDAY') {
return dayShort(slot.scopeDayOfWeek);
}
return 'Ручн.';
}
function dayShort(dayOfWeek) {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
}

View File

@@ -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() {

View File

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

View File

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

View File

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

View 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}`;
}

View 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>

View File

@@ -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">

View File

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