подправил создание правил расписания
This commit is contained in:
@@ -191,7 +191,7 @@ public class ScheduleRuleAdminController {
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
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()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public class ScheduleRuleAdminController {
|
||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||
slot.setParity(slotDto.parity());
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setSubgroup(subgroup);
|
||||
slot.setSubgroups(subgroups);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
@@ -209,21 +209,44 @@ public class ScheduleRuleAdminController {
|
||||
return slot;
|
||||
}
|
||||
|
||||
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) {
|
||||
if (subgroupId == null) {
|
||||
return null;
|
||||
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
|
||||
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
|
||||
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) {
|
||||
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
|
||||
}
|
||||
|
||||
Set<Long> ruleGroupIds = rule.getGroups().stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<Long> subgroupGroupIds = new HashSet<>();
|
||||
LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
|
||||
|
||||
for (Long subgroupId : subgroupIds) {
|
||||
Subgroup subgroup = subgroupRepository.findById(subgroupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
|
||||
boolean subgroupBelongsToRuleGroups = rule.getGroups().stream()
|
||||
.anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId()));
|
||||
if (!subgroupBelongsToRuleGroups) {
|
||||
Long subgroupGroupId = subgroup.getStudentGroup().getId();
|
||||
if (!ruleGroupIds.contains(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
|
||||
}
|
||||
return subgroup;
|
||||
if (!subgroupGroupIds.add(subgroupGroupId)) {
|
||||
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
|
||||
}
|
||||
subgroups.add(subgroup);
|
||||
}
|
||||
|
||||
return subgroups;
|
||||
}
|
||||
|
||||
private String validateTypeHours(ScheduleRuleDto request) {
|
||||
@@ -318,6 +341,7 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||
return new ScheduleRuleSlotDto(
|
||||
slot.getId(),
|
||||
slot.getDayOfWeek(),
|
||||
@@ -326,8 +350,10 @@ public class ScheduleRuleAdminController {
|
||||
timeSlot.getId(),
|
||||
timeSlot.getOrderNumber(),
|
||||
formatTimeSlot(timeSlot),
|
||||
slot.getSubgroupId(),
|
||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
||||
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
|
||||
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
|
||||
sortedSubgroups.stream().map(Subgroup::getId).toList(),
|
||||
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
|
||||
slot.getTeacher().getId(),
|
||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
@@ -348,4 +374,16 @@ public class ScheduleRuleAdminController {
|
||||
private String formatTimeSlot(TimeSlot timeSlot) {
|
||||
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,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRuleSlotDto(
|
||||
Long id,
|
||||
Integer dayOfWeek,
|
||||
@@ -12,6 +14,8 @@ public record ScheduleRuleSlotDto(
|
||||
String timeSlotLabel,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_rule_slots")
|
||||
public class ScheduleRuleSlot {
|
||||
@@ -25,9 +29,13 @@ public class ScheduleRuleSlot {
|
||||
@JoinColumn(name = "time_slot_id", nullable = false)
|
||||
private TimeSlot timeSlot;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "subgroup_id")
|
||||
private Subgroup subgroup;
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
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)
|
||||
@JoinColumn(name = "teacher_id", nullable = false)
|
||||
@@ -85,15 +93,41 @@ public class ScheduleRuleSlot {
|
||||
}
|
||||
|
||||
public Long getSubgroupId() {
|
||||
return subgroup == null ? null : subgroup.getId();
|
||||
return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
|
||||
}
|
||||
|
||||
public Subgroup getSubgroup() {
|
||||
return subgroup;
|
||||
return subgroups.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -19,8 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroup subgroup
|
||||
left join fetch subgroup.studentGroup
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
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.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroup subgroup
|
||||
left join fetch subgroup.studentGroup
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
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.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroup subgroup
|
||||
left join fetch subgroup.studentGroup
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
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.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroup subgroup
|
||||
left join fetch subgroup.studentGroup
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
|
||||
@@ -7,6 +7,6 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -201,22 +201,54 @@ public class ScheduleGeneratorService {
|
||||
if (totalHours <= 0) {
|
||||
continue;
|
||||
}
|
||||
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups);
|
||||
if (lessonGroups.isEmpty()) {
|
||||
List<LessonScope> lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups);
|
||||
if (lessonScopes.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String consumptionBucket = consumptionBucket(category, slot);
|
||||
|
||||
List<ActiveLessonScope> activeScopes = new ArrayList<>();
|
||||
for (LessonScope scope : lessonScopes) {
|
||||
String consumptionBucket = consumptionBucket(category, scope.subgroupId());
|
||||
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
||||
consumptionBucket,
|
||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId())
|
||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
|
||||
);
|
||||
if (consumedHours >= totalHours) {
|
||||
continue;
|
||||
}
|
||||
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups,
|
||||
activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson));
|
||||
}
|
||||
|
||||
if (activeScopes.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
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 -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
||||
.filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId))
|
||||
.filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty())
|
||||
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
|
||||
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
|
||||
.count();
|
||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||
if (consumed >= rule.academicHoursFor(category)) {
|
||||
@@ -283,26 +315,51 @@ public class ScheduleGeneratorService {
|
||||
return consumed;
|
||||
}
|
||||
|
||||
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) {
|
||||
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId());
|
||||
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
|
||||
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
|
||||
}
|
||||
|
||||
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) {
|
||||
if (slot.getSubgroup() == null) {
|
||||
return eligibleGroups;
|
||||
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
|
||||
ScheduleLessonCategory category,
|
||||
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()
|
||||
.filter(group -> Objects.equals(group.getId(), subgroupGroupId))
|
||||
|
||||
Set<Long> eligibleGroupIds = eligibleGroups.stream()
|
||||
.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();
|
||||
}
|
||||
|
||||
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,
|
||||
ScheduleRuleSlot slot,
|
||||
LocalDate date,
|
||||
int weekNumber,
|
||||
ScheduleParity dateParity,
|
||||
List<StudentGroup> groups,
|
||||
List<Subgroup> subgroups,
|
||||
int lessonTypeAcademicHours,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
@@ -310,6 +367,11 @@ public class ScheduleGeneratorService {
|
||||
List<StudentGroup> sortedGroups = groups.stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
List<Subgroup> sortedSubgroups = subgroups.stream()
|
||||
.sorted(Comparator
|
||||
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
|
||||
.thenComparing(Subgroup::getName))
|
||||
.toList();
|
||||
|
||||
return new RenderedLessonDto(
|
||||
rule.getId(),
|
||||
@@ -332,8 +394,10 @@ public class ScheduleGeneratorService {
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat(),
|
||||
slot.getSubgroupId(),
|
||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
||||
sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null,
|
||||
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::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) {
|
||||
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
||||
return baseSlot;
|
||||
|
||||
@@ -515,7 +515,6 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
||||
day_of_week INT NOT NULL,
|
||||
parity VARCHAR(10) NOT NULL,
|
||||
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),
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(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_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_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 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 $$
|
||||
DECLARE
|
||||
lesson_type_name VARCHAR(50);
|
||||
rule_id BIGINT;
|
||||
subgroup_group_id BIGINT;
|
||||
has_group_duplicate BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.subgroup_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT name INTO lesson_type_name
|
||||
FROM lesson_types
|
||||
WHERE id = NEW.lesson_type_id;
|
||||
SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
|
||||
FROM schedule_rule_slots slot
|
||||
JOIN lesson_types type ON type.id = slot.lesson_type_id
|
||||
WHERE slot.id = NEW.schedule_rule_slot_id;
|
||||
|
||||
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
|
||||
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
|
||||
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;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER validate_schedule_rule_slot_subgroup
|
||||
BEFORE INSERT OR UPDATE ON schedule_rule_slots
|
||||
CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
||||
BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup();
|
||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||
|
||||
CREATE TEMP TABLE tmp_default_rules AS
|
||||
SELECT
|
||||
@@ -712,6 +761,7 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к
|
||||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||||
COMMENT ON TABLE schedule_rule_groups 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.laboratory_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": "Очно",
|
||||
"subgroupId": null,
|
||||
"subgroupName": null,
|
||||
"subgroupIds": [],
|
||||
"subgroupNames": [],
|
||||
"groupIds": [1],
|
||||
"groupNames": ["ИВТ-21-1"],
|
||||
"activityType": "Т",
|
||||
@@ -293,6 +295,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
"parity": "BOTH",
|
||||
"timeSlotId": 3,
|
||||
"subgroupId": null,
|
||||
"subgroupIds": [],
|
||||
"teacherId": 2,
|
||||
"classroomId": 1,
|
||||
"lessonTypeId": 1,
|
||||
@@ -314,7 +317,7 @@ CRUD доступен по:
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации.
|
||||
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||
|
||||
@@ -460,7 +463,7 @@ CRUD доступен по:
|
||||
|
||||
### Подгруппы группы
|
||||
|
||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`.
|
||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||
|
||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||
1. Определяет семестр для каждой даты диапазона.
|
||||
@@ -98,7 +99,7 @@
|
||||
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
|
||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`.
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||
|
||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||
|
||||
@@ -126,7 +127,7 @@
|
||||
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила.
|
||||
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,13 +226,17 @@ erDiagram
|
||||
INT day_of_week
|
||||
VARCHAR parity
|
||||
BIGINT time_slot_id FK
|
||||
BIGINT subgroup_id FK
|
||||
BIGINT teacher_id FK
|
||||
BIGINT classroom_id FK
|
||||
BIGINT lesson_type_id FK
|
||||
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{ student_groups : "department_id"
|
||||
departments ||--o{ subjects : "department_id"
|
||||
@@ -265,10 +269,11 @@ 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"
|
||||
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_slot_date_assignments : "time_slot_scope_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"
|
||||
```
|
||||
|
||||
@@ -534,13 +539,18 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник |
|
||||
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
|
||||
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
|
||||
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
|
||||
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
|
||||
| `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` | Настраиваемая сетка пар |
|
||||
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||
| `schedule_rule_groups` | Группы правила |
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу |
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||
|
||||
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
|
||||
|
||||
@@ -40,7 +41,7 @@
|
||||
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
||||
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
||||
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; один лабораторный слот может включать разные подгруппы разных групп. Лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
|
||||
## API
|
||||
|
||||
@@ -73,7 +74,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
||||
|
||||
- конструктор правил динамического расписания;
|
||||
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
||||
- выбор подгруппы только для лабораторного слота;
|
||||
- выбор подгрупп только для лабораторного слота;
|
||||
- управление временными слотами;
|
||||
- учебные годы и семестры;
|
||||
- CRUD календарных графиков;
|
||||
|
||||
@@ -123,7 +123,7 @@ frontend/
|
||||
- Вкладка `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.
|
||||
- Вкладка `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}`.
|
||||
@@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() {
|
||||
- навигация по неделям: предыдущая, текущая, следующая;
|
||||
- выбор даты через `input[type="date"]`;
|
||||
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||
- отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила.
|
||||
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
|
||||
|
||||
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
|
||||
|
||||
@@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() {
|
||||
- селект группы с сохранением выбора в `localStorage.studentGroupId`;
|
||||
- недельная сетка по дням;
|
||||
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
|
||||
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы.
|
||||
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
- календарный учебный график по специальности, профилю, форме обучения и учебному году;
|
||||
- правила расписания дисциплин;
|
||||
- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных;
|
||||
- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональные подгруппы для лабораторных;
|
||||
- назначение конкретного календарного графика учебной группе на учебный год.
|
||||
|
||||
При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются.
|
||||
@@ -29,7 +29,7 @@
|
||||
- `time_slots` задаёт сетку пар для тенанта.
|
||||
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
|
||||
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
||||
- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ.
|
||||
- `schedule_rule_slots` хранит шаблонные занятия правила, а `schedule_rule_slot_subgroups` связывает лабораторный слот с одной или несколькими подгруппами.
|
||||
|
||||
## Алгоритм генерации
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||
|
||||
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы.
|
||||
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к нескольким подгруппам разных групп, он выводится как совместное занятие этих групп, а часы списываются отдельно по каждой подгруппе.
|
||||
|
||||
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||
- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы.
|
||||
- [x] Добавлены подгруппы для лабораторных слотов, включая выбор разных подгрупп разных групп в одном поточном лабораторном слоте; лекции и практики не делятся на подгруппы.
|
||||
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
||||
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
||||
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
||||
|
||||
@@ -114,14 +114,18 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.schedule-checkbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
.schedule-group-select {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.schedule-group-select .select-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-group-checkboxes {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.schedule-hours-panel {
|
||||
@@ -164,7 +168,7 @@
|
||||
|
||||
.schedule-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||
grid-template-columns: repeat(7, minmax(125px, 1fr)) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
@@ -173,6 +177,20 @@
|
||||
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-semester-line {
|
||||
margin-bottom: 0.35rem;
|
||||
@@ -479,7 +497,8 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-slot-row {
|
||||
.schedule-slot-row,
|
||||
.schedule-slot-row.has-subgroup {
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
@@ -499,7 +518,8 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-slot-row {
|
||||
.schedule-slot-row,
|
||||
.schedule-slot-row.has-subgroup {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ const SEMESTER_LABELS = {
|
||||
spring: 'весенний'
|
||||
};
|
||||
|
||||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
|
||||
const LESSON_TYPE_LIMITS = [
|
||||
{
|
||||
key: 'lecture',
|
||||
@@ -59,7 +61,10 @@ export async function initSchedule() {
|
||||
hours: document.getElementById(config.hoursInputId),
|
||||
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 ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||
@@ -94,16 +99,44 @@ export async function initSchedule() {
|
||||
|
||||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||||
const removeButton = event.target.closest('.schedule-slot-remove');
|
||||
if (!removeButton) return;
|
||||
const subgroupBox = event.target.closest('.slot-subgroup-box');
|
||||
const subgroupMenu = event.target.closest('.slot-subgroup-menu');
|
||||
|
||||
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) => {
|
||||
if (event.target.closest('.slot-lesson-type')) {
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
||||
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
||||
}
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
@@ -167,7 +200,7 @@ export async function initSchedule() {
|
||||
slot.dayName,
|
||||
slot.timeSlotLabel,
|
||||
parity,
|
||||
slot.subgroupName,
|
||||
slotSubgroupNames(slot).join(', '),
|
||||
slot.teacherName,
|
||||
slot.classroomName,
|
||||
slot.lessonTypeName,
|
||||
@@ -232,8 +265,7 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function buildRulePayload() {
|
||||
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(input => Number(input.value));
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
@@ -277,29 +309,31 @@ export async function initSchedule() {
|
||||
|
||||
function renderRuleGroups(selectedIds) {
|
||||
if (!groups.length) {
|
||||
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
|
||||
ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
|
||||
updateRuleGroupsText();
|
||||
return;
|
||||
}
|
||||
const selected = new Set((selectedIds || []).map(String));
|
||||
ruleGroupsContainer.innerHTML = groups.map(group => `
|
||||
ruleGroupCheckboxes.innerHTML = groups.map(group => `
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
|
||||
<span class="checkmark"></span>
|
||||
<span>${escapeHtml(group.name)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
updateRuleGroupsText();
|
||||
}
|
||||
|
||||
function renderRuleSlots(slots) {
|
||||
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-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-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-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>
|
||||
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
</div>
|
||||
@@ -309,13 +343,14 @@ export async function initSchedule() {
|
||||
function readRuleSlots(validate) {
|
||||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||||
return rows.map((row, index) => {
|
||||
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
|
||||
const subgroupIds = readSlotSubgroupIds(row);
|
||||
const slot = {
|
||||
id: null,
|
||||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||||
parity: row.querySelector('.slot-parity').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),
|
||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||
@@ -370,19 +405,27 @@ export async function initSchedule() {
|
||||
|
||||
function validateSlotSubgroup(slot, index) {
|
||||
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
||||
if (slot.subgroupId && typeKey !== 'laboratory') {
|
||||
const subgroupIds = slotSubgroupIds(slot);
|
||||
if (subgroupIds.length && typeKey !== 'laboratory') {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||||
}
|
||||
if (!slot.subgroupId) return;
|
||||
if (!subgroupIds.length) return;
|
||||
|
||||
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
|
||||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||||
const subgroupGroupIds = new Set();
|
||||
for (const subgroupId of subgroupIds) {
|
||||
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
|
||||
if (!selectedSubgroup) {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||||
}
|
||||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeSlots() {
|
||||
@@ -417,22 +460,196 @@ export async function initSchedule() {
|
||||
if (!isLaboratorySlot(slot)) {
|
||||
return '<option value="">Только для лабораторных</option>';
|
||||
}
|
||||
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
|
||||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||||
const selectedGroupIds = selectedRuleGroupIds();
|
||||
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
|
||||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||||
.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);
|
||||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
||||
}).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() {
|
||||
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.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) {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
|
||||
@@ -61,7 +61,19 @@
|
||||
|
||||
<div class="schedule-groups-panel">
|
||||
<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 class="schedule-slots-panel">
|
||||
|
||||
@@ -416,13 +416,16 @@
|
||||
|
||||
function renderLesson(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return `
|
||||
<div class="lesson">
|
||||
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
|
||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-meta">
|
||||
<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.teacherName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
|
||||
@@ -431,13 +431,16 @@
|
||||
|
||||
function renderLesson(lesson) {
|
||||
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
|
||||
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
|
||||
? lesson.subgroupNames.join(', ')
|
||||
: lesson.subgroupName;
|
||||
return `
|
||||
<div class="lesson">
|
||||
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</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>
|
||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user