подправил создание правил расписания
This commit is contained in:
@@ -191,7 +191,7 @@ public class ScheduleRuleAdminController {
|
|||||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
||||||
Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category);
|
Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
|
||||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ public class ScheduleRuleAdminController {
|
|||||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||||
slot.setParity(slotDto.parity());
|
slot.setParity(slotDto.parity());
|
||||||
slot.setTimeSlot(timeSlot);
|
slot.setTimeSlot(timeSlot);
|
||||||
slot.setSubgroup(subgroup);
|
slot.setSubgroups(subgroups);
|
||||||
slot.setTeacher(teacher);
|
slot.setTeacher(teacher);
|
||||||
slot.setClassroom(classroom);
|
slot.setClassroom(classroom);
|
||||||
slot.setLessonType(lessonType);
|
slot.setLessonType(lessonType);
|
||||||
@@ -209,21 +209,44 @@ public class ScheduleRuleAdminController {
|
|||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) {
|
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
|
||||||
if (subgroupId == null) {
|
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
|
||||||
return null;
|
if (slotDto.subgroupIds() != null) {
|
||||||
|
slotDto.subgroupIds().stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.forEach(subgroupIds::add);
|
||||||
}
|
}
|
||||||
|
if (slotDto.subgroupId() != null) {
|
||||||
|
subgroupIds.add(slotDto.subgroupId());
|
||||||
|
}
|
||||||
|
if (subgroupIds.isEmpty()) {
|
||||||
|
return new LinkedHashSet<>();
|
||||||
|
}
|
||||||
|
|
||||||
if (category != ScheduleLessonCategory.LABORATORY) {
|
if (category != ScheduleLessonCategory.LABORATORY) {
|
||||||
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
|
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
|
||||||
}
|
}
|
||||||
Subgroup subgroup = subgroupRepository.findById(subgroupId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
|
Set<Long> ruleGroupIds = rule.getGroups().stream()
|
||||||
boolean subgroupBelongsToRuleGroups = rule.getGroups().stream()
|
.map(StudentGroup::getId)
|
||||||
.anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId()));
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
if (!subgroupBelongsToRuleGroups) {
|
Set<Long> subgroupGroupIds = new HashSet<>();
|
||||||
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
|
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (Long subgroupId : subgroupIds) {
|
||||||
|
Subgroup subgroup = subgroupRepository.findById(subgroupId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
|
||||||
|
Long subgroupGroupId = subgroup.getStudentGroup().getId();
|
||||||
|
if (!ruleGroupIds.contains(subgroupGroupId)) {
|
||||||
|
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
|
||||||
|
}
|
||||||
|
if (!subgroupGroupIds.add(subgroupGroupId)) {
|
||||||
|
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
|
||||||
|
}
|
||||||
|
subgroups.add(subgroup);
|
||||||
}
|
}
|
||||||
return subgroup;
|
|
||||||
|
return subgroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validateTypeHours(ScheduleRuleDto request) {
|
private String validateTypeHours(ScheduleRuleDto request) {
|
||||||
@@ -318,6 +341,7 @@ public class ScheduleRuleAdminController {
|
|||||||
|
|
||||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||||
TimeSlot timeSlot = slot.getTimeSlot();
|
TimeSlot timeSlot = slot.getTimeSlot();
|
||||||
|
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||||
return new ScheduleRuleSlotDto(
|
return new ScheduleRuleSlotDto(
|
||||||
slot.getId(),
|
slot.getId(),
|
||||||
slot.getDayOfWeek(),
|
slot.getDayOfWeek(),
|
||||||
@@ -326,8 +350,10 @@ public class ScheduleRuleAdminController {
|
|||||||
timeSlot.getId(),
|
timeSlot.getId(),
|
||||||
timeSlot.getOrderNumber(),
|
timeSlot.getOrderNumber(),
|
||||||
formatTimeSlot(timeSlot),
|
formatTimeSlot(timeSlot),
|
||||||
slot.getSubgroupId(),
|
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
|
||||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
|
||||||
|
sortedSubgroups.stream().map(Subgroup::getId).toList(),
|
||||||
|
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
|
||||||
slot.getTeacher().getId(),
|
slot.getTeacher().getId(),
|
||||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||||
slot.getClassroom().getId(),
|
slot.getClassroom().getId(),
|
||||||
@@ -348,4 +374,16 @@ public class ScheduleRuleAdminController {
|
|||||||
private String formatTimeSlot(TimeSlot timeSlot) {
|
private String formatTimeSlot(TimeSlot timeSlot) {
|
||||||
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
|
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String formatSubgroup(Subgroup subgroup) {
|
||||||
|
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
|
||||||
|
return slot.getSubgroups().stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||||
|
.thenComparing(Subgroup::getName))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public record RenderedLessonDto(
|
|||||||
String lessonFormat,
|
String lessonFormat,
|
||||||
Long subgroupId,
|
Long subgroupId,
|
||||||
String subgroupName,
|
String subgroupName,
|
||||||
|
List<Long> subgroupIds,
|
||||||
|
List<String> subgroupNames,
|
||||||
List<Long> groupIds,
|
List<Long> groupIds,
|
||||||
List<String> groupNames,
|
List<String> groupNames,
|
||||||
String activityType,
|
String activityType,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.magistr.app.dto;
|
|||||||
|
|
||||||
import com.magistr.app.model.ScheduleParity;
|
import com.magistr.app.model.ScheduleParity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public record ScheduleRuleSlotDto(
|
public record ScheduleRuleSlotDto(
|
||||||
Long id,
|
Long id,
|
||||||
Integer dayOfWeek,
|
Integer dayOfWeek,
|
||||||
@@ -12,6 +14,8 @@ public record ScheduleRuleSlotDto(
|
|||||||
String timeSlotLabel,
|
String timeSlotLabel,
|
||||||
Long subgroupId,
|
Long subgroupId,
|
||||||
String subgroupName,
|
String subgroupName,
|
||||||
|
List<Long> subgroupIds,
|
||||||
|
List<String> subgroupNames,
|
||||||
Long teacherId,
|
Long teacherId,
|
||||||
String teacherName,
|
String teacherName,
|
||||||
Long classroomId,
|
Long classroomId,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "schedule_rule_slots")
|
@Table(name = "schedule_rule_slots")
|
||||||
public class ScheduleRuleSlot {
|
public class ScheduleRuleSlot {
|
||||||
@@ -25,9 +29,13 @@ public class ScheduleRuleSlot {
|
|||||||
@JoinColumn(name = "time_slot_id", nullable = false)
|
@JoinColumn(name = "time_slot_id", nullable = false)
|
||||||
private TimeSlot timeSlot;
|
private TimeSlot timeSlot;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToMany
|
||||||
@JoinColumn(name = "subgroup_id")
|
@JoinTable(
|
||||||
private Subgroup subgroup;
|
name = "schedule_rule_slot_subgroups",
|
||||||
|
joinColumns = @JoinColumn(name = "schedule_rule_slot_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "subgroup_id")
|
||||||
|
)
|
||||||
|
private Set<Subgroup> subgroups = new LinkedHashSet<>();
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
@ManyToOne(optional = false)
|
||||||
@JoinColumn(name = "teacher_id", nullable = false)
|
@JoinColumn(name = "teacher_id", nullable = false)
|
||||||
@@ -85,15 +93,41 @@ public class ScheduleRuleSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Long getSubgroupId() {
|
public Long getSubgroupId() {
|
||||||
return subgroup == null ? null : subgroup.getId();
|
return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Subgroup getSubgroup() {
|
public Subgroup getSubgroup() {
|
||||||
return subgroup;
|
return subgroups.stream().findFirst().orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSubgroup(Subgroup subgroup) {
|
public void setSubgroup(Subgroup subgroup) {
|
||||||
this.subgroup = subgroup;
|
subgroups.clear();
|
||||||
|
if (subgroup != null) {
|
||||||
|
subgroups.add(subgroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Subgroup> getSubgroups() {
|
||||||
|
return subgroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubgroups(Set<Subgroup> subgroups) {
|
||||||
|
this.subgroups.clear();
|
||||||
|
if (subgroups != null) {
|
||||||
|
this.subgroups.addAll(subgroups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> getSubgroupIds() {
|
||||||
|
return subgroups.stream()
|
||||||
|
.map(Subgroup::getId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getSubgroupNames() {
|
||||||
|
return subgroups.stream()
|
||||||
|
.map(Subgroup::getName)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public User getTeacher() {
|
public User getTeacher() {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
left join fetch slots.timeSlot
|
||||||
left join fetch slots.subgroup subgroup
|
left join fetch slots.subgroups slotSubgroups
|
||||||
left join fetch subgroup.studentGroup
|
left join fetch slotSubgroups.studentGroup
|
||||||
left join fetch slots.teacher
|
left join fetch slots.teacher
|
||||||
left join fetch slots.classroom
|
left join fetch slots.classroom
|
||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
@@ -42,8 +42,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
left join fetch slots.timeSlot
|
||||||
left join fetch slots.subgroup subgroup
|
left join fetch slots.subgroups slotSubgroups
|
||||||
left join fetch subgroup.studentGroup
|
left join fetch slotSubgroups.studentGroup
|
||||||
left join fetch slots.teacher
|
left join fetch slots.teacher
|
||||||
left join fetch slots.classroom
|
left join fetch slots.classroom
|
||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
@@ -64,8 +64,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
left join fetch slots.timeSlot
|
||||||
left join fetch slots.subgroup subgroup
|
left join fetch slots.subgroups slotSubgroups
|
||||||
left join fetch subgroup.studentGroup
|
left join fetch slotSubgroups.studentGroup
|
||||||
left join fetch slots.teacher
|
left join fetch slots.teacher
|
||||||
left join fetch slots.classroom
|
left join fetch slots.classroom
|
||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
@@ -82,8 +82,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
left join fetch slots.timeSlot
|
||||||
left join fetch slots.subgroup subgroup
|
left join fetch slots.subgroups slotSubgroups
|
||||||
left join fetch subgroup.studentGroup
|
left join fetch slotSubgroups.studentGroup
|
||||||
left join fetch slots.teacher
|
left join fetch slots.teacher
|
||||||
left join fetch slots.classroom
|
left join fetch slots.classroom
|
||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ import org.springframework.data.repository.query.Param;
|
|||||||
|
|
||||||
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
|
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
|
||||||
|
|
||||||
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot where slot.subgroup.id = :subgroupId")
|
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
|
||||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,22 +201,54 @@ public class ScheduleGeneratorService {
|
|||||||
if (totalHours <= 0) {
|
if (totalHours <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups);
|
List<LessonScope> lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups);
|
||||||
if (lessonGroups.isEmpty()) {
|
if (lessonScopes.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String consumptionBucket = consumptionBucket(category, slot);
|
|
||||||
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
||||||
consumptionBucket,
|
for (LessonScope scope : lessonScopes) {
|
||||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId())
|
String consumptionBucket = consumptionBucket(category, scope.subgroupId());
|
||||||
);
|
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
||||||
if (consumedHours >= totalHours) {
|
consumptionBucket,
|
||||||
|
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
|
||||||
|
);
|
||||||
|
if (consumedHours >= totalHours) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||||
|
activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeScopes.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
|
||||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups,
|
Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
|
||||||
|
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
|
||||||
|
activeScopes.forEach(scope -> {
|
||||||
|
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
|
||||||
|
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
|
||||||
|
});
|
||||||
|
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
|
||||||
|
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
|
||||||
|
int consumedHours = activeScopes.stream()
|
||||||
|
.mapToInt(ActiveLessonScope::consumedHours)
|
||||||
|
.min()
|
||||||
|
.orElse(0);
|
||||||
|
int remainingAfterLesson = activeScopes.stream()
|
||||||
|
.mapToInt(ActiveLessonScope::remainingAfterLesson)
|
||||||
|
.min()
|
||||||
|
.orElse(0);
|
||||||
|
|
||||||
|
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||||
totalHours, consumedHours, remainingAfterLesson));
|
totalHours, consumedHours, remainingAfterLesson));
|
||||||
consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT);
|
for (ActiveLessonScope scope : activeScopes) {
|
||||||
|
consumedHoursByBucket.put(
|
||||||
|
consumptionBucket(category, scope.scope().subgroupId()),
|
||||||
|
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,8 +304,8 @@ public class ScheduleGeneratorService {
|
|||||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
||||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
||||||
.filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId))
|
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
|
||||||
.filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty())
|
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
|
||||||
.count();
|
.count();
|
||||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||||
if (consumed >= rule.academicHoursFor(category)) {
|
if (consumed >= rule.academicHoursFor(category)) {
|
||||||
@@ -283,26 +315,51 @@ public class ScheduleGeneratorService {
|
|||||||
return consumed;
|
return consumed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) {
|
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
||||||
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId());
|
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) {
|
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||||
if (slot.getSubgroup() == null) {
|
ScheduleLessonCategory category,
|
||||||
return eligibleGroups;
|
List<StudentGroup> eligibleGroups) {
|
||||||
|
if (category != ScheduleLessonCategory.LABORATORY || slot.getSubgroups().isEmpty()) {
|
||||||
|
return eligibleGroups.isEmpty()
|
||||||
|
? List.of()
|
||||||
|
: List.of(new LessonScope(eligibleGroups, List.of(), null));
|
||||||
}
|
}
|
||||||
Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId();
|
|
||||||
return eligibleGroups.stream()
|
Set<Long> eligibleGroupIds = eligibleGroups.stream()
|
||||||
.filter(group -> Objects.equals(group.getId(), subgroupGroupId))
|
.map(StudentGroup::getId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<Long, StudentGroup> eligibleGroupById = eligibleGroups.stream()
|
||||||
|
.collect(Collectors.toMap(StudentGroup::getId, group -> group));
|
||||||
|
|
||||||
|
return slot.getSubgroups().stream()
|
||||||
|
.filter(subgroup -> eligibleGroupIds.contains(subgroup.getStudentGroup().getId()))
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||||
|
.thenComparing(Subgroup::getName))
|
||||||
|
.map(subgroup -> new LessonScope(
|
||||||
|
List.of(eligibleGroupById.get(subgroup.getStudentGroup().getId())),
|
||||||
|
List.of(subgroup),
|
||||||
|
subgroup.getId()
|
||||||
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record LessonScope(List<StudentGroup> groups, List<Subgroup> subgroups, Long subgroupId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
|
||||||
|
}
|
||||||
|
|
||||||
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||||
ScheduleRuleSlot slot,
|
ScheduleRuleSlot slot,
|
||||||
LocalDate date,
|
LocalDate date,
|
||||||
int weekNumber,
|
int weekNumber,
|
||||||
ScheduleParity dateParity,
|
ScheduleParity dateParity,
|
||||||
List<StudentGroup> groups,
|
List<StudentGroup> groups,
|
||||||
|
List<Subgroup> subgroups,
|
||||||
int lessonTypeAcademicHours,
|
int lessonTypeAcademicHours,
|
||||||
int consumedBeforeLesson,
|
int consumedBeforeLesson,
|
||||||
int remainingAfterLesson) {
|
int remainingAfterLesson) {
|
||||||
@@ -310,6 +367,11 @@ public class ScheduleGeneratorService {
|
|||||||
List<StudentGroup> sortedGroups = groups.stream()
|
List<StudentGroup> sortedGroups = groups.stream()
|
||||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||||
.toList();
|
.toList();
|
||||||
|
List<Subgroup> sortedSubgroups = subgroups.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||||
|
.thenComparing(Subgroup::getName))
|
||||||
|
.toList();
|
||||||
|
|
||||||
return new RenderedLessonDto(
|
return new RenderedLessonDto(
|
||||||
rule.getId(),
|
rule.getId(),
|
||||||
@@ -332,8 +394,10 @@ public class ScheduleGeneratorService {
|
|||||||
slot.getLessonType().getId(),
|
slot.getLessonType().getId(),
|
||||||
slot.getLessonType().getLessonType(),
|
slot.getLessonType().getLessonType(),
|
||||||
slot.getLessonFormat(),
|
slot.getLessonFormat(),
|
||||||
slot.getSubgroupId(),
|
sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null,
|
||||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
sortedSubgroups.size() == 1 ? formatSubgroup(sortedSubgroups.get(0)) : null,
|
||||||
|
sortedSubgroups.stream().map(Subgroup::getId).toList(),
|
||||||
|
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
|
||||||
sortedGroups.stream().map(StudentGroup::getId).toList(),
|
sortedGroups.stream().map(StudentGroup::getId).toList(),
|
||||||
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
|
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
|
||||||
"Т",
|
"Т",
|
||||||
@@ -343,6 +407,10 @@ public class ScheduleGeneratorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String formatSubgroup(Subgroup subgroup) {
|
||||||
|
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||||
|
}
|
||||||
|
|
||||||
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
||||||
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
||||||
return baseSlot;
|
return baseSlot;
|
||||||
|
|||||||
@@ -515,7 +515,6 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
|||||||
day_of_week INT NOT NULL,
|
day_of_week INT NOT NULL,
|
||||||
parity VARCHAR(10) NOT NULL,
|
parity VARCHAR(10) NOT NULL,
|
||||||
time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
|
time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
|
||||||
subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL,
|
|
||||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
||||||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
||||||
@@ -528,34 +527,84 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
|
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
|
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
|
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_subgroup ON schedule_rule_slots(subgroup_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
|
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroup()
|
CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
|
||||||
|
schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE,
|
||||||
|
subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (schedule_rule_slot_id, subgroup_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
lesson_type_name VARCHAR(50);
|
lesson_type_name VARCHAR(50);
|
||||||
|
rule_id BIGINT;
|
||||||
|
subgroup_group_id BIGINT;
|
||||||
|
has_group_duplicate BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.subgroup_id IS NULL THEN
|
SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
|
||||||
RETURN NEW;
|
FROM schedule_rule_slots slot
|
||||||
END IF;
|
JOIN lesson_types type ON type.id = slot.lesson_type_id
|
||||||
|
WHERE slot.id = NEW.schedule_rule_slot_id;
|
||||||
SELECT name INTO lesson_type_name
|
|
||||||
FROM lesson_types
|
|
||||||
WHERE id = NEW.lesson_type_id;
|
|
||||||
|
|
||||||
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
|
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
|
||||||
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
|
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
SELECT group_id INTO subgroup_group_id
|
||||||
|
FROM subgroups
|
||||||
|
WHERE id = NEW.subgroup_id;
|
||||||
|
|
||||||
|
IF subgroup_group_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Подгруппа не найдена';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM schedule_rule_groups rule_group
|
||||||
|
WHERE rule_group.schedule_rule_id = rule_id
|
||||||
|
AND rule_group.group_id = subgroup_group_id
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM schedule_rule_slot_subgroups slot_subgroup
|
||||||
|
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
|
||||||
|
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
|
||||||
|
AND subgroup.group_id = subgroup_group_id
|
||||||
|
AND NOT (
|
||||||
|
slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id
|
||||||
|
AND slot_subgroup.subgroup_id = OLD.subgroup_id
|
||||||
|
)
|
||||||
|
) INTO has_group_duplicate;
|
||||||
|
ELSE
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM schedule_rule_slot_subgroups slot_subgroup
|
||||||
|
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
|
||||||
|
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
|
||||||
|
AND subgroup.group_id = subgroup_group_id
|
||||||
|
) INTO has_group_duplicate;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF has_group_duplicate THEN
|
||||||
|
RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы';
|
||||||
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
CREATE TRIGGER validate_schedule_rule_slot_subgroup
|
CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
||||||
BEFORE INSERT OR UPDATE ON schedule_rule_slots
|
BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup();
|
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||||
|
|
||||||
CREATE TEMP TABLE tmp_default_rules AS
|
CREATE TEMP TABLE tmp_default_rules AS
|
||||||
SELECT
|
SELECT
|
||||||
@@ -712,6 +761,7 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к
|
|||||||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||||||
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
||||||
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
|
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
|
||||||
|
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
|
||||||
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
||||||
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
||||||
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
"lessonFormat": "Очно",
|
"lessonFormat": "Очно",
|
||||||
"subgroupId": null,
|
"subgroupId": null,
|
||||||
"subgroupName": null,
|
"subgroupName": null,
|
||||||
|
"subgroupIds": [],
|
||||||
|
"subgroupNames": [],
|
||||||
"groupIds": [1],
|
"groupIds": [1],
|
||||||
"groupNames": ["ИВТ-21-1"],
|
"groupNames": ["ИВТ-21-1"],
|
||||||
"activityType": "Т",
|
"activityType": "Т",
|
||||||
@@ -292,7 +294,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
"dayOfWeek": 1,
|
"dayOfWeek": 1,
|
||||||
"parity": "BOTH",
|
"parity": "BOTH",
|
||||||
"timeSlotId": 3,
|
"timeSlotId": 3,
|
||||||
"subgroupId": null,
|
"subgroupId": null,
|
||||||
|
"subgroupIds": [],
|
||||||
"teacherId": 2,
|
"teacherId": 2,
|
||||||
"classroomId": 1,
|
"classroomId": 1,
|
||||||
"lessonTypeId": 1,
|
"lessonTypeId": 1,
|
||||||
@@ -314,7 +317,7 @@ CRUD доступен по:
|
|||||||
|
|
||||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации.
|
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
||||||
|
|
||||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||||
|
|
||||||
@@ -460,7 +463,7 @@ CRUD доступен по:
|
|||||||
|
|
||||||
### Подгруппы группы
|
### Подгруппы группы
|
||||||
|
|
||||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`.
|
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||||||
|
|
||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@
|
|||||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||||
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа |
|
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||||
|
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||||
|
|
||||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||||
1. Определяет семестр для каждой даты диапазона.
|
1. Определяет семестр для каждой даты диапазона.
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
|
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
|
||||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||||
|
|
||||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`.
|
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||||
|
|
||||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||||
- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила.
|
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -226,12 +226,16 @@ erDiagram
|
|||||||
INT day_of_week
|
INT day_of_week
|
||||||
VARCHAR parity
|
VARCHAR parity
|
||||||
BIGINT time_slot_id FK
|
BIGINT time_slot_id FK
|
||||||
BIGINT subgroup_id FK
|
|
||||||
BIGINT teacher_id FK
|
BIGINT teacher_id FK
|
||||||
BIGINT classroom_id FK
|
BIGINT classroom_id FK
|
||||||
BIGINT lesson_type_id FK
|
BIGINT lesson_type_id FK
|
||||||
VARCHAR lesson_format
|
VARCHAR lesson_format
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schedule_rule_slot_subgroups {
|
||||||
|
BIGINT schedule_rule_slot_id FK,PK
|
||||||
|
BIGINT subgroup_id FK,PK
|
||||||
|
}
|
||||||
|
|
||||||
departments ||--o{ users : "department_id"
|
departments ||--o{ users : "department_id"
|
||||||
departments ||--o{ student_groups : "department_id"
|
departments ||--o{ student_groups : "department_id"
|
||||||
@@ -265,10 +269,11 @@ erDiagram
|
|||||||
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
||||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||||
|
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
|
||||||
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
|
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
|
||||||
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
||||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||||
subgroups ||--o{ schedule_rule_slots : "subgroup_id"
|
subgroups ||--o{ schedule_rule_slot_subgroups : "subgroup_id"
|
||||||
users ||--o{ schedule_rule_slots : "teacher_id"
|
users ||--o{ schedule_rule_slots : "teacher_id"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -534,13 +539,18 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
||||||
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
||||||
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
|
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
|
||||||
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы |
|
|
||||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||||
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
|
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
|
||||||
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
|
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
|
||||||
|
|
||||||
`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`.
|
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `schedule_rule_slot_id` | BIGINT PK, FK → schedule_rule_slots (CASCADE) | Слот правила |
|
||||||
|
| `subgroup_id` | BIGINT PK, FK → subgroups (CASCADE) | Подгруппа |
|
||||||
|
|
||||||
|
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
| `time_slots` | Настраиваемая сетка пар |
|
| `time_slots` | Настраиваемая сетка пар |
|
||||||
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||||
| `schedule_rule_groups` | Группы правила |
|
| `schedule_rule_groups` | Группы правила |
|
||||||
| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу |
|
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||||
|
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||||
|
|
||||||
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
|
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
||||||
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
||||||
|
|
||||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; один лабораторный слот может включать разные подгруппы разных групп. Лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
|
|
||||||
- конструктор правил динамического расписания;
|
- конструктор правил динамического расписания;
|
||||||
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
||||||
- выбор подгруппы только для лабораторного слота;
|
- выбор подгрупп только для лабораторного слота;
|
||||||
- управление временными слотами;
|
- управление временными слотами;
|
||||||
- учебные годы и семестры;
|
- учебные годы и семестры;
|
||||||
- CRUD календарных графиков;
|
- CRUD календарных графиков;
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ frontend/
|
|||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||||
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
|
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. В слоте подгруппа доступна только при выборе лабораторной работы; для лекций и практик селект подгруппы заблокирован. Из календарной системы здесь используется список семестров для выбора периода действия правила.
|
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
|
||||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
||||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||||
@@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() {
|
|||||||
- навигация по неделям: предыдущая, текущая, следующая;
|
- навигация по неделям: предыдущая, текущая, следующая;
|
||||||
- выбор даты через `input[type="date"]`;
|
- выбор даты через `input[type="date"]`;
|
||||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||||
- отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила.
|
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
||||||
|
|
||||||
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
|
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() {
|
|||||||
- селект группы с сохранением выбора в `localStorage.studentGroupId`;
|
- селект группы с сохранением выбора в `localStorage.studentGroupId`;
|
||||||
- недельная сетка по дням;
|
- недельная сетка по дням;
|
||||||
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||||
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы.
|
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- календарный учебный график по специальности, профилю, форме обучения и учебному году;
|
- календарный учебный график по специальности, профилю, форме обучения и учебному году;
|
||||||
- правила расписания дисциплин;
|
- правила расписания дисциплин;
|
||||||
- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных;
|
- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональные подгруппы для лабораторных;
|
||||||
- назначение конкретного календарного графика учебной группе на учебный год.
|
- назначение конкретного календарного графика учебной группе на учебный год.
|
||||||
|
|
||||||
При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются.
|
При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются.
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
- `time_slots` задаёт сетку пар для тенанта.
|
- `time_slots` задаёт сетку пар для тенанта.
|
||||||
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
|
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
|
||||||
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
||||||
- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ.
|
- `schedule_rule_slots` хранит шаблонные занятия правила, а `schedule_rule_slot_subgroups` связывает лабораторный слот с одной или несколькими подгруппами.
|
||||||
|
|
||||||
## Алгоритм генерации
|
## Алгоритм генерации
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||||
|
|
||||||
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы.
|
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к нескольким подгруппам разных групп, он выводится как совместное занятие этих групп, а часы списываются отдельно по каждой подгруппе.
|
||||||
|
|
||||||
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.
|
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||||
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||||
- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы.
|
- [x] Добавлены подгруппы для лабораторных слотов, включая выбор разных подгрупп разных групп в одном поточном лабораторном слоте; лекции и практики не делятся на подгруппы.
|
||||||
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
||||||
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
||||||
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
||||||
|
|||||||
@@ -114,14 +114,18 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-checkbox-grid {
|
.schedule-group-select {
|
||||||
display: grid;
|
max-width: 520px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
}
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem;
|
.schedule-group-select .select-text {
|
||||||
background: var(--bg-input);
|
overflow: hidden;
|
||||||
border: 1px solid var(--bg-card-border);
|
text-overflow: ellipsis;
|
||||||
border-radius: var(--radius-sm);
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-group-checkboxes {
|
||||||
|
max-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-hours-panel {
|
.schedule-hours-panel {
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
grid-template-columns: repeat(7, minmax(125px, 1fr)) auto;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -173,6 +177,20 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-slot-row.has-subgroup {
|
||||||
|
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-subgroup-multi .select-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-subgroup-multi .dropdown-menu {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-slot-summary,
|
.schedule-slot-summary,
|
||||||
.schedule-semester-line {
|
.schedule-semester-line {
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
@@ -479,7 +497,8 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row,
|
||||||
|
.schedule-slot-row.has-subgroup {
|
||||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +518,8 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row,
|
||||||
|
.schedule-slot-row.has-subgroup {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const SEMESTER_LABELS = {
|
|||||||
spring: 'весенний'
|
spring: 'весенний'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||||
|
|
||||||
const LESSON_TYPE_LIMITS = [
|
const LESSON_TYPE_LIMITS = [
|
||||||
{
|
{
|
||||||
key: 'lecture',
|
key: 'lecture',
|
||||||
@@ -59,7 +61,10 @@ export async function initSchedule() {
|
|||||||
hours: document.getElementById(config.hoursInputId),
|
hours: document.getElementById(config.hoursInputId),
|
||||||
startWeek: document.getElementById(config.startWeekInputId)
|
startWeek: document.getElementById(config.startWeekInputId)
|
||||||
}]));
|
}]));
|
||||||
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
|
const ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
|
||||||
|
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
|
||||||
|
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
|
||||||
|
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
|
||||||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||||||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||||
@@ -94,16 +99,44 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||||
const removeButton = event.target.closest('.schedule-slot-remove');
|
const removeButton = event.target.closest('.schedule-slot-remove');
|
||||||
if (!removeButton) return;
|
const subgroupBox = event.target.closest('.slot-subgroup-box');
|
||||||
const slots = readRuleSlots(false);
|
const subgroupMenu = event.target.closest('.slot-subgroup-menu');
|
||||||
slots.splice(Number(removeButton.dataset.index), 1);
|
|
||||||
renderRuleSlots(slots.length ? slots : [{}]);
|
if (removeButton) {
|
||||||
|
const slots = readRuleSlots(false);
|
||||||
|
slots.splice(Number(removeButton.dataset.index), 1);
|
||||||
|
renderRuleSlots(slots.length ? slots : [{}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subgroupBox) {
|
||||||
|
toggleSlotSubgroupMenu(event, subgroupBox);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subgroupMenu) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
|
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
|
||||||
|
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
|
||||||
|
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
|
||||||
|
ruleGroupCheckboxes.addEventListener('change', () => {
|
||||||
|
updateRuleGroupsText();
|
||||||
|
renderRuleSlots(readRuleSlots(false));
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
closeRuleGroupsMenu();
|
||||||
|
closeSlotSubgroupMenus();
|
||||||
|
});
|
||||||
ruleSlotsContainer.addEventListener('change', (event) => {
|
ruleSlotsContainer.addEventListener('change', (event) => {
|
||||||
if (event.target.closest('.slot-lesson-type')) {
|
if (event.target.closest('.slot-lesson-type')) {
|
||||||
renderRuleSlots(readRuleSlots(false));
|
renderRuleSlots(readRuleSlots(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
||||||
|
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ruleForm.addEventListener('submit', saveRule);
|
ruleForm.addEventListener('submit', saveRule);
|
||||||
@@ -167,7 +200,7 @@ export async function initSchedule() {
|
|||||||
slot.dayName,
|
slot.dayName,
|
||||||
slot.timeSlotLabel,
|
slot.timeSlotLabel,
|
||||||
parity,
|
parity,
|
||||||
slot.subgroupName,
|
slotSubgroupNames(slot).join(', '),
|
||||||
slot.teacherName,
|
slot.teacherName,
|
||||||
slot.classroomName,
|
slot.classroomName,
|
||||||
slot.lessonTypeName,
|
slot.lessonTypeName,
|
||||||
@@ -232,8 +265,7 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRulePayload() {
|
function buildRulePayload() {
|
||||||
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
const groupIds = selectedRuleGroupIds();
|
||||||
.map(input => Number(input.value));
|
|
||||||
const slots = readRuleSlots(true);
|
const slots = readRuleSlots(true);
|
||||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||||
@@ -277,29 +309,31 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function renderRuleGroups(selectedIds) {
|
function renderRuleGroups(selectedIds) {
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
|
ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
|
||||||
|
updateRuleGroupsText();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = new Set((selectedIds || []).map(String));
|
const selected = new Set((selectedIds || []).map(String));
|
||||||
ruleGroupsContainer.innerHTML = groups.map(group => `
|
ruleGroupCheckboxes.innerHTML = groups.map(group => `
|
||||||
<label class="checkbox-item">
|
<label class="checkbox-item">
|
||||||
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
|
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
<span>${escapeHtml(group.name)}</span>
|
<span>${escapeHtml(group.name)}</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
updateRuleGroupsText();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRuleSlots(slots) {
|
function renderRuleSlots(slots) {
|
||||||
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
|
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
|
||||||
<div class="schedule-slot-row" data-index="${index}">
|
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
|
||||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||||
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</select></div>
|
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,13 +343,14 @@ export async function initSchedule() {
|
|||||||
function readRuleSlots(validate) {
|
function readRuleSlots(validate) {
|
||||||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||||||
return rows.map((row, index) => {
|
return rows.map((row, index) => {
|
||||||
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
|
const subgroupIds = readSlotSubgroupIds(row);
|
||||||
const slot = {
|
const slot = {
|
||||||
id: null,
|
id: null,
|
||||||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||||||
parity: row.querySelector('.slot-parity').value,
|
parity: row.querySelector('.slot-parity').value,
|
||||||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||||||
subgroupId: subgroupValue ? Number(subgroupValue) : null,
|
subgroupId: subgroupIds[0] || null,
|
||||||
|
subgroupIds,
|
||||||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||||
@@ -370,18 +405,26 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function validateSlotSubgroup(slot, index) {
|
function validateSlotSubgroup(slot, index) {
|
||||||
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
||||||
if (slot.subgroupId && typeKey !== 'laboratory') {
|
const subgroupIds = slotSubgroupIds(slot);
|
||||||
|
if (subgroupIds.length && typeKey !== 'laboratory') {
|
||||||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||||||
}
|
}
|
||||||
if (!slot.subgroupId) return;
|
if (!subgroupIds.length) return;
|
||||||
|
|
||||||
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
|
|
||||||
if (!selectedSubgroup) {
|
|
||||||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
|
||||||
}
|
|
||||||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||||||
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
const subgroupGroupIds = new Set();
|
||||||
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
for (const subgroupId of subgroupIds) {
|
||||||
|
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
|
||||||
|
if (!selectedSubgroup) {
|
||||||
|
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||||||
|
}
|
||||||
|
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
||||||
|
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
||||||
|
}
|
||||||
|
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
|
||||||
|
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
|
||||||
|
}
|
||||||
|
subgroupGroupIds.add(String(selectedSubgroup.groupId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,22 +460,196 @@ export async function initSchedule() {
|
|||||||
if (!isLaboratorySlot(slot)) {
|
if (!isLaboratorySlot(slot)) {
|
||||||
return '<option value="">Только для лабораторных</option>';
|
return '<option value="">Только для лабораторных</option>';
|
||||||
}
|
}
|
||||||
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
|
const selectedGroupIds = selectedRuleGroupIds();
|
||||||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
if (selectedGroupIds.length !== 1) {
|
||||||
|
return '<option value="">Выберите группу правила</option>';
|
||||||
|
}
|
||||||
|
const selected = slotSubgroupIds(slot)[0] == null
|
||||||
|
? WHOLE_GROUP_SUBGROUP_VALUE
|
||||||
|
: String(slotSubgroupIds(slot)[0]);
|
||||||
|
const selectedGroups = new Set(selectedGroupIds.map(String));
|
||||||
const matchingSubgroups = subgroups
|
const matchingSubgroups = subgroups
|
||||||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||||||
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
||||||
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
|
return `<option value="${WHOLE_GROUP_SUBGROUP_VALUE}" ${selected === WHOLE_GROUP_SUBGROUP_VALUE ? 'selected' : ''}>Вся группа</option>` + matchingSubgroups.map(subgroup => {
|
||||||
const value = String(subgroup.id);
|
const value = String(subgroup.id);
|
||||||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSubgroupField(slot, index) {
|
||||||
|
return selectedRuleGroupIds().length > 1
|
||||||
|
? renderSubgroupMultiSelect(slot, index)
|
||||||
|
: `<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup">${subgroupOptions(slot)}</select></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubgroupMultiSelect(slot, index) {
|
||||||
|
const selected = new Set(slotSubgroupIds(slot).map(String));
|
||||||
|
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||||||
|
const matchingSubgroups = subgroups
|
||||||
|
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||||||
|
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
||||||
|
const items = matchingSubgroups.length
|
||||||
|
? matchingSubgroups.map(subgroup => {
|
||||||
|
const value = String(subgroup.id);
|
||||||
|
return `
|
||||||
|
<label class="checkbox-item">
|
||||||
|
<input type="checkbox" value="${escapeHtml(value)}" ${selected.has(value) ? 'checked' : ''}>
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span>${escapeHtml(subgroupLabel(subgroup))}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('')
|
||||||
|
: '<div class="loading-row">Нет подгрупп выбранных групп</div>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="form-group slot-subgroup-multi">
|
||||||
|
<label>Подгруппы</label>
|
||||||
|
<div class="custom-multi-select">
|
||||||
|
<div class="select-box slot-subgroup-box" role="button" tabindex="0" aria-expanded="false">
|
||||||
|
<span class="select-text">${escapeHtml(slotSubgroupText(slot))}</span>
|
||||||
|
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu slot-subgroup-menu">
|
||||||
|
<div class="checkbox-group-vertical slot-subgroup-checkboxes" data-index="${index}">
|
||||||
|
${items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSlotSubgroupIds(row) {
|
||||||
|
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
|
||||||
|
const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
|
||||||
|
.map(input => Number(input.value))
|
||||||
|
.filter(Boolean);
|
||||||
|
return checkboxValues.length
|
||||||
|
? checkboxValues
|
||||||
|
: (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotSubgroupIds(slot) {
|
||||||
|
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
|
||||||
|
return slot.subgroupIds.map(Number).filter(Boolean);
|
||||||
|
}
|
||||||
|
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotSubgroupNames(slot) {
|
||||||
|
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
|
||||||
|
return slot.subgroupNames.filter(Boolean);
|
||||||
|
}
|
||||||
|
return slot.subgroupName ? [slot.subgroupName] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotSubgroupText(slot) {
|
||||||
|
const selectedIds = slotSubgroupIds(slot);
|
||||||
|
if (!selectedIds.length) return 'Все выбранные группы';
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
|
||||||
|
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||||||
|
}
|
||||||
|
return `Выбрано подгрупп: ${selectedIds.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
function selectedRuleGroupIds() {
|
function selectedRuleGroupIds() {
|
||||||
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
|
||||||
.map(input => Number(input.value));
|
.map(input => Number(input.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRuleGroupsMenu(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const isOpen = ruleGroupsMenu.classList.contains('open');
|
||||||
|
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
|
||||||
|
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
|
||||||
|
if (isOpen) {
|
||||||
|
closeRuleGroupsMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ruleGroupsMenu.classList.add('open');
|
||||||
|
ruleGroupsBox.classList.add('active');
|
||||||
|
ruleGroupsBox.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRuleGroupsKeydown(event) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleRuleGroupsMenu(event);
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeRuleGroupsMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRuleGroupsMenu() {
|
||||||
|
ruleGroupsMenu.classList.remove('open');
|
||||||
|
ruleGroupsBox.classList.remove('active');
|
||||||
|
ruleGroupsBox.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSlotSubgroupMenu(event, box) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
const isOpen = menu.classList.contains('open');
|
||||||
|
document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
|
||||||
|
document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
|
||||||
|
if (isOpen) {
|
||||||
|
box.setAttribute('aria-expanded', 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
menu.classList.add('open');
|
||||||
|
box.classList.add('active');
|
||||||
|
box.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSlotSubgroupMenus() {
|
||||||
|
ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
|
||||||
|
ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
|
||||||
|
box.classList.remove('active');
|
||||||
|
box.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlotSubgroupText(row) {
|
||||||
|
if (!row) return;
|
||||||
|
const text = row.querySelector('.slot-subgroup-box .select-text');
|
||||||
|
if (!text) return;
|
||||||
|
const selectedIds = readSlotSubgroupIds(row);
|
||||||
|
if (!selectedIds.length) {
|
||||||
|
text.textContent = 'Все выбранные группы';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
|
||||||
|
text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRuleGroupsText() {
|
||||||
|
const selectedIds = selectedRuleGroupIds();
|
||||||
|
if (!groups.length) {
|
||||||
|
ruleGroupsText.textContent = 'Нет групп';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedIds.length) {
|
||||||
|
ruleGroupsText.textContent = 'Выберите группы...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
|
||||||
|
ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toTimeSlotOption(slot) {
|
function toTimeSlotOption(slot) {
|
||||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,19 @@
|
|||||||
|
|
||||||
<div class="schedule-groups-panel">
|
<div class="schedule-groups-panel">
|
||||||
<label class="schedule-section-label">Группы</label>
|
<label class="schedule-section-label">Группы</label>
|
||||||
<div id="schedule-rule-groups" class="schedule-checkbox-grid"></div>
|
<div id="schedule-rule-groups" class="custom-multi-select schedule-group-select">
|
||||||
|
<div class="select-box" id="schedule-rule-groups-box" role="button" tabindex="0" aria-expanded="false">
|
||||||
|
<span class="select-text" id="schedule-rule-groups-text">Выберите группы...</span>
|
||||||
|
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" id="schedule-rule-groups-menu">
|
||||||
|
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="schedule-slots-panel">
|
<div class="schedule-slots-panel">
|
||||||
|
|||||||
@@ -416,13 +416,16 @@
|
|||||||
|
|
||||||
function renderLesson(lesson) {
|
function renderLesson(lesson) {
|
||||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||||
|
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||||
|
? lesson.subgroupNames.join(', ')
|
||||||
|
: lesson.subgroupName;
|
||||||
return `
|
return `
|
||||||
<div class="lesson">
|
<div class="lesson">
|
||||||
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
|
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
|
||||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
||||||
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
|
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
|
||||||
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
|
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
|
||||||
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
|
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
|
||||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||||
|
|||||||
@@ -431,13 +431,16 @@
|
|||||||
|
|
||||||
function renderLesson(lesson) {
|
function renderLesson(lesson) {
|
||||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||||
|
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||||
|
? lesson.subgroupNames.join(', ')
|
||||||
|
: lesson.subgroupName;
|
||||||
return `
|
return `
|
||||||
<div class="lesson">
|
<div class="lesson">
|
||||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
|
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
|
||||||
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
|
||||||
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
|
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
|
||||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user