diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 3c36000..516a73b 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) { + LinkedHashSet 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("Подгруппы можно выбирать только для лабораторных занятий"); } - Subgroup subgroup = subgroupRepository.findById(subgroupId) - .orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); - boolean subgroupBelongsToRuleGroups = rule.getGroups().stream() - .anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId())); - if (!subgroupBelongsToRuleGroups) { - throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); + + Set ruleGroupIds = rule.getGroups().stream() + .map(StudentGroup::getId) + .collect(java.util.stream.Collectors.toSet()); + Set subgroupGroupIds = new HashSet<>(); + LinkedHashSet 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) { @@ -318,6 +341,7 @@ public class ScheduleRuleAdminController { private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { TimeSlot timeSlot = slot.getTimeSlot(); + List 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 sortedSubgroups(ScheduleRuleSlot slot) { + return slot.getSubgroups().stream() + .sorted(Comparator + .comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName()) + .thenComparing(Subgroup::getName)) + .toList(); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 6b6ddcc..b449fae 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -29,6 +29,8 @@ public record RenderedLessonDto( String lessonFormat, Long subgroupId, String subgroupName, + List subgroupIds, + List subgroupNames, List groupIds, List groupNames, String activityType, diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java index 6cb88cf..bcdfb01 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -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 subgroupIds, + List subgroupNames, Long teacherId, String teacherName, Long classroomId, diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java index 63cfcf3..1404dc8 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -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 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 getSubgroups() { + return subgroups; + } + + public void setSubgroups(Set subgroups) { + this.subgroups.clear(); + if (subgroups != null) { + this.subgroups.addAll(subgroups); + } + } + + public List getSubgroupIds() { + return subgroups.stream() + .map(Subgroup::getId) + .toList(); + } + + public List getSubgroupNames() { + return subgroups.stream() + .map(Subgroup::getName) + .toList(); } public User getTeacher() { diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index 1577f1d..6b51ac5 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -19,8 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository { - @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); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 0a98f9e..83925d4 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -201,22 +201,54 @@ public class ScheduleGeneratorService { if (totalHours <= 0) { continue; } - List lessonGroups = groupsForSlot(slot, eligibleGroups); - if (lessonGroups.isEmpty()) { + List lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups); + if (lessonScopes.isEmpty()) { continue; } - String consumptionBucket = consumptionBucket(category, slot); - int consumedHours = consumedHoursByBucket.computeIfAbsent( - consumptionBucket, - key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId()) - ); - if (consumedHours >= totalHours) { + + List activeScopes = new ArrayList<>(); + for (LessonScope scope : lessonScopes) { + String consumptionBucket = consumptionBucket(category, scope.subgroupId()); + int consumedHours = consumedHoursByBucket.computeIfAbsent( + 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; } - int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT); - result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, + + Map lessonGroupById = new LinkedHashMap<>(); + Map 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 lessonGroups = new ArrayList<>(lessonGroupById.values()); + List 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 groupsForSlot(ScheduleRuleSlot slot, List eligibleGroups) { - if (slot.getSubgroup() == null) { - return eligibleGroups; + private List lessonScopesForSlot(ScheduleRuleSlot slot, + ScheduleLessonCategory category, + List 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 eligibleGroupIds = eligibleGroups.stream() + .map(StudentGroup::getId) + .collect(Collectors.toSet()); + Map 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 groups, List 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 groups, + List subgroups, int lessonTypeAcademicHours, int consumedBeforeLesson, int remainingAfterLesson) { @@ -310,6 +367,11 @@ public class ScheduleGeneratorService { List sortedGroups = groups.stream() .sorted(Comparator.comparing(StudentGroup::getName)) .toList(); + List 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; diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 0fe5465..8bba67b 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 'Лимит академических часов практик'; diff --git a/docs/API.md b/docs/API.md index 42caf0a..babf842 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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": "Т", @@ -292,7 +294,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "dayOfWeek": 1, "parity": "BOTH", "timeSlotId": 3, - "subgroupId": null, + "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 | Назначение | |-------|-----|------------| diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 3b3ffe6..fe9d3a1 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d11be68..b4af127 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -226,12 +226,16 @@ 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" @@ -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`. --- diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 7a06847..3987c1a 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -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 календарных графиков; diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6884331..31b8404 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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}`; -- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы. +- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторных подгрупп. --- diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index 2af646d..c064c40 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -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. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. -Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы. +Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к нескольким подгруппам разных групп, он выводится как совместное занятие этих групп, а часы списываются отдельно по каждой подгруппе. Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index fb220d7..86221ef 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -12,7 +12,7 @@ - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. -- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы. +- [x] Добавлены подгруппы для лабораторных слотов, включая выбор разных подгрупп разных групп в одном поточном лабораторном слоте; лекции и практики не делятся на подгруппы. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index cd5a439..19a65e4 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index ce8c626..490fc83 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -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 slots = readRuleSlots(false); - slots.splice(Number(removeButton.dataset.index), 1); - renderRuleSlots(slots.length ? slots : [{}]); + 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 = '
Нет групп
'; + ruleGroupCheckboxes.innerHTML = '
Нет групп
'; + updateRuleGroupsText(); return; } const selected = new Set((selectedIds || []).map(String)); - ruleGroupsContainer.innerHTML = groups.map(group => ` + ruleGroupCheckboxes.innerHTML = groups.map(group => ` `).join(''); + updateRuleGroupsText(); } function renderRuleSlots(slots) { ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` -
+
-
+ ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
@@ -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,18 +405,26 @@ 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)); - 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} подгруппа должна относиться к выбранным группам правила`); + 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} подгруппа не найдена`); + } + 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)) { return ''; } - const selected = slot.subgroupId == null ? '' : String(slot.subgroupId); - const selectedGroups = new Set(selectedRuleGroupIds().map(String)); + const selectedGroupIds = selectedRuleGroupIds(); + if (selectedGroupIds.length !== 1) { + return ''; + } + 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 '' + matchingSubgroups.map(subgroup => { + return `` + matchingSubgroups.map(subgroup => { const value = String(subgroup.id); return ``; }).join(''); } + function renderSubgroupField(slot, index) { + return selectedRuleGroupIds().length > 1 + ? renderSubgroupMultiSelect(slot, index) + : `
`; + } + + 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 ` + + `; + }).join('') + : '
Нет подгрупп выбранных групп
'; + + return ` +
+ +
+ + +
+
+ `; + } + + 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)}` }; } diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 86a5efd..fe8637d 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -61,7 +61,19 @@
-
+
+ + +
diff --git a/frontend/student/index.html b/frontend/student/index.html index 4b83cae..34219aa 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -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 `
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}
${escapeHtml(lesson.subjectName)}
${escapeHtml(lesson.lessonTypeName)} - ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} ${escapeHtml(lesson.lessonFormat)} ${escapeHtml(lesson.teacherName)} ${escapeHtml(lesson.classroomName)} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index f15428c..e06adab 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -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 `
${escapeHtml(lesson.subjectName)}
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} ${escapeHtml(lesson.lessonTypeName)} - ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} + ${subgroups ? `${escapeHtml(subgroups)}` : ''} ${escapeHtml(lesson.classroomName)} ${groups ? `${escapeHtml(groups)}` : ''}