добавил денение на подгруппы для лабораторных работ
This commit is contained in:
@@ -26,6 +26,7 @@ public class ScheduleRuleAdminController {
|
||||
private final UserRepository userRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final LessonTypesRepository lessonTypesRepository;
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
|
||||
@@ -36,6 +37,7 @@ public class ScheduleRuleAdminController {
|
||||
UserRepository userRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
LessonTypesRepository lessonTypesRepository,
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
@@ -45,6 +47,7 @@ public class ScheduleRuleAdminController {
|
||||
this.userRepository = userRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.lessonTypesRepository = lessonTypesRepository;
|
||||
this.subgroupRepository = subgroupRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@@ -187,7 +190,8 @@ public class ScheduleRuleAdminController {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category);
|
||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
@@ -197,7 +201,7 @@ public class ScheduleRuleAdminController {
|
||||
slot.setDayOfWeek(slotDto.dayOfWeek());
|
||||
slot.setParity(slotDto.parity());
|
||||
slot.setTimeSlot(timeSlot);
|
||||
slot.setSubgroupId(slotDto.subgroupId());
|
||||
slot.setSubgroup(subgroup);
|
||||
slot.setTeacher(teacher);
|
||||
slot.setClassroom(classroom);
|
||||
slot.setLessonType(lessonType);
|
||||
@@ -205,6 +209,23 @@ public class ScheduleRuleAdminController {
|
||||
return slot;
|
||||
}
|
||||
|
||||
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) {
|
||||
if (subgroupId == null) {
|
||||
return null;
|
||||
}
|
||||
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("Подгруппа должна относиться к одной из групп правила");
|
||||
}
|
||||
return subgroup;
|
||||
}
|
||||
|
||||
private String validateTypeHours(ScheduleRuleDto request) {
|
||||
if (isMissing(request.lectureAcademicHours())
|
||||
|| isMissing(request.laboratoryAcademicHours())
|
||||
@@ -306,6 +327,7 @@ public class ScheduleRuleAdminController {
|
||||
timeSlot.getOrderNumber(),
|
||||
formatTimeSlot(timeSlot),
|
||||
slot.getSubgroupId(),
|
||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
||||
slot.getTeacher().getId(),
|
||||
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
|
||||
slot.getClassroom().getId(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.SubgroupDto;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class SubgroupController {
|
||||
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public SubgroupController(SubgroupRepository subgroupRepository,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.subgroupRepository = subgroupRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/subgroups")
|
||||
public List<SubgroupDto> getAll() {
|
||||
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/api/groups/{groupId}/subgroups")
|
||||
public ResponseEntity<?> getByGroup(@PathVariable Long groupId) {
|
||||
if (!groupRepository.existsById(groupId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream()
|
||||
.map(this::toDto)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/api/groups/{groupId}/subgroups")
|
||||
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
|
||||
StudentGroup group = groupRepository.findById(groupId).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
||||
}
|
||||
|
||||
Subgroup subgroup = new Subgroup();
|
||||
subgroup.setStudentGroup(group);
|
||||
subgroup.setName(request.name().trim());
|
||||
subgroup.setStudentCapacity(request.studentCapacity());
|
||||
Subgroup saved = subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long groupId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody SubgroupDto request) {
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
String newName = request.name().trim();
|
||||
boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName)
|
||||
&& !subgroup.getName().equalsIgnoreCase(newName);
|
||||
if (duplicateName) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
|
||||
}
|
||||
|
||||
subgroup.setName(newName);
|
||||
subgroup.setStudentCapacity(request.studentCapacity());
|
||||
Subgroup saved = subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toDto(saved));
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
|
||||
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
|
||||
if (subgroup == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
try {
|
||||
subgroupRepository.delete(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(SubgroupDto request) {
|
||||
if (request == null || request.name() == null || request.name().isBlank()) {
|
||||
return "Название подгруппы обязательно";
|
||||
}
|
||||
if (request.studentCapacity() != null && request.studentCapacity() < 0) {
|
||||
return "Численность подгруппы не может быть отрицательной";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SubgroupDto toDto(Subgroup subgroup) {
|
||||
StudentGroup group = subgroup.getStudentGroup();
|
||||
return new SubgroupDto(
|
||||
subgroup.getId(),
|
||||
group.getId(),
|
||||
group.getName(),
|
||||
subgroup.getName(),
|
||||
subgroup.getStudentCapacity()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public record RenderedLessonDto(
|
||||
String lessonTypeName,
|
||||
String lessonFormat,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
|
||||
@@ -11,6 +11,7 @@ public record ScheduleRuleSlotDto(
|
||||
Integer timeSlotOrder,
|
||||
String timeSlotLabel,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
|
||||
10
backend/src/main/java/com/magistr/app/dto/SubgroupDto.java
Normal file
10
backend/src/main/java/com/magistr/app/dto/SubgroupDto.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record SubgroupDto(
|
||||
Long id,
|
||||
Long groupId,
|
||||
String groupName,
|
||||
String name,
|
||||
Integer studentCapacity
|
||||
) {
|
||||
}
|
||||
@@ -25,8 +25,9 @@ public class ScheduleRuleSlot {
|
||||
@JoinColumn(name = "time_slot_id", nullable = false)
|
||||
private TimeSlot timeSlot;
|
||||
|
||||
@Column(name = "subgroup_id")
|
||||
private Long subgroupId;
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "subgroup_id")
|
||||
private Subgroup subgroup;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "teacher_id", nullable = false)
|
||||
@@ -84,11 +85,15 @@ public class ScheduleRuleSlot {
|
||||
}
|
||||
|
||||
public Long getSubgroupId() {
|
||||
return subgroupId;
|
||||
return subgroup == null ? null : subgroup.getId();
|
||||
}
|
||||
|
||||
public void setSubgroupId(Long subgroupId) {
|
||||
this.subgroupId = subgroupId;
|
||||
public Subgroup getSubgroup() {
|
||||
return subgroup;
|
||||
}
|
||||
|
||||
public void setSubgroup(Subgroup subgroup) {
|
||||
this.subgroup = subgroup;
|
||||
}
|
||||
|
||||
public User getTeacher() {
|
||||
|
||||
54
backend/src/main/java/com/magistr/app/model/Subgroup.java
Normal file
54
backend/src/main/java/com/magistr/app/model/Subgroup.java
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subgroups")
|
||||
public class Subgroup {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "group_id", nullable = false)
|
||||
private StudentGroup studentGroup;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(name = "student_capacity")
|
||||
private Integer studentCapacity;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public StudentGroup getStudentGroup() {
|
||||
return studentGroup;
|
||||
}
|
||||
|
||||
public void setStudentGroup(StudentGroup studentGroup) {
|
||||
this.studentGroup = studentGroup;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Integer getStudentCapacity() {
|
||||
return studentCapacity;
|
||||
}
|
||||
|
||||
public void setStudentCapacity(Integer studentCapacity) {
|
||||
this.studentCapacity = studentCapacity;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +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.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
@@ -40,6 +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.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
@@ -60,6 +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.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
@@ -76,6 +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.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
|
||||
@@ -2,6 +2,11 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
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")
|
||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Subgroup;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup studentGroup
|
||||
order by studentGroup.name, subgroup.name
|
||||
""")
|
||||
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
order by subgroup.name
|
||||
""")
|
||||
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
|
||||
|
||||
@Query("""
|
||||
select subgroup
|
||||
from Subgroup subgroup
|
||||
join fetch subgroup.studentGroup
|
||||
where subgroup.id = :id
|
||||
and subgroup.studentGroup.id = :groupId
|
||||
""")
|
||||
Optional<Subgroup> findByIdAndStudentGroupId(@Param("id") Long id, @Param("groupId") Long groupId);
|
||||
|
||||
@Query("""
|
||||
select case when count(subgroup) > 0 then true else false end
|
||||
from Subgroup subgroup
|
||||
where subgroup.studentGroup.id = :groupId
|
||||
and lower(subgroup.name) = lower(:name)
|
||||
""")
|
||||
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
|
||||
}
|
||||
@@ -190,7 +190,7 @@ public class ScheduleGeneratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<ScheduleLessonCategory, Integer> consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class);
|
||||
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
|
||||
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||
@@ -201,17 +201,22 @@ public class ScheduleGeneratorService {
|
||||
if (totalHours <= 0) {
|
||||
continue;
|
||||
}
|
||||
int consumedHours = consumedHoursByCategory.computeIfAbsent(
|
||||
category,
|
||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, key)
|
||||
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups);
|
||||
if (lessonGroups.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String consumptionBucket = consumptionBucket(category, slot);
|
||||
int consumedHours = consumedHoursByBucket.computeIfAbsent(
|
||||
consumptionBucket,
|
||||
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId())
|
||||
);
|
||||
if (consumedHours >= totalHours) {
|
||||
continue;
|
||||
}
|
||||
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups,
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups,
|
||||
totalHours, consumedHours, remainingAfterLesson));
|
||||
consumedHoursByCategory.put(category, consumedHours + ACADEMIC_HOURS_PER_SLOT);
|
||||
consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,17 +234,23 @@ public class ScheduleGeneratorService {
|
||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
|
||||
StudentGroup targetGroup,
|
||||
LocalDate targetDate,
|
||||
ScheduleLessonCategory category) {
|
||||
ScheduleLessonCategory category,
|
||||
Long subgroupId) {
|
||||
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
||||
String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
||||
String cacheKey = rule.getId()
|
||||
+ ":" + category.name()
|
||||
+ ":" + (subgroupId == null ? "whole" : subgroupId)
|
||||
+ ":" + (groupId == null ? "all" : groupId)
|
||||
+ ":" + targetDate;
|
||||
return consumedHoursCache.computeIfAbsent(cacheKey,
|
||||
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category));
|
||||
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
|
||||
}
|
||||
|
||||
private int calculateConsumedHours(ScheduleRule rule,
|
||||
StudentGroup targetGroup,
|
||||
LocalDate targetDate,
|
||||
ScheduleLessonCategory category) {
|
||||
ScheduleLessonCategory category,
|
||||
Long subgroupId) {
|
||||
int consumed = 0;
|
||||
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
|
||||
Semester semester = rule.getSemester();
|
||||
@@ -250,7 +261,8 @@ public class ScheduleGeneratorService {
|
||||
if (weekNumber < rule.startWeekFor(category)) {
|
||||
continue;
|
||||
}
|
||||
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
if (eligibleGroups.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -260,6 +272,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())
|
||||
.count();
|
||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||
if (consumed >= rule.academicHoursFor(category)) {
|
||||
@@ -269,6 +283,20 @@ public class ScheduleGeneratorService {
|
||||
return consumed;
|
||||
}
|
||||
|
||||
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) {
|
||||
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId());
|
||||
}
|
||||
|
||||
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) {
|
||||
if (slot.getSubgroup() == null) {
|
||||
return eligibleGroups;
|
||||
}
|
||||
Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId();
|
||||
return eligibleGroups.stream()
|
||||
.filter(group -> Objects.equals(group.getId(), subgroupGroupId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||
ScheduleRuleSlot slot,
|
||||
LocalDate date,
|
||||
@@ -305,6 +333,7 @@ public class ScheduleGeneratorService {
|
||||
slot.getLessonType().getLessonType(),
|
||||
slot.getLessonFormat(),
|
||||
slot.getSubgroupId(),
|
||||
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
|
||||
sortedGroups.stream().map(StudentGroup::getId).toList(),
|
||||
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
|
||||
"Т",
|
||||
|
||||
@@ -119,6 +119,22 @@ CREATE TABLE IF NOT EXISTS subgroups (
|
||||
UNIQUE(group_id, name)
|
||||
);
|
||||
|
||||
INSERT INTO subgroups (group_id, name, student_capacity)
|
||||
SELECT
|
||||
sg.id,
|
||||
data.name,
|
||||
CASE
|
||||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
||||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
||||
END
|
||||
FROM student_groups sg
|
||||
CROSS JOIN (VALUES
|
||||
('Подгруппа 1', 1),
|
||||
('Подгруппа 2', 2)
|
||||
) AS data(name, part_number)
|
||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||||
ON CONFLICT (group_id, name) DO NOTHING;
|
||||
|
||||
-- ==========================================
|
||||
-- Справочники
|
||||
-- ==========================================
|
||||
@@ -512,8 +528,35 @@ 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()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
lesson_type_name VARCHAR(50);
|
||||
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;
|
||||
|
||||
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' 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
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup();
|
||||
|
||||
CREATE TEMP TABLE tmp_default_rules AS
|
||||
SELECT
|
||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
||||
|
||||
41
docs/API.md
41
docs/API.md
@@ -101,7 +101,7 @@
|
||||
|
||||
## Динамическое расписание
|
||||
|
||||
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
|
||||
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
|
||||
|
||||
### `GET /api/schedule`
|
||||
|
||||
@@ -148,6 +148,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
"lessonTypeName": "Лекция",
|
||||
"lessonFormat": "Очно",
|
||||
"subgroupId": null,
|
||||
"subgroupName": null,
|
||||
"groupIds": [1],
|
||||
"groupNames": ["ИВТ-21-1"],
|
||||
"activityType": "Т",
|
||||
@@ -291,7 +292,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
"dayOfWeek": 1,
|
||||
"parity": "BOTH",
|
||||
"timeSlotId": 3,
|
||||
"subgroupId": null,
|
||||
"subgroupId": null,
|
||||
"teacherId": 2,
|
||||
"classroomId": 1,
|
||||
"lessonTypeId": 1,
|
||||
@@ -313,6 +314,8 @@ CRUD доступен по:
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации.
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||
|
||||
### `GET /api/lesson-types`
|
||||
@@ -323,7 +326,8 @@ CRUD доступен по:
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "Лекция" },
|
||||
{ "id": 2, "name": "Практика" }
|
||||
{ "id": 2, "name": "Практика" },
|
||||
{ "id": 3, "name": "Лабораторная работа" }
|
||||
]
|
||||
```
|
||||
|
||||
@@ -454,6 +458,37 @@ CRUD доступен по:
|
||||
|
||||
Удаление группы.
|
||||
|
||||
### Подгруппы группы
|
||||
|
||||
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`.
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/subgroups` | Список всех подгрупп |
|
||||
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
|
||||
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
|
||||
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
|
||||
| `DELETE` | `/api/groups/{groupId}/subgroups/{id}` | Удалить подгруппу, если она не используется в расписании |
|
||||
|
||||
**Тело создания/обновления:**
|
||||
```json
|
||||
{
|
||||
"name": "Подгруппа 1",
|
||||
"studentCapacity": 12
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"groupId": 1,
|
||||
"groupName": "ИВТ-21-1",
|
||||
"name": "Подгруппа 1",
|
||||
"studentCapacity": 12
|
||||
}
|
||||
```
|
||||
|
||||
### Календарные графики группы
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа |
|
||||
|
||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||
1. Определяет семестр для каждой даты диапазона.
|
||||
@@ -95,9 +95,11 @@
|
||||
6. Загружает правила группы или преподавателя.
|
||||
7. Для каждого типа занятия проверяет свою неделю начала в семестре.
|
||||
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||
9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик.
|
||||
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
|
||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||
|
||||
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`.
|
||||
|
||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||
|
||||
### Временные слоты
|
||||
@@ -124,6 +126,7 @@
|
||||
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
|
||||
---
|
||||
|
||||
@@ -348,6 +348,8 @@ erDiagram
|
||||
| `name` | VARCHAR(100) | Название подгруппы |
|
||||
| `student_capacity` | INT | Количество студентов |
|
||||
|
||||
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
|
||||
|
||||
#### `subjects` — Дисциплины
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
@@ -532,12 +534,14 @@ 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 | Подгруппа |
|
||||
| `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`.
|
||||
|
||||
---
|
||||
|
||||
## Flyway миграции
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| `time_slots` | Настраиваемая сетка пар |
|
||||
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||
| `schedule_rule_groups` | Группы правила |
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу |
|
||||
|
||||
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
||||
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
||||
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
|
||||
## API
|
||||
|
||||
@@ -64,6 +64,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
||||
| `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки |
|
||||
| `/api/specialties/{id}/profiles` | CRUD профилей специальности |
|
||||
| `/api/groups/{id}/calendar-assignments` | Назначения графиков группе |
|
||||
| `/api/subgroups`, `/api/groups/{id}/subgroups` | Управление подгруппами для лабораторных |
|
||||
| `/api/admin/schedule-rules` | CRUD правил расписания |
|
||||
|
||||
## Frontend
|
||||
@@ -72,12 +73,13 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
||||
|
||||
- конструктор правил динамического расписания;
|
||||
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
||||
- выбор подгруппы только для лабораторного слота;
|
||||
- управление временными слотами;
|
||||
- учебные годы и семестры;
|
||||
- CRUD календарных графиков;
|
||||
- Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей.
|
||||
|
||||
Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год.
|
||||
Экран `groups` создаёт и редактирует группы с профилем обучения, управляет подгруппами для лабораторных и назначает календарный график группе на учебный год.
|
||||
|
||||
Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей.
|
||||
|
||||
|
||||
@@ -106,13 +106,13 @@ frontend/
|
||||
| Tab | Описание | API |
|
||||
|-----|----------|-----|
|
||||
| `users` | CRUD пользователей | `/api/users` |
|
||||
| `groups` | CRUD групп | `/api/groups` |
|
||||
| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
|
||||
| `edu-forms` | Формы обучения | `/api/education-forms` |
|
||||
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
|
||||
| `equipments` | Оборудование | `/api/equipments` |
|
||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` |
|
||||
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
|
||||
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
|
||||
| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` |
|
||||
| `database` | Тенанты | `/api/database` |
|
||||
@@ -120,10 +120,10 @@ frontend/
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
|
||||
- Вкладка `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` хранит шаблонные занятия правила.
|
||||
- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ.
|
||||
|
||||
## Алгоритм генерации
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
4. По курсу и дате находится код активности.
|
||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
|
||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||
7. Лимит часов считается только по реально проведённым занятиям.
|
||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||
|
||||
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы.
|
||||
|
||||
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||
- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы.
|
||||
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
||||
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
||||
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
.schedule-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(130px, 1fr)) auto;
|
||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
|
||||
@@ -22,8 +22,17 @@ export async function initGroups() {
|
||||
const calendarYearSelect = document.getElementById('calendar-year');
|
||||
const calendarSelect = document.getElementById('calendar-id');
|
||||
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody');
|
||||
const subgroupForm = document.getElementById('subgroup-form');
|
||||
const subgroupIdInput = document.getElementById('subgroup-id');
|
||||
const subgroupGroupSelect = document.getElementById('subgroup-group');
|
||||
const subgroupNameInput = document.getElementById('subgroup-name');
|
||||
const subgroupCapacityInput = document.getElementById('subgroup-capacity');
|
||||
const subgroupSubmitButton = document.getElementById('subgroup-submit');
|
||||
const subgroupResetButton = document.getElementById('subgroup-reset');
|
||||
const subgroupsTbody = document.getElementById('subgroups-tbody');
|
||||
|
||||
let allGroups = [];
|
||||
let subgroups = [];
|
||||
let educationForms = [];
|
||||
let departments = [];
|
||||
let specialties = [];
|
||||
@@ -47,7 +56,11 @@ export async function initGroups() {
|
||||
createGroupForm.addEventListener('submit', createGroup);
|
||||
editGroupForm.addEventListener('submit', updateGroup);
|
||||
calendarForm.addEventListener('submit', saveAssignment);
|
||||
subgroupForm.addEventListener('submit', saveSubgroup);
|
||||
subgroupGroupSelect.addEventListener('change', renderSubgroups);
|
||||
subgroupResetButton.addEventListener('click', resetSubgroupForm);
|
||||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||||
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||||
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick);
|
||||
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
||||
modalEditGroup.addEventListener('click', (event) => {
|
||||
@@ -73,8 +86,10 @@ export async function initGroups() {
|
||||
populateYearSelect();
|
||||
populateCalendarSelect();
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +106,22 @@ export async function initGroups() {
|
||||
allGroups = await api.get('/api/groups');
|
||||
applyGroupFilter();
|
||||
populateGroupSelect();
|
||||
populateSubgroupGroupSelect();
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroups() {
|
||||
try {
|
||||
subgroups = await api.get('/api/subgroups');
|
||||
renderSubgroups();
|
||||
} catch (error) {
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGroupFilter() {
|
||||
const filterId = filterEfSelect.value;
|
||||
const filtered = filterId
|
||||
@@ -217,6 +242,18 @@ export async function initGroups() {
|
||||
syncSelects(calendarGroupSelect);
|
||||
}
|
||||
|
||||
function populateSubgroupGroupSelect() {
|
||||
const current = subgroupGroupSelect.value;
|
||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
subgroupGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
subgroupGroupSelect.value = String(allGroups[0].id);
|
||||
}
|
||||
syncSelects(subgroupGroupSelect);
|
||||
}
|
||||
|
||||
function populateCalendarSelect() {
|
||||
const group = selectedCalendarGroup();
|
||||
const yearId = calendarYearSelect.value;
|
||||
@@ -248,6 +285,7 @@ export async function initGroups() {
|
||||
createGroupForm.reset();
|
||||
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
||||
}
|
||||
@@ -265,6 +303,7 @@ export async function initGroups() {
|
||||
modalEditGroup.classList.remove('open');
|
||||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
||||
}
|
||||
@@ -309,6 +348,7 @@ export async function initGroups() {
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
}
|
||||
@@ -339,6 +379,100 @@ export async function initGroups() {
|
||||
modalEditGroup.classList.add('open');
|
||||
}
|
||||
|
||||
async function saveSubgroup(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('subgroup-alert');
|
||||
const groupId = subgroupGroupSelect.value;
|
||||
const name = subgroupNameInput.value.trim();
|
||||
const capacity = subgroupCapacityInput.value;
|
||||
const subgroupId = subgroupIdInput.value;
|
||||
|
||||
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
|
||||
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
|
||||
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
studentCapacity: capacity ? Number(capacity) : null
|
||||
};
|
||||
|
||||
try {
|
||||
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
|
||||
const saved = subgroupId
|
||||
? await api.put(endpoint, payload)
|
||||
: await api.post(endpoint, payload);
|
||||
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubgroups() {
|
||||
const groupId = subgroupGroupSelect.value;
|
||||
const visibleSubgroups = groupId
|
||||
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId))
|
||||
: subgroups;
|
||||
if (!visibleSubgroups.length) {
|
||||
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => `
|
||||
<tr>
|
||||
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td>
|
||||
<td>${escapeHtml(subgroup.name)}</td>
|
||||
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button>
|
||||
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleSubgroupTableClick(event) {
|
||||
const editButton = event.target.closest('.btn-edit-subgroup');
|
||||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||||
|
||||
if (editButton) {
|
||||
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id));
|
||||
if (!subgroup) return;
|
||||
subgroupIdInput.value = subgroup.id;
|
||||
subgroupGroupSelect.value = subgroup.groupId;
|
||||
subgroupGroupSelect.disabled = true;
|
||||
subgroupNameInput.value = subgroup.name || '';
|
||||
subgroupCapacityInput.value = subgroup.studentCapacity ?? '';
|
||||
subgroupSubmitButton.textContent = 'Сохранить подгруппу';
|
||||
syncSelects(subgroupGroupSelect);
|
||||
hideAlert('subgroup-alert');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id));
|
||||
if (!subgroup || !confirm('Удалить подгруппу?')) return;
|
||||
try {
|
||||
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`);
|
||||
showAlert('subgroup-alert', 'Подгруппа удалена', 'success');
|
||||
resetSubgroupForm();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetSubgroupForm() {
|
||||
subgroupIdInput.value = '';
|
||||
subgroupNameInput.value = '';
|
||||
subgroupCapacityInput.value = '';
|
||||
subgroupGroupSelect.disabled = false;
|
||||
subgroupSubmitButton.textContent = 'Создать подгруппу';
|
||||
syncSelects(subgroupGroupSelect);
|
||||
hideAlert('subgroup-alert');
|
||||
}
|
||||
|
||||
async function saveAssignment(event) {
|
||||
event.preventDefault();
|
||||
hideAlert('group-calendar-alert');
|
||||
@@ -419,6 +553,11 @@ export async function initGroups() {
|
||||
return department.departmentName || department.name || departmentId;
|
||||
}
|
||||
|
||||
function groupName(groupId) {
|
||||
const group = allGroups.find(item => String(item.id) === String(groupId));
|
||||
return group ? group.name : groupId || '-';
|
||||
}
|
||||
|
||||
function specialityLabel(specialityId) {
|
||||
const speciality = specialties.find(item => item.id == specialityId);
|
||||
if (!speciality) return specialityId || '-';
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function initSchedule() {
|
||||
let teachers = [];
|
||||
let classrooms = [];
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
let semesters = [];
|
||||
|
||||
@@ -99,17 +100,24 @@ export async function initSchedule() {
|
||||
renderRuleSlots(slots.length ? slots : [{}]);
|
||||
});
|
||||
|
||||
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
|
||||
ruleSlotsContainer.addEventListener('change', (event) => {
|
||||
if (event.target.closest('.slot-lesson-type')) {
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
}
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
}
|
||||
|
||||
async function loadBaseLists() {
|
||||
[subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([
|
||||
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/lesson-types')
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/subgroups')
|
||||
]);
|
||||
populateSubjectSelects();
|
||||
}
|
||||
@@ -159,6 +167,7 @@ export async function initSchedule() {
|
||||
slot.dayName,
|
||||
slot.timeSlotLabel,
|
||||
parity,
|
||||
slot.subgroupName,
|
||||
slot.teacherName,
|
||||
slot.classroomName,
|
||||
slot.lessonTypeName,
|
||||
@@ -290,6 +299,7 @@ export async function initSchedule() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -299,12 +309,13 @@ 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 slot = {
|
||||
id: null,
|
||||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||||
parity: row.querySelector('.slot-parity').value,
|
||||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||||
subgroupId: null,
|
||||
subgroupId: subgroupValue ? Number(subgroupValue) : null,
|
||||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||
@@ -314,6 +325,9 @@ export async function initSchedule() {
|
||||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
|
||||
throw new Error(`Заполните слот ${index + 1}`);
|
||||
}
|
||||
if (validate) {
|
||||
validateSlotSubgroup(slot, index);
|
||||
}
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
@@ -354,6 +368,23 @@ export async function initSchedule() {
|
||||
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||||
}
|
||||
|
||||
function validateSlotSubgroup(slot, index) {
|
||||
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
||||
if (slot.subgroupId && typeKey !== 'laboratory') {
|
||||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||||
}
|
||||
if (!slot.subgroupId) 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} подгруппа должна относиться к выбранным группам правила`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeSlots() {
|
||||
const slots = await api.get('/api/admin/time-slots');
|
||||
timeSlots = slots
|
||||
@@ -382,6 +413,26 @@ export async function initSchedule() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function subgroupOptions(slot) {
|
||||
if (!isLaboratorySlot(slot)) {
|
||||
return '<option value="">Только для лабораторных</option>';
|
||||
}
|
||||
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
|
||||
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'));
|
||||
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
|
||||
const value = String(subgroup.id);
|
||||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectedRuleGroupIds() {
|
||||
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(input => Number(input.value));
|
||||
}
|
||||
|
||||
function toTimeSlotOption(slot) {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
@@ -404,6 +455,14 @@ export async function initSchedule() {
|
||||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||
}
|
||||
|
||||
function isLaboratorySlot(slot) {
|
||||
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
|
||||
}
|
||||
|
||||
function subgroupLabel(subgroup) {
|
||||
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
|
||||
}
|
||||
|
||||
function lessonTypeKey(lessonType) {
|
||||
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||||
if (name.includes('лекц')) return 'lecture';
|
||||
|
||||
@@ -79,6 +79,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Подгруппы для лабораторных</h2>
|
||||
<form id="subgroup-form">
|
||||
<input type="hidden" id="subgroup-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subgroup-group">Группа</label>
|
||||
<select id="subgroup-group">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subgroup-name">Название подгруппы</label>
|
||||
<input type="text" id="subgroup-name" placeholder="Подгруппа 1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subgroup-capacity">Численность</label>
|
||||
<input type="number" id="subgroup-capacity" min="0" placeholder="12">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" id="subgroup-submit">Создать подгруппу</button>
|
||||
<button type="button" class="btn-edit-classroom" id="subgroup-reset">Очистить</button>
|
||||
</div>
|
||||
<div class="form-alert" id="subgroup-alert" role="alert"></div>
|
||||
</form>
|
||||
<div class="table-wrap schedule-inline-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Группа</th>
|
||||
<th>Подгруппа</th>
|
||||
<th>Численность</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subgroups-tbody">
|
||||
<tr>
|
||||
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card">
|
||||
<h2>Назначение календарного графика</h2>
|
||||
<form id="group-calendar-form">
|
||||
|
||||
@@ -422,6 +422,7 @@
|
||||
<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>` : ''}
|
||||
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
|
||||
@@ -437,6 +437,7 @@
|
||||
<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>` : ''}
|
||||
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
|
||||
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user