feat: улучшить учебные периоды и просмотр расписаний

This commit is contained in:
Zuev
2026-08-15 00:20:00 +03:00
parent 855d191517
commit 52d516dd8f
22 changed files with 1107 additions and 250 deletions

View File

@@ -7,6 +7,7 @@ import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory; import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus; import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester; import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository; import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository; import com.magistr.app.repository.ScheduleVersionRepository;
@@ -19,13 +20,20 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Set;
@Service @Service
public class AcademicPeriodService { public class AcademicPeriodService {
private static final String YEAR_CONFLICT_MESSAGE = private static final String YEAR_CONFLICT_MESSAGE =
"Название или период учебного года конфликтует с существующим учебным годом"; "Учебный год или его период конфликтует с существующим учебным годом";
private static final String SEMESTER_CONFLICT_MESSAGE = private static final String SEMESTER_CONFLICT_MESSAGE =
"Тип или период семестра конфликтует с существующим семестром учебного года"; "Тип или период семестра конфликтует с существующим семестром учебного года";
@@ -49,48 +57,53 @@ public class AcademicPeriodService {
@Transactional @Transactional
public AcademicYear createYear(AcademicYearDto request) { public AcademicYear createYear(AcademicYearDto request) {
validateYear(request); YearPeriod period = resolveYearPeriod(request);
String title = request.title().trim(); List<SemesterDto> semesterRequests = validateRequestedSemesters(
request.semesters(), period.startDate(), period.endDate(), true
);
String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitle(title) if (academicYearRepository.existsByTitle(title)
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) { || academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
} }
AcademicYear year = new AcademicYear(); AcademicYear year = new AcademicYear();
applyYear(year, title, request.startDate(), request.endDate()); applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year); AcademicYear saved = saveYear(year);
for (SemesterDto semesterRequest : semesterRequests) {
Semester semester = new Semester();
semester.setAcademicYear(saved);
applySemester(semester, semesterRequest);
Semester savedSemester = saveSemester(semester);
createInitialPublishedSchedule(savedSemester);
}
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
return saved; return saved;
} }
@Transactional @Transactional
public AcademicYear updateYear(Long id, AcademicYearDto request) { public AcademicYear updateYear(Long id, AcademicYearDto request) {
validateYear(request); YearPeriod period = resolveYearPeriod(request);
AcademicYear year = lockYear(id); AcademicYear year = lockYear(id);
String title = request.title().trim(); String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitleAndIdNot(title, id) if (academicYearRepository.existsByTitleAndIdNot(title, id)
|| academicYearRepository.existsOverlappingExcluding( || academicYearRepository.existsOverlappingExcluding(
id, request.startDate(), request.endDate())) { id, period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE); throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
} }
boolean semesterOutsideNewRange = semesterRepository
.findByAcademicYearIdOrderByStartDateAsc(id)
.stream()
.anyMatch(semester -> !contains(
request.startDate(),
request.endDate(),
semester.getStartDate(),
semester.getEndDate()
));
if (semesterOutsideNewRange) {
throw new IllegalArgumentException(
"Новые границы учебного года не включают все его семестры"
);
}
applyYear(year, title, request.startDate(), request.endDate()); List<Semester> existingSemesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(id);
List<SemesterDto> requestedSemesters = request.semesters() == null
? List.of()
: request.semesters();
List<SemesterDto> effectiveSemesterRequests = mergeSemesterRequests(
existingSemesters, requestedSemesters, period.startDate(), period.endDate()
);
applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year); AcademicYear saved = saveYear(year);
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
return saved; return saved;
} }
@@ -139,19 +152,15 @@ public class AcademicPeriodService {
return saved; return saved;
} }
private void validateYear(AcademicYearDto request) { private YearPeriod resolveYearPeriod(AcademicYearDto request) {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Данные учебного года обязательны"); throw new IllegalArgumentException("Данные учебного года обязательны");
} }
if (request.title() == null || request.title().isBlank()) { if (request.startDate() == null) {
throw new IllegalArgumentException("Название учебного года обязательно"); throw new IllegalArgumentException("Дата начала учебного года обязательна");
} }
validateDateRange( LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
request.startDate(), return new YearPeriod(request.startDate(), endDate);
request.endDate(),
"Даты начала и окончания учебного года обязательны",
"Дата окончания учебного года не может быть раньше даты начала"
);
} }
private void validateSemester(SemesterDto request) { private void validateSemester(SemesterDto request) {
@@ -189,6 +198,110 @@ public class AcademicPeriodService {
} }
} }
private List<SemesterDto> validateRequestedSemesters(List<SemesterDto> requests,
LocalDate yearStart,
LocalDate yearEnd,
boolean required) {
List<SemesterDto> semesters = requests == null ? List.of() : new ArrayList<>(requests);
if (required && semesters.isEmpty()) {
throw new IllegalArgumentException("Учебный год нельзя создать без семестров");
}
Set<SemesterType> types = new HashSet<>();
Set<Long> ids = new HashSet<>();
for (SemesterDto semester : semesters) {
validateSemester(semester);
if (!types.add(semester.semesterType())) {
throw new ScheduleConflictException("Типы семестров учебного года не должны повторяться");
}
if (semester.id() != null && !ids.add(semester.id())) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
if (!contains(yearStart, yearEnd, semester.startDate(), semester.endDate())) {
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
}
}
List<SemesterDto> sorted = new ArrayList<>(semesters);
sorted.sort(Comparator.comparing(SemesterDto::startDate));
for (int index = 1; index < sorted.size(); index++) {
SemesterDto previous = sorted.get(index - 1);
SemesterDto current = sorted.get(index);
if (!previous.endDate().isBefore(current.startDate())) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
return sorted;
}
private List<SemesterDto> mergeSemesterRequests(List<Semester> existingSemesters,
List<SemesterDto> requestedSemesters,
LocalDate yearStart,
LocalDate yearEnd) {
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
Map<Long, SemesterDto> requestedById = new HashMap<>();
List<SemesterDto> newRequests = new ArrayList<>();
for (SemesterDto request : requestedSemesters) {
if (request == null) {
throw new IllegalArgumentException("Данные семестра обязательны");
}
if (request.id() == null) {
newRequests.add(request);
continue;
}
if (!existingById.containsKey(request.id())) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
if (requestedById.put(request.id(), request) != null) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
}
List<SemesterDto> effective = new ArrayList<>();
for (Semester semester : existingSemesters) {
effective.add(requestedById.getOrDefault(semester.getId(), toSemesterRequest(semester)));
}
effective.addAll(newRequests);
return validateRequestedSemesters(effective, yearStart, yearEnd, true);
}
private void synchronizeSemesters(AcademicYear year,
List<Semester> existingSemesters,
List<SemesterDto> effectiveRequests,
List<SemesterDto> requestedSemesters) {
if (effectiveRequests.isEmpty() || requestedSemesters.isEmpty()) {
return;
}
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
for (SemesterDto request : requestedSemesters) {
Semester semester = request.id() == null ? new Semester() : existingById.get(request.id());
if (semester == null) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
boolean created = semester.getId() == null;
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
if (created) {
createInitialPublishedSchedule(saved);
}
}
}
private SemesterDto toSemesterRequest(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
private boolean contains(LocalDate outerStart, private boolean contains(LocalDate outerStart,
LocalDate outerEnd, LocalDate outerEnd,
LocalDate innerStart, LocalDate innerStart,
@@ -226,6 +339,10 @@ public class AcademicPeriodService {
year.setEndDate(endDate); year.setEndDate(endDate);
} }
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
return startDate.getYear() + "-" + endDate.getYear();
}
private void applySemester(Semester semester, SemesterDto request) { private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType()); semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate()); semester.setStartDate(request.startDate());
@@ -283,4 +400,7 @@ public class AcademicPeriodService {
} }
}); });
} }
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
}
} }

View File

@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
private static final int ACADEMIC_HOURS_PER_SLOT = 2; private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final long MAX_RANGE_DAYS = 120; private static final long MAX_RANGE_DAYS = 200;
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY"; private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private final ScheduleRuleRepository scheduleRuleRepository; private final ScheduleRuleRepository scheduleRuleRepository;
@@ -328,7 +328,7 @@ public class ScheduleGeneratorService {
} }
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1; long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) { if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
} }
} }

View File

@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
@Service @Service
public class ScheduleOverrideService { public class ScheduleOverrideService {
private static final long MAX_RANGE_DAYS = 200;
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE"); private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн"); private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
@@ -706,8 +708,8 @@ public class ScheduleOverrideService {
if (endDate.isBefore(startDate)) { if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
} }
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) { if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
} }
} }

View File

@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
public class ScheduleQueryService { public class ScheduleQueryService {
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50; private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
private static final long MAX_RANGE_DAYS = 120; private static final long MAX_RANGE_DAYS = 200;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
@@ -380,7 +380,7 @@ public class ScheduleQueryService {
} }
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1; long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) { if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
} }
} }

View File

@@ -73,18 +73,38 @@ class AcademicPeriodServiceTest {
} }
@Test @Test
void createsNonOverlappingYearWithTrimmedTitle() { void createsYearWithAutomaticTitleEndDateAndSemesters() {
AcademicYear saved = service.createYear(yearRequest( AcademicYear saved = service.createYear(yearRequest(
" 2026-2027 ", "Произвольное название",
YEAR_START, YEAR_START,
YEAR_END YEAR_END.minusMonths(1)
)); ));
assertThat(saved.getTitle()).isEqualTo("2026-2027"); assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(saved.getStartDate()).isEqualTo(YEAR_START); assertThat(saved.getStartDate()).isEqualTo(YEAR_START);
assertThat(saved.getEndDate()).isEqualTo(YEAR_END);
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
verify(scheduleVersionRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(ScheduleVersion.class));
verify(scheduleGeneratorService).clearCache(); verify(scheduleGeneratorService).clearCache();
} }
@Test
void rejectsYearWithoutSemesters() {
AcademicYearDto request = new AcademicYearDto(
null,
null,
YEAR_START,
null,
List.of()
);
assertThatThrownBy(() -> service.createYear(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Учебный год нельзя создать без семестров");
verify(academicYearRepository, never()).saveAndFlush(any());
}
@Test @Test
void rejectsDuplicateTitleOrOverlappingYear() { void rejectsDuplicateTitleOrOverlappingYear() {
when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true); when(academicYearRepository.existsByTitle("2026-2027")).thenReturn(true);
@@ -95,7 +115,7 @@ class AcademicPeriodServiceTest {
YEAR_END YEAR_END
))) )))
.isInstanceOf(ScheduleConflictException.class) .isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом"); .hasMessage("Учебный год или его период конфликтует с существующим учебным годом");
verify(academicYearRepository, never()).saveAndFlush(any()); verify(academicYearRepository, never()).saveAndFlush(any());
} }
@@ -112,17 +132,49 @@ class AcademicPeriodServiceTest {
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID)) when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn)); .thenReturn(List.of(autumn));
assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequest( assertThatThrownBy(() -> service.updateYear(YEAR_ID, yearRequestWithoutSemesters(YEAR_START.plusDays(1))))
"2026-2027",
YEAR_START.plusDays(1),
YEAR_END
)))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Новые границы учебного года не включают все его семестры"); .hasMessage("Семестр должен полностью находиться в границах учебного года");
verify(academicYearRepository, never()).saveAndFlush(any()); verify(academicYearRepository, never()).saveAndFlush(any());
} }
@Test
void updatesYearAndItsSemestersTogether() {
Semester autumn = semester(
SEMESTER_ID,
SemesterType.autumn,
YEAR_START,
LocalDate.of(2027, 1, 31)
);
Semester spring = semester(
SEMESTER_ID + 1,
SemesterType.spring,
LocalDate.of(2027, 2, 1),
YEAR_END
);
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
when(semesterRepository.findByAcademicYearIdOrderByStartDateAsc(YEAR_ID))
.thenReturn(List.of(autumn, spring));
AcademicYear saved = service.updateYear(YEAR_ID, new AcademicYearDto(
YEAR_ID,
null,
YEAR_START,
null,
List.of(
semesterRequest(SEMESTER_ID, SemesterType.autumn, YEAR_START, LocalDate.of(2027, 1, 25)),
semesterRequest(SEMESTER_ID + 1, SemesterType.spring, LocalDate.of(2027, 1, 26), YEAR_END)
)
));
assertThat(saved.getTitle()).isEqualTo("2026-2027");
assertThat(autumn.getEndDate()).isEqualTo(LocalDate.of(2027, 1, 25));
assertThat(spring.getStartDate()).isEqualTo(LocalDate.of(2027, 1, 26));
verify(semesterRepository, org.mockito.Mockito.times(2)).saveAndFlush(any(Semester.class));
verify(scheduleVersionRepository, never()).saveAndFlush(any());
}
@Test @Test
void rejectsSemesterOutsideAcademicYear() { void rejectsSemesterOutsideAcademicYear() {
when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year)); when(academicYearRepository.findByIdForUpdate(YEAR_ID)).thenReturn(Optional.of(year));
@@ -218,19 +270,33 @@ class AcademicPeriodServiceTest {
YEAR_END YEAR_END
))) )))
.isInstanceOf(ScheduleConflictException.class) .isInstanceOf(ScheduleConflictException.class)
.hasMessage("Название или период учебного года конфликтует с существующим учебным годом") .hasMessage("Учебный год или его период конфликтует с существующим учебным годом")
.hasNoCause(); .hasNoCause();
} }
private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) { private AcademicYearDto yearRequest(String title, LocalDate startDate, LocalDate endDate) {
return new AcademicYearDto(null, title, startDate, endDate, List.of()); return new AcademicYearDto(null, title, startDate, endDate, List.of(
semesterRequest(SemesterType.autumn, startDate, LocalDate.of(2027, 1, 31)),
semesterRequest(SemesterType.spring, LocalDate.of(2027, 2, 1), YEAR_END)
));
}
private AcademicYearDto yearRequestWithoutSemesters(LocalDate startDate) {
return new AcademicYearDto(null, null, startDate, null, List.of());
} }
private SemesterDto semesterRequest(SemesterType semesterType, private SemesterDto semesterRequest(SemesterType semesterType,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate) {
return semesterRequest(null, semesterType, startDate, endDate);
}
private SemesterDto semesterRequest(Long id,
SemesterType semesterType,
LocalDate startDate,
LocalDate endDate) {
return new SemesterDto( return new SemesterDto(
null, id,
YEAR_ID, YEAR_ID,
"2026-2027", "2026-2027",
semesterType, semesterType,

View File

@@ -145,8 +145,8 @@ class ScheduleGenerationQueryCountTest {
} }
@Test @Test
@DisplayName("Генератор отклоняет 121 включительную дату") @DisplayName("Генератор отклоняет 201 включительную дату")
void rejects121InclusiveDates() { void rejects201InclusiveDates() {
ScheduleGeneratorService generator = new ScheduleGeneratorService( ScheduleGeneratorService generator = new ScheduleGeneratorService(
mock(ScheduleRuleRepository.class), mock(ScheduleRuleRepository.class),
mock(GroupRepository.class), mock(GroupRepository.class),
@@ -161,10 +161,10 @@ class ScheduleGenerationQueryCountTest {
assertThatThrownBy(() -> generator.buildScheduleForGroups( assertThatThrownBy(() -> generator.buildScheduleForGroups(
List.of(), List.of(),
startDate, startDate,
startDate.plusDays(120) startDate.plusDays(200)
)) ))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней"); .hasMessage("Диапазон расписания не может превышать 200 дней");
} }
private AcademicYear academicYear() { private AcademicYear academicYear() {

View File

@@ -181,6 +181,15 @@ class ScheduleOverrideServiceTest {
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE); verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
} }
@Test
void rejectsRegistryRangeLongerThanTwoHundredDays() {
assertThatThrownBy(() -> service.getAll(DATE, DATE.plusDays(200)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон точечных изменений не может превышать 200 дней");
verify(scheduleOverrideRepository, never()).findForRegistry(any(LocalDate.class), any(LocalDate.class));
}
@Test @Test
void availabilityContainsOnlyStudyDatesOfSourceSemester() { void availabilityContainsOnlyStudyDatesOfSourceSemester() {
Semester semester = configureRuleForTarget(); Semester semester = configureRuleForTarget();

View File

@@ -86,8 +86,8 @@ class ScheduleQueryServiceTest {
groupLifecycleService groupLifecycleService
); );
LocalDate startDate = LocalDate.of(2026, 1, 1); LocalDate startDate = LocalDate.of(2026, 1, 1);
LocalDate allowedEndDate = startDate.plusDays(119); LocalDate allowedEndDate = startDate.plusDays(199);
LocalDate rejectedEndDate = startDate.plusDays(120); LocalDate rejectedEndDate = startDate.plusDays(200);
when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate)) when(generatorService.buildScheduleForTeacher(10L, startDate, allowedEndDate))
.thenReturn(List.of()); .thenReturn(List.of());
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate)) when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, allowedEndDate))
@@ -101,7 +101,7 @@ class ScheduleQueryServiceTest {
null, null, null, null, null, null, null, null,
startDate, rejectedEndDate)) startDate, rejectedEndDate))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Диапазон расписания не может превышать 120 дней"); .hasMessage("Диапазон расписания не может превышать 200 дней");
} }
@ParameterizedTest @ParameterizedTest

View File

@@ -365,10 +365,9 @@ Refresh-токен ротируется при каждом успешном о
| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` |
| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` |
Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 200
календарных дат с учётом обеих границ: например, период с 1 января по 30 апреля календарных дат с учётом обеих границ: 200 дат разрешены, 201 дата отклоняется. Если у
невисокосного года содержит ровно 120 дат и разрешён, а по 1 мая — уже 121 дата и группы нет назначения календарного графика на учебный год даты,
отклоняется. Если у группы нет назначения календарного графика на учебный год даты,
расписание для неё возвращается пустым списком. Время пары берётся из базового слота расписание для неё возвращается пустым списком. Время пары берётся из базового слота
правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из правила, но для конкретной даты может быть заменено субботней или ручной сеткой времени из
`/api/admin/time-slots`. `/api/admin/time-slots`.
@@ -508,12 +507,30 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
**Тело учебного года:** **Тело учебного года:**
```json ```json
{ {
"title": "2026-2027",
"startDate": "2026-09-01", "startDate": "2026-09-01",
"semesters": [
{
"semesterType": "autumn",
"startDate": "2026-09-01",
"endDate": "2027-01-31"
},
{
"semesterType": "spring",
"startDate": "2027-02-01",
"endDate": "2027-06-30" "endDate": "2027-06-30"
} }
]
}
``` ```
Название и дата окончания учебного года вычисляются backend автоматически по дате начала:
для `2026-09-01` будут сохранены название `2026-2027` и окончание `2027-06-30`.
В административной форме пользователь вводит только год `2026`; frontend преобразует его
в `startDate: 2026-09-01` и сразу показывает вычисленный год окончания `2027`.
При создании требуется хотя бы один семестр; учебный год и все переданные семестры
сохраняются одной транзакцией. Поля `title` и `endDate`, переданные старым клиентом,
не влияют на вычисленные значения.
**Тело семестра:** **Тело семестра:**
```json ```json
{ {
@@ -526,10 +543,11 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного Границы учебных периодов включительны. Учебные годы не могут пересекаться, семестры одного
года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий года также не могут пересекаться и должны полностью находиться внутри его границ. Следующий
период разрешено начать на следующий календарный день после окончания предыдущего. период разрешено начать на следующий календарный день после окончания предыдущего.
Повторное название года, повторный тип семестра или пересечение возвращаются как Повторный тип семестра или пересечение возвращаются как
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`. `409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
При сужении учебного года все существующие семестры должны оставаться внутри новых границ. При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием Создание учебного года или отдельного семестра также создаёт для каждого нового семестра
пустую версию 1 со статусом `PUBLISHED` и названием
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме. `Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
**Тело создания/обновления кода активности:** **Тело создания/обновления кода активности:**
@@ -752,6 +770,9 @@ ID существующих слотов сохраняются; удалени
| `timeSlotId` | Временной слот | | `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` | | `parity` | `BOTH`, `ODD`, `EVEN` |
Диапазон включает обе границы и не может превышать 200 календарных дней. Этого достаточно,
чтобы запросить осенний или весенний семестр целиком одним обращением.
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
@@ -798,7 +819,7 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила | | `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный `startDate` и `endDate` у списка передаются только парой, включительно; максимальный
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя, диапазон — 200 дней. Ответ реестра содержит исходные дату, время, преподавателя,
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания. или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.

View File

@@ -203,9 +203,10 @@ constraint. При чтении API разворачивает периоды о
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра. В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными Диапазон расписания включает обе переданные границы и ограничен ровно 200 календарными
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 200 дат
разрешены, 121 дата отклоняется до генерации. разрешены, 201 дата отклоняется до генерации. Один запрос поэтому покрывает учебный
семестр целиком.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`, Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и `departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и

View File

@@ -226,13 +226,13 @@ frontend/
при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор
или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной. или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. При выбранном семестре frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу; без семестра сохраняется двухнедельный режим по текущей дате. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней.
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. REST-контракт остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Учебный год создаётся одной формой вместе с обязательными осенним и весенним семестрами: пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры.
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
@@ -357,7 +357,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Просмотр расписаний (`/admin/#schedule-view`) ### Просмотр расписаний (`/admin/#schedule-view`)
Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам. Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. При выборе семестра найденные занятия отображаются одной матрицей на весь период с явными границами недель для каждой пары; без семестра используется совмещённая двухнедельная таблица чётной/нечётной недели.
### Преподаватель (`/teacher/`) ### Преподаватель (`/teacher/`)

View File

@@ -1014,9 +1014,9 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97); background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
top: 50%; top: 0;
left: 50%; left: 0;
transform: translate(-50%, -50%); transform: none;
} }
.schedule-visual-context-menu[hidden] { .schedule-visual-context-menu[hidden] {
@@ -1325,6 +1325,152 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.academic-year-builder-card {
overflow: hidden;
}
.academic-year-builder-description {
margin: 0.28rem 0 0;
font-size: 0.84rem;
}
.academic-year-period-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
#academic-year-end[readonly] {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-input) 82%, var(--accent) 5%);
cursor: default;
}
.academic-year-field-hint {
display: block;
margin-top: 0.4rem;
font-size: 0.72rem;
line-height: 1.35;
}
.academic-year-semester-builder {
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 78%, transparent);
}
.academic-year-semester-builder-head,
.academic-year-semester-card-title,
.academic-year-form-actions {
display: flex;
align-items: center;
}
.academic-year-semester-builder-head {
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.academic-year-semester-builder-head h3,
.academic-year-semester-builder-head p {
margin: 0;
}
.academic-year-semester-builder-head h3 {
font-size: 0.96rem;
}
.academic-year-semester-builder-head p {
margin-top: 0.2rem;
font-size: 0.78rem;
}
.academic-year-required-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent-hover);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.academic-year-semester-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.academic-year-semester-card {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.academic-year-semester-card-title {
gap: 0.5rem;
margin-bottom: 0.65rem;
color: var(--text-primary);
}
.academic-year-semester-dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 14%, transparent);
}
.academic-year-semester-dot-autumn {
color: #f59e0b;
background: #f59e0b;
}
.academic-year-semester-dot-spring {
color: #22c55e;
background: #22c55e;
}
.academic-year-semester-dates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.academic-year-form-actions {
justify-content: flex-end;
margin-top: 0.85rem;
}
@media (max-width: 900px) {
.academic-year-semester-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.academic-year-period-grid,
.academic-year-semester-dates {
grid-template-columns: 1fr;
}
.academic-year-semester-builder-head {
align-items: flex-start;
}
.academic-year-form-actions .btn {
width: 100%;
}
}
.schedule-inline-table { .schedule-inline-table {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -2148,6 +2294,20 @@
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition); transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition);
} }
.calendar-activity-color-10b981 { --activity-color: #10b981; }
.calendar-activity-color-f59e0b { --activity-color: #f59e0b; }
.calendar-activity-color-38bdf8 { --activity-color: #38bdf8; }
.calendar-activity-color-a78bfa { --activity-color: #a78bfa; }
.calendar-activity-color-fb7185 { --activity-color: #fb7185; }
.calendar-activity-color-f97316 { --activity-color: #f97316; }
.calendar-activity-color-22c55e { --activity-color: #22c55e; }
.calendar-activity-color-ef4444 { --activity-color: #ef4444; }
.calendar-activity-color-dc2626 { --activity-color: #dc2626; }
.calendar-activity-color-eab308 { --activity-color: #eab308; }
.calendar-activity-color-06b6d4 { --activity-color: #06b6d4; }
.calendar-activity-color-64748b { --activity-color: #64748b; }
.calendar-activity-color-475569 { --activity-color: #475569; }
.calendar-day:hover, .calendar-day:hover,
.calendar-day:focus-visible { .calendar-day:focus-visible {
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card)); background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card));
@@ -2311,7 +2471,7 @@
} }
.calendar-total-chip { .calendar-total-chip {
--chip-color: var(--accent); --chip-color: var(--activity-color, var(--accent));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;

View File

@@ -54,25 +54,107 @@ export function calculateCalendarTooltipPosition({
}; };
} }
export function academicYearDefaults(startYearValue) {
const value = String(startYearValue || '').trim();
if (!/^\d{4}$/.test(value)) {
throw new TypeError('Год начала учебного года должен состоять из четырёх цифр');
}
const startYear = Number(value);
if (startYear < 1000 || startYear > 9998) {
throw new TypeError('Год начала учебного года должен быть в диапазоне от 1000 до 9998');
}
const endYear = startYear + 1;
const start = new Date(Date.UTC(startYear, 8, 1));
const autumnEnd = new Date(Date.UTC(endYear, 0, 31));
const springStart = new Date(Date.UTC(endYear, 1, 1));
const end = new Date(Date.UTC(endYear, 5, 30));
return {
startYear,
endYear,
title: `${startYear}-${endYear}`,
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(end),
semesters: [
{
semesterType: 'autumn',
startDate: toIsoDateUtc(start),
endDate: toIsoDateUtc(autumnEnd)
},
{
semesterType: 'spring',
startDate: toIsoDateUtc(springStart),
endDate: toIsoDateUtc(end)
}
]
};
}
export function validateAcademicYearSemesters(semesters, yearStart, yearEnd) {
if (!Array.isArray(semesters) || semesters.length === 0) {
throw new Error('Учебный год нельзя создать без семестров');
}
const sorted = [...semesters].sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
const types = new Set();
sorted.forEach((semester, index) => {
if (!semester?.semesterType || !semester.startDate || !semester.endDate) {
throw new Error('Заполните тип и даты каждого семестра');
}
if (types.has(semester.semesterType)) {
throw new Error('Типы семестров не должны повторяться');
}
types.add(semester.semesterType);
if (semester.endDate < semester.startDate) {
throw new Error('Дата окончания семестра не может быть раньше даты начала');
}
if (semester.startDate < yearStart || semester.endDate > yearEnd) {
throw new Error('Семестры должны находиться в границах учебного года');
}
if (index > 0 && sorted[index - 1].endDate >= semester.startDate) {
throw new Error('Периоды семестров не должны пересекаться');
}
});
}
function toIsoDateUtc(date) {
return [
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
export function activityColorCode(activity) {
const color = String(activity?.colorCode || '').trim();
return /^#[0-9a-f]{6}$/i.test(color) ? color : '#64748b';
}
export function activityColorClass(activity) {
return `calendar-activity-color-${activityColorCode(activity).slice(1).toLowerCase()}`;
}
export async function initAcademicCalendar() { export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form'); const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title'); const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id'); const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start'); const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end'); const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset'); const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody'); const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const academicYearSemesterInputs = {
const semesterForm = document.getElementById('semester-form'); autumn: {
const semesterFormTitle = document.getElementById('semester-form-title'); id: document.getElementById('academic-year-autumn-id'),
const semesterIdInput = document.getElementById('semester-id'); start: document.getElementById('academic-year-autumn-start'),
const semesterYearSelect = document.getElementById('semester-year'); end: document.getElementById('academic-year-autumn-end')
const semesterTypeSelect = document.getElementById('semester-type'); },
const semesterStartInput = document.getElementById('semester-start'); spring: {
const semesterEndInput = document.getElementById('semester-end'); id: document.getElementById('academic-year-spring-id'),
const semesterResetButton = document.getElementById('semester-reset'); start: document.getElementById('academic-year-spring-start'),
end: document.getElementById('academic-year-spring-end')
}
};
const calendarForm = document.getElementById('academic-calendar-form'); const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title'); const calendarFormTitle = document.getElementById('academic-calendar-form-title');
@@ -119,7 +201,6 @@ export async function initAcademicCalendar() {
const subjectCount = document.getElementById('calendar-subject-count'); const subjectCount = document.getElementById('calendar-subject-count');
let academicYears = []; let academicYears = [];
let semesters = [];
let specialties = []; let specialties = [];
let profilesBySpecialty = new Map(); let profilesBySpecialty = new Map();
let studyForms = []; let studyForms = [];
@@ -158,10 +239,10 @@ export async function initAcademicCalendar() {
function bindEvents() { function bindEvents() {
[ [
academicYearStartInput, academicYearSemesterInputs.autumn.start,
academicYearEndInput, academicYearSemesterInputs.autumn.end,
semesterStartInput, academicYearSemesterInputs.spring.start,
semesterEndInput, academicYearSemesterInputs.spring.end,
fillStartInput, fillStartInput,
fillEndInput fillEndInput
].forEach(bindDateInputMask); ].forEach(bindDateInputMask);
@@ -172,7 +253,7 @@ export async function initAcademicCalendar() {
academicYearsRefreshButton?.addEventListener('click', loadYears); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm); academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
calendarResetButton?.addEventListener('click', resetCalendarForm); calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid); editorLoadButton?.addEventListener('click', loadCalendarGrid);
@@ -244,7 +325,6 @@ export async function initAcademicCalendar() {
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear); academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
calendarForm?.addEventListener('submit', saveCalendar); calendarForm?.addEventListener('submit', saveCalendar);
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
@@ -269,7 +349,6 @@ export async function initAcademicCalendar() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>'; academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try { try {
academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears(); renderAcademicYears();
populateYearSelects(); populateYearSelects();
} catch (error) { } catch (error) {
@@ -319,17 +398,19 @@ export async function initAcademicCalendar() {
event.preventDefault(); event.preventDefault();
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
try { try {
const defaults = applyAcademicYearDateDefaults(true);
const { startDate, endDate } = defaults;
const payload = { const payload = {
title: academicYearTitleInput.value.trim(), startDate,
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'), endDate,
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года') semesters: ['autumn', 'spring'].map(type => readAcademicYearSemester(type))
}; };
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); validateAcademicYearSemesters(payload.semesters, startDate, endDate);
const id = academicYearIdInput.value; const id = academicYearIdInput.value;
await (id await (id
? api.put('/api/admin/calendar/years/' + id, payload) ? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload)); : api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); showAlert('academic-year-alert', 'Учебный год и семестры сохранены', 'success');
resetAcademicYearForm(); resetAcademicYearForm();
await loadYears(); await loadYears();
await loadCalendars(); await loadCalendars();
@@ -346,28 +427,16 @@ export async function initAcademicCalendar() {
if (editYearButton) { if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id); const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return; if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года'; fillAcademicYearForm(year);
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
if (editSemesterButton) { if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); const year = academicYears.find(item => (item.semesters || [])
if (!semester) return; .some(semester => String(semester.id) === String(editSemesterButton.dataset.id)));
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`; if (!year) return;
semesterIdInput.value = semester.id; const semester = year.semesters.find(item => String(item.id) === String(editSemesterButton.dataset.id));
semesterYearSelect.value = semester.academicYearId || ''; fillAcademicYearForm(year, normalizeSemesterType(semester?.semesterType));
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
} }
@@ -386,42 +455,73 @@ export async function initAcademicCalendar() {
function resetAcademicYearForm() { function resetAcademicYearForm() {
academicYearForm.reset(); academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год'; academicYearFormTitle.textContent = 'Учебный год и семестры';
academicYearIdInput.value = ''; academicYearIdInput.value = '';
Object.values(academicYearSemesterInputs).forEach(inputs => {
inputs.id.value = '';
});
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
} }
async function saveSemester(event) { function fillAcademicYearForm(year, focusedSemesterType = '') {
event.preventDefault(); academicYearFormTitle.textContent = `Редактирование учебного года ${year.title || ''}`.trim();
hideAlert('semester-alert'); academicYearIdInput.value = year.id;
const yearId = semesterYearSelect.value; academicYearStartInput.value = String(year.startDate || '').slice(0, 4);
try { academicYearEndInput.value = String(year.endDate || '').slice(0, 4);
const payload = { ['autumn', 'spring'].forEach(type => {
semesterType: semesterTypeSelect.value, const semester = (year.semesters || []).find(item => normalizeSemesterType(item.semesterType) === type);
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'), const inputs = academicYearSemesterInputs[type];
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра') inputs.id.value = semester?.id || '';
}; setDateInputIso(inputs.start, semester?.startDate || '');
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { setDateInputIso(inputs.end, semester?.endDate || '');
throw new Error('Заполните учебный год, тип и даты семестра'); });
} hideAlert('academic-year-alert');
const id = semesterIdInput.value; academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
await (id if (focusedSemesterType && academicYearSemesterInputs[focusedSemesterType]) {
? api.put('/api/admin/calendar/semesters/' + id, payload) academicYearSemesterInputs[focusedSemesterType].start.focus({ preventScroll: true });
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
} }
} }
function resetSemesterForm() { function handleAcademicYearStartInput() {
semesterForm.reset(); academicYearStartInput.value = academicYearStartInput.value.replace(/\D/g, '').slice(0, 4);
semesterFormTitle.textContent = 'Семестр'; if (academicYearStartInput.value.length !== 4) {
semesterIdInput.value = ''; academicYearEndInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect); return;
hideAlert('semester-alert'); }
try {
applyAcademicYearDateDefaults();
} catch (_) {
academicYearEndInput.value = '';
}
}
function applyAcademicYearDateDefaults(preserveSemesterDates = false) {
const defaults = academicYearDefaults(academicYearStartInput.value);
academicYearEndInput.value = String(defaults.endYear);
const semesterDefaults = {
autumn: defaults.semesters[0],
spring: defaults.semesters[1]
};
['autumn', 'spring'].forEach(type => {
const inputs = academicYearSemesterInputs[type];
if (!preserveSemesterDates || !inputs.start.value) {
setDateInputIso(inputs.start, semesterDefaults[type].startDate);
}
if (!preserveSemesterDates || !inputs.end.value) {
setDateInputIso(inputs.end, semesterDefaults[type].endDate);
}
});
return defaults;
}
function readAcademicYearSemester(type) {
const inputs = academicYearSemesterInputs[type];
return {
id: inputs.id.value ? Number(inputs.id.value) : null,
semesterType: type,
startDate: readDateInputIso(inputs.start, `Начало ${SEMESTER_LABELS[type]} семестра`),
endDate: readDateInputIso(inputs.end, `Окончание ${SEMESTER_LABELS[type]} семестра`)
};
} }
async function loadStudyForms() { async function loadStudyForms() {
@@ -895,7 +995,7 @@ export async function initAcademicCalendar() {
const activity = activityById(selected) || activityTypes[0] || {}; const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т'; const code = activity.code || row.activityCode || 'Т';
return ` return `
<td class="calendar-day" <td class="calendar-day ${activityColorClass(activity)}"
data-course="${row.courseNumber}" data-course="${row.courseNumber}"
data-date="${row.date}" data-date="${row.date}"
data-week="${row.weekNumber}" data-week="${row.weekNumber}"
@@ -967,9 +1067,11 @@ export async function initAcademicCalendar() {
function updateCalendarDayDialogSummary(day) { function updateCalendarDayDialogSummary(day) {
const activity = activityById(day.querySelector('.calendar-day-activity').value); const activity = activityById(day.querySelector('.calendar-day-activity').value);
if (!activity) { if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = ''; calendarDayDialogActivitySummary.innerHTML = '';
return; return;
} }
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span> <span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div> <div>
@@ -987,6 +1089,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days)); const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) { if (!activity) {
applyActivityColorClass(calendarDayDialogActivitySummary, null);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">...</span> <span class="calendar-day-dialog-code">...</span>
<div> <div>
@@ -997,6 +1100,7 @@ export async function initAcademicCalendar() {
return; return;
} }
applyActivityColorClass(calendarDayDialogActivitySummary, activity);
calendarDayDialogActivitySummary.innerHTML = ` calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span> <span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div> <div>
@@ -1091,6 +1195,7 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity'); const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value); const activity = activityById(select.value);
const code = activity?.code || '-'; const code = activity?.code || '-';
applyActivityColorClass(day, activity);
day.querySelector('.calendar-day-code').textContent = code; day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
} }
@@ -1102,6 +1207,8 @@ export async function initAcademicCalendar() {
} }
const tooltip = ensureCalendarDayTooltip(); const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.innerHTML = renderCalendarDayTooltip(day);
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
applyActivityColorClass(tooltip, activity);
tooltip.classList.add('visible'); tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event); positionCalendarDayTooltip(tooltip, day, event);
} }
@@ -1285,7 +1392,9 @@ export async function initAcademicCalendar() {
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value); const activity = activityById(select.value);
const key = activity ? activity.code : '-'; const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1); const current = counts.get(key) || { count: 0, colorClass: activityColorClass(activity) };
current.count += 1;
counts.set(key, current);
}); });
const selectedDays = selectedCalendarDays.length const selectedDays = selectedCalendarDays.length
? selectedCalendarDays ? selectedCalendarDays
@@ -1296,19 +1405,18 @@ export async function initAcademicCalendar() {
const selectedCode = selectedActivity?.code || null; const selectedCode = selectedActivity?.code || null;
calendarTotals.innerHTML = Array.from(counts.entries()) calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru')) .sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => { .map(([code, entry]) => {
const selectedClass = selectedCode === code ? ' is-selected' : ''; const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : ''; const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`; return `<span class="calendar-total-chip ${entry.colorClass}${selectedClass}">${escapeHtml(code)}: ${entry.count}${suffix}</span>`;
}).join(''); }).join('');
} }
function populateYearSelects() { function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' + const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join(''); academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
calendarYearSelect.innerHTML = yearOptions; calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect); syncSelects(calendarYearSelect);
} }
function populateSpecialtySelects() { function populateSpecialtySelects() {
@@ -1474,6 +1582,16 @@ export async function initAcademicCalendar() {
return activityTypes.find(type => String(type.id) === String(id)); return activityTypes.find(type => String(type.id) === String(id));
} }
function applyActivityColorClass(element, activity) {
if (!element) return;
Array.from(element.classList)
.filter(className => className.startsWith('calendar-activity-color-'))
.forEach(className => element.classList.remove(className));
if (activity) {
element.classList.add(activityColorClass(activity));
}
}
function timeAssignmentForDate(date) { function timeAssignmentForDate(date) {
return timeSlotDateAssignments.find(item => item.date === date); return timeSlotDateAssignments.find(item => item.date === date);
} }

View File

@@ -421,6 +421,11 @@ export async function initScheduleView() {
function renderDesktopSchedule(section, range) { function renderDesktopSchedule(section, range) {
const slots = collectSlots(section.lessons); const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderDesktopSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) { if (!blocks.length || !slots.length) {
@@ -432,6 +437,50 @@ export async function initScheduleView() {
.join(''); .join('');
} }
function renderDesktopSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const dayHeaders = DAY_ORDER.map(day => `
<th class="room-day-cell">
<strong>${escapeHtml(dayShort(day))}</strong>
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
</th>
`).join('');
const rows = slots.map(slot => `
<tr>
<td class="room-time-cell">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
</td>
${DAY_ORDER.map(day => renderSemesterSlotCell(day, slot, lessonMatrix)).join('')}
</tr>
`).join('');
tables.innerHTML = `
<section class="schedule-view-week-panel schedule-view-semester-panel">
<div class="room-week-heading">
<h3>${escapeHtml(section.title)}</h3>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
<div class="workload-grid-container schedule-view-grid-container">
<table class="workload-table room-week-table schedule-view-matrix-table">
<thead>
<tr>
<th class="room-time-heading">Время</th>
${dayHeaders}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</section>
`;
}
function renderMobileDayTabs() { function renderMobileDayTabs() {
dayTabs.hidden = false; dayTabs.hidden = false;
dayTabs.innerHTML = DAY_ORDER.map(day => ` dayTabs.innerHTML = DAY_ORDER.map(day => `
@@ -451,6 +500,11 @@ export async function initScheduleView() {
function renderMobileSchedule(section, range) { function renderMobileSchedule(section, range) {
const slots = collectSlots(section.lessons); const slots = collectSlots(section.lessons);
const semester = selectedSemester();
if (semester) {
renderMobileSemesterSchedule(section, slots, semester);
return;
}
const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons);
const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons);
if (!blocks.length || !slots.length) { if (!blocks.length || !slots.length) {
@@ -460,6 +514,34 @@ export async function initScheduleView() {
tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join('');
} }
function renderMobileSemesterSchedule(section, slots, semester) {
if (!slots.length) {
setTableState('Занятия не найдены');
return;
}
const lessonMatrix = buildSemesterLessonMatrix(section.lessons);
const rows = slots.map(slot => `
<article class="schedule-view-mobile-slot">
<div class="schedule-view-mobile-slot-time">
<strong>${escapeHtml(slot.orderLabel)}</strong>
<span>${escapeHtml(slot.timeLabel)}</span>
</div>
<div class="schedule-view-mobile-slot-content">
${renderSlotContent(slotStatesForSemester(state.mobileDay, slot, lessonMatrix))}
</div>
</article>
`).join('');
tables.innerHTML = `
<section class="schedule-view-mobile-day schedule-view-mobile-semester">
<div class="schedule-view-mobile-day-heading">
<strong>${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))}</strong>
<span>${escapeHtml(semesterScheduleCaption(semester))}</span>
</div>
${rows}
</section>
`;
}
function renderMobileDayBlock(block, slots, lessonsByDateSlot) { function renderMobileDayBlock(block, slots, lessonsByDateSlot) {
const dayDates = block.datesByDay[state.mobileDay] || {}; const dayDates = block.datesByDay[state.mobileDay] || {};
const rows = slots.map(slot => { const rows = slots.map(slot => {
@@ -537,6 +619,12 @@ export async function initScheduleView() {
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`; return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
} }
function renderSemesterSlotCell(day, slot, lessonMatrix) {
const states = slotStatesForSemester(day, slot, lessonMatrix);
const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : '';
return `<td class="room-combined-cell${singleCellClass}">${renderSlotContent(states)}</td>`;
}
function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
parity, parity,
@@ -545,6 +633,54 @@ export async function initScheduleView() {
})); }));
} }
function slotStatesForSemester(day, slot, lessonMatrix) {
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const lessons = lessonMatrix.get(`${day}:${slot.key}:${parity}`) || [];
return {
parity,
label,
state: lessons.length ? { kind: 'busy', lessons } : { kind: 'free', lessons: [] }
};
});
}
function buildSemesterLessonMatrix(lessons) {
const weekRanges = new Map();
lessons.forEach(lesson => {
const signature = lessonContentSignature(lesson);
const weekNumber = Number(lesson.weekNumber);
if (!Number.isFinite(weekNumber)) return;
const range = weekRanges.get(signature) || { startWeek: weekNumber, endWeek: weekNumber };
range.startWeek = Math.min(range.startWeek, weekNumber);
range.endWeek = Math.max(range.endWeek, weekNumber);
weekRanges.set(signature, range);
});
const matrix = new Map();
lessons.forEach(lesson => {
const day = Number(lesson.dayOfWeek) || isoDay(lesson.date);
const parity = ['ODD', 'EVEN'].includes(lesson.parity)
? lesson.parity
: (Number(lesson.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD');
const signature = lessonContentSignature(lesson);
const key = `${day}:${lessonSlotKey(lesson)}:${parity}`;
if (!matrix.has(key)) matrix.set(key, new Map());
if (!matrix.get(key).has(signature)) {
const range = weekRanges.get(signature);
matrix.get(key).set(signature, {
...lesson,
displayStartWeek: range?.startWeek,
displayEndWeek: range?.endWeek
});
}
});
return new Map(Array.from(matrix.entries()).map(([key, values]) => [
key,
Array.from(values.values()).sort((a, b) => naturalCompare(a.subjectName, b.subjectName))
]));
}
function renderSlotContent(states) { function renderSlotContent(states) {
if (slotStatesEqual(states[0].state, states[1].state)) { if (slotStatesEqual(states[0].state, states[1].state)) {
return renderUnifiedSlot(states[0].state); return renderUnifiedSlot(states[0].state);
@@ -640,6 +776,9 @@ export async function initScheduleView() {
} }
function getLessonWeeksText(lesson) { function getLessonWeeksText(lesson) {
if (lesson?.displayStartWeek != null && lesson?.displayEndWeek != null) {
return formatLessonWeeksText(lesson.displayStartWeek, lesson.displayEndWeek);
}
if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) { if (!lesson || lesson.weekNumber == null || lesson.lessonTypeAcademicHours == null) {
return ''; return '';
} }
@@ -667,42 +806,7 @@ export async function initScheduleView() {
if (startWeek <= 0) startWeek = 1; if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek; if (endWeek < startWeek) endWeek = startWeek;
return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson)); return formatLessonWeeksText(startWeek, endWeek);
}
function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
return startWeek === endWeek
? `(${startWeek} нед.)`
: `(с ${startWeek} по ${endWeek} нед.)`;
}
function semesterWeeksForLesson(lesson) {
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateInRange(date, startDate, endDate) {
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
} }
function renderLessonCard(lesson) { function renderLessonCard(lesson) {
@@ -801,8 +905,8 @@ export async function initScheduleView() {
function selectCurrentSemester() { function selectCurrentSemester() {
if (!semesterSelect) return; if (!semesterSelect) return;
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value); const defaultSemester = findDefaultSemester(state.dictionaries.semesters, dateInput.value);
semesterSelect.value = currentSemester ? String(currentSemester.id) : ''; semesterSelect.value = defaultSemester ? String(defaultSemester.id) : '';
synchronizeDateWithSemester(); synchronizeDateWithSemester();
updatePeriodLabel(); updatePeriodLabel();
} }
@@ -1107,11 +1211,42 @@ export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null; return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
} }
export function findDefaultSemester(semesters, date) {
const availableSemesters = semesters || [];
return findSemesterForDate(availableSemesters, date) || availableSemesters[0] || null;
}
export function scheduleRangeForSemester(selectedDate, semester) { export function scheduleRangeForSemester(selectedDate, semester) {
const anchorDate = semester && !dateWithinSemester(selectedDate, semester) if (semester?.startDate && semester?.endDate) {
? semester.startDate return {
: selectedDate; startDate: semester.startDate,
return twoWeekRange(anchorDate); endDate: semester.endDate
};
}
return twoWeekRange(selectedDate);
}
export function formatLessonWeeksText(startWeek, endWeek) {
const start = Number(startWeek);
const end = Number(endWeek);
if (!Number.isFinite(start) || !Number.isFinite(end)) return '';
return start === end
? `(${start} нед.)`
: `(с ${start} по ${end} нед.)`;
}
function semesterScheduleCaption(semester) {
const weeks = semesterWeekCount(semester);
const weekLabel = weeks === 1 ? '1 неделя' : `недели 1–${weeks}`;
return `Весь семестр · ${weekLabel}`;
}
function semesterWeekCount(semester) {
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
} }
function dateWithinSemester(date, semester) { function dateWithinSemester(date, semester) {

View File

@@ -29,6 +29,33 @@ const SEMESTER_LABELS = {
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2; const ACADEMIC_HOURS_PER_SLOT = 2;
const VISUAL_MENU_GAP = 8;
const VISUAL_MENU_VIEWPORT_PADDING = 8;
export function calculateVisualContextMenuPosition({
anchorRect,
menuWidth,
menuHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
let left = anchorRect.right + VISUAL_MENU_GAP;
let top = anchorRect.top;
if (left > maxLeft) {
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
}
if (top > maxTop) {
top = anchorRect.bottom - menuHeight;
}
return {
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
export function scheduleSlotActionLabel(slotCount) { export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить'; return slotCount === 1 ? 'Очистить' : 'Удалить';
@@ -160,6 +187,7 @@ export async function initSchedule() {
let syncingVisualPeriod = false; let syncingVisualPeriod = false;
let pendingConflictResolution = null; let pendingConflictResolution = null;
let visualContextTarget = null; let visualContextTarget = null;
let visualContextMenuPositionAnimation = null;
let pendingSlotMove = null; let pendingSlotMove = null;
let creatingDraft = false; let creatingDraft = false;
@@ -207,6 +235,7 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange); visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick); visualStage?.addEventListener('click', handleVisualStageClick);
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
conflictCloseButton?.addEventListener('click', closeConflictModal); conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal); conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => { conflictModal?.addEventListener('click', (event) => {
@@ -555,6 +584,7 @@ export async function initSchedule() {
function closeVisualDrawer() { function closeVisualDrawer() {
if (!visualDrawer) return; if (!visualDrawer) return;
closeVisualContextMenu();
visualDrawer.classList.remove('open'); visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true'); visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open'); visualToggleButton?.classList.remove('open');
@@ -882,6 +912,23 @@ export async function initSchedule() {
}; };
if (!visualContextMenu) return; if (!visualContextMenu) return;
visualContextMenu.hidden = false; visualContextMenu.hidden = false;
const anchorRect = button.getBoundingClientRect();
const position = calculateVisualContextMenuPosition({
anchorRect,
menuWidth: visualContextMenu.offsetWidth,
menuHeight: visualContextMenu.offsetHeight,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
visualContextMenuPositionAnimation?.cancel();
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
visualContextMenuPositionAnimation = visualContextMenu.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
visualContextMenu.querySelector('button')?.focus(); visualContextMenu.querySelector('button')?.focus();
} }
@@ -889,6 +936,8 @@ export async function initSchedule() {
visualContextTarget = null; visualContextTarget = null;
if (!visualContextMenu) return; if (!visualContextMenu) return;
visualContextMenu.hidden = true; visualContextMenu.hidden = true;
visualContextMenuPositionAnimation?.cancel();
visualContextMenuPositionAnimation = null;
} }
async function handleVisualContextMenuClick(event) { async function handleVisualContextMenuClick(event) {

View File

@@ -5,25 +5,77 @@
</div> </div>
<section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel"> <section class="academic-calendar-tab-panel active" id="academic-calendar-tab-manage" data-calendar-tab-panel="manage" role="tabpanel">
<div class="card create-card"> <div class="card create-card academic-year-builder-card">
<h2 id="academic-year-form-title">Учебный год</h2> <div class="card-header-row">
<div>
<h2 id="academic-year-form-title">Учебный год и семестры</h2>
<p class="muted academic-year-builder-description">Учебный год создаётся сразу с осенним и весенним семестрами.</p>
</div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div>
<form id="academic-year-form" novalidate> <form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id"> <input type="hidden" id="academic-year-id">
<div class="form-row"> <div class="academic-year-period-grid">
<div class="form-group"> <div class="form-group">
<label for="academic-year-title">Название</label> <label for="academic-year-start">Год начала учебного года</label>
<input type="text" id="academic-year-title" placeholder="2026-2027" required> <input type="text" id="academic-year-start" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="2026" autocomplete="off" aria-describedby="academic-year-start-hint" required>
<small class="muted academic-year-field-hint" id="academic-year-start-hint">Введите четыре цифры — дата начала будет 1 сентября.</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-start">Начало</label> <label for="academic-year-end">Год окончания учебного года</label>
<input type="text" id="academic-year-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required> <input type="text" id="academic-year-end" inputmode="numeric" maxlength="4" placeholder="2027" readonly aria-readonly="true" required>
<small class="muted academic-year-field-hint">Подставляется автоматически и формирует название учебного года.</small>
</div>
</div>
<section class="academic-year-semester-builder" aria-labelledby="academic-year-semesters-title">
<div class="academic-year-semester-builder-head">
<div>
<h3 id="academic-year-semesters-title">Семестры</h3>
<p class="muted">Проверьте даты: оба семестра будут сохранены вместе с учебным годом.</p>
</div>
<span class="academic-year-required-badge">Обязательно</span>
</div>
<div class="academic-year-semester-grid">
<article class="academic-year-semester-card" data-academic-year-semester="autumn">
<input type="hidden" id="academic-year-autumn-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-autumn" aria-hidden="true"></span>
<strong>Осенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-autumn-start">Начало</label>
<input type="text" id="academic-year-autumn-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-end">Окончание</label> <label for="academic-year-autumn-end">Окончание</label>
<input type="text" id="academic-year-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required> <input type="text" id="academic-year-autumn-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div> </div>
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button> </div>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button> </article>
<article class="academic-year-semester-card" data-academic-year-semester="spring">
<input type="hidden" id="academic-year-spring-id">
<div class="academic-year-semester-card-title">
<span class="academic-year-semester-dot academic-year-semester-dot-spring" aria-hidden="true"></span>
<strong>Весенний семестр</strong>
</div>
<div class="academic-year-semester-dates">
<div class="form-group">
<label for="academic-year-spring-start">Начало</label>
<input type="text" id="academic-year-spring-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="academic-year-spring-end">Окончание</label>
<input type="text" id="academic-year-spring-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
</div>
</article>
</div>
</section>
<div class="academic-year-form-actions">
<button type="submit" class="btn btn-md btn-primary">Сохранить учебный год и семестры</button>
</div> </div>
<div class="form-alert" id="academic-year-alert" role="alert"></div> <div class="form-alert" id="academic-year-alert" role="alert"></div>
</form> </form>
@@ -53,39 +105,6 @@
</div> </div>
</div> </div>
<div class="card create-card">
<h2 id="semester-form-title">Семестр</h2>
<form id="semester-form" novalidate>
<input type="hidden" id="semester-id">
<div class="form-row">
<div class="form-group">
<label for="semester-year">Учебный год</label>
<select id="semester-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="semester-type">Тип семестра</label>
<select id="semester-type" required>
<option value="autumn">Осенний</option>
<option value="spring">Весенний</option>
</select>
</div>
<div class="form-group">
<label for="semester-start">Начало</label>
<input type="text" id="semester-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<div class="form-group">
<label for="semester-end">Окончание</label>
<input type="text" id="semester-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
</div>
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
</div>
<div class="form-alert" id="semester-alert" role="alert"></div>
</form>
</div>
<div class="card create-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2> <h2 id="academic-calendar-form-title">Календарный учебный график</h2>

View File

@@ -158,10 +158,10 @@
</div> </div>
</div> </div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden> <div class="schedule-visual-context-menu" id="schedule-visual-context-menu" role="menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button> <button type="button" role="menuitem" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button> <button type="button" role="menuitem" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button> <button type="button" role="menuitem" data-action="delete-rule" class="danger">Удалить правило</button>
</div> </div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true"> <div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import {
academicYearDefaults,
activityColorClass,
activityColorCode,
validateAcademicYearSemesters
} from '../admin/js/views/academic-calendar.js';
test('окончание, название и границы семестров рассчитываются по году начала', () => {
assert.deepEqual(academicYearDefaults('2026'), {
startYear: 2026,
endYear: 2027,
title: '2026-2027',
startDate: '2026-09-01',
endDate: '2027-06-30',
semesters: [
{ semesterType: 'autumn', startDate: '2026-09-01', endDate: '2027-01-31' },
{ semesterType: 'spring', startDate: '2027-02-01', endDate: '2027-06-30' }
]
});
});
test('год начала должен состоять ровно из четырёх цифр', () => {
assert.throws(() => academicYearDefaults('26'), /четырёх цифр/);
assert.throws(() => academicYearDefaults('202A'), /четырёх цифр/);
assert.throws(() => academicYearDefaults('9999'), /диапазоне/);
});
test('учебный год нельзя подготовить без семестров', () => {
assert.throws(
() => validateAcademicYearSemesters([], '2026-09-01', '2027-06-30'),
/Учебный год нельзя создать без семестров/
);
});
test('пересекающиеся семестры отклоняются до отправки формы', () => {
assert.throws(
() => validateAcademicYearSemesters([
{ semesterType: 'autumn', startDate: '2026-09-01', endDate: '2027-02-10' },
{ semesterType: 'spring', startDate: '2027-02-01', endDate: '2027-06-30' }
], '2026-09-01', '2027-06-30'),
/Периоды семестров не должны пересекаться/
);
});
test('цвет обозначения берётся из справочника и имеет безопасный запасной вариант', () => {
assert.equal(activityColorCode({ colorCode: '#f59e0b' }), '#f59e0b');
assert.equal(activityColorClass({ colorCode: '#f59e0b' }), 'calendar-activity-color-f59e0b');
assert.equal(activityColorCode({ colorCode: 'red; background: black' }), '#64748b');
});
test('форма создания не содержит поля названия и включает оба семестра', async () => {
const view = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
assert.doesNotMatch(view, /id="academic-year-title"/);
assert.match(view, /id="academic-year-start"[^>]+maxlength="4"[^>]+pattern="\[0-9\]\{4\}"/);
assert.match(view, /id="academic-year-end"[^>]+maxlength="4"[^>]+readonly/);
assert.match(view, /id="academic-year-autumn-start"/);
assert.match(view, /id="academic-year-spring-start"/);
assert.match(view, /Сохранить учебный год и семестры/);
});

View File

@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
findDefaultSemester,
findSemesterForDate, findSemesterForDate,
formatLessonWeeksText,
scheduleRangeForSemester, scheduleRangeForSemester,
semesterOptionLabel semesterOptionLabel
} from '../admin/js/views/schedule-view.js'; } from '../admin/js/views/schedule-view.js';
@@ -31,20 +33,29 @@ test('семестр определяется по выбранной дате
assert.equal(findSemesterForDate(semesters, '2026-07-01'), null); assert.equal(findSemesterForDate(semesters, '2026-07-01'), null);
}); });
test('при выборе другого семестра диапазон начинается с его первой учебной недели', () => { test('вне учебного периода по умолчанию выбирается последний доступный семестр', () => {
assert.equal(findDefaultSemester([springSemester, autumnSemester], '2026-08-14')?.id, 12);
});
test('при выборе семестра диапазон охватывает его целиком', () => {
assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), { assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), {
startDate: '2025-09-01', startDate: '2025-09-01',
endDate: '2025-09-14' endDate: '2026-01-31'
}); });
}); });
test('внутри выбранного семестра сохраняется текущая двухнедельная точка просмотра', () => { test('дата внутри выбранного семестра не сужает его до двух недель', () => {
assert.deepEqual(scheduleRangeForSemester('2026-03-18', springSemester), { assert.deepEqual(scheduleRangeForSemester('2026-03-18', springSemester), {
startDate: '2026-03-16', startDate: '2026-02-09',
endDate: '2026-03-29' endDate: '2026-06-30'
}); });
}); });
test('диапазон недель занятия всегда показывается явно', () => {
assert.equal(formatLessonWeeksText(1, 18), '(с 1 по 18 нед.)');
assert.equal(formatLessonWeeksText(7, 7), '(7 нед.)');
});
test('название семестра содержит учебный год, тип и границы', () => { test('название семестра содержит учебный год, тип и границы', () => {
assert.equal( assert.equal(
semesterOptionLabel(springSemester), semesterOptionLabel(springSemester),

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { calculateVisualContextMenuPosition } from '../admin/js/views/schedule.js';
test('меню пары открывается справа от кнопки с тремя точками', () => {
assert.deepEqual(calculateVisualContextMenuPosition({
anchorRect: { left: 100, right: 120, top: 80, bottom: 100 },
menuWidth: 220,
menuHeight: 120,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 128, top: 80 });
});
test('у края экрана меню раскрывается влево и остаётся в viewport', () => {
assert.deepEqual(calculateVisualContextMenuPosition({
anchorRect: { left: 1240, right: 1260, top: 680, bottom: 700 },
menuWidth: 220,
menuHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 1012, top: 540 });
});
test('контекстное меню больше не центрируется стилями', async () => {
const styles = await readFile(new URL('../admin/css/components.css', import.meta.url), 'utf8');
const menuRule = styles.match(/\.schedule-visual-context-menu\s*\{([\s\S]*?)\}/)?.[1] || '';
assert.doesNotMatch(menuRule, /translate\(-50%,\s*-50%\)/);
assert.match(menuRule, /position:\s*fixed/);
});

View File

@@ -0,0 +1,25 @@
---
type: "query"
date: "2026-08-14T20:42:46.535559+00:00"
question: "1. сделай на вкладке просмотра расписаний - просмотр расписаний на весть семестр с указанием в таблице с какой по какую неделю идёт занятие. 2. Убери название из поля создания учебного года. Пусть будет заполняться начало учебного года, а год окончания будет подставляться автоматически. 3. Учебный год не может создаваться без семестров. Перенеси блок создания семестров внутрь блока создания учебного года, чтобы они создавались вместе. 4. В редактировании сетки календарного графика каждому обозначению должен быть присвоен свой цвет. 5. На вкладке редактирования правил в боковой панели визуальное расписание групп при нажатии на три точки возле пары модальное окно открывается посередине экрана, а не рядом с тремя точками. Это нужно исправить."
contributor: "graphify"
outcome: "useful"
source_nodes: ["Semester", "AcademicYear", "AcademicCalendarAdminController", "ScheduleRule"]
---
# Q: 1. сделай на вкладке просмотра расписаний - просмотр расписаний на весть семестр с указанием в таблице с какой по какую неделю идёт занятие. 2. Убери название из поля создания учебного года. Пусть будет заполняться начало учебного года, а год окончания будет подставляться автоматически. 3. Учебный год не может создаваться без семестров. Перенеси блок создания семестров внутрь блока создания учебного года, чтобы они создавались вместе. 4. В редактировании сетки календарного графика каждому обозначению должен быть присвоен свой цвет. 5. На вкладке редактирования правил в боковой панели визуальное расписание групп при нажатии на три точки возле пары модальное окно открывается посередине экрана, а не рядом с тремя точками. Это нужно исправить.
## Answer
Expanded from original query via vocab: [schedule, semester, week, period, academic, calendar, year, grid, activity, color, rule, menu]. Graph traversal located Semester, AcademicYear, AcademicCalendarAdminController and ScheduleRule as navigation anchors. Source inspection confirmed the implementation points; the completed change adds whole-semester schedule rendering with week ranges, atomic academic-year and semester creation, activity colors, and anchored rule menus.
## Outcome
- Signal: useful
## Source Nodes
- Semester
- AcademicYear
- AcademicCalendarAdminController
- ScheduleRule

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-08-14T20:57:53.698944+00:00"
question: "в создании учебного года поле начало учебного года нужно заполнять только год начала учебного года, допустим 2026, тогда год окончания автоматически подставится 2027 и из этого будет сформировано название."
contributor: "graphify"
outcome: "useful"
source_nodes: ["AcademicYear", "AcademicCalendarAdminController", "Semester"]
---
# Q: в создании учебного года поле начало учебного года нужно заполнять только год начала учебного года, допустим 2026, тогда год окончания автоматически подставится 2027 и из этого будет сформировано название.
## Answer
Expanded from original query via vocab: [academic, year, start, date, title, name, create, form, semester, period, default, calendar]. Graph traversal located AcademicYear, AcademicCalendarAdminController and Semester as navigation anchors. Source inspection confirmed and implemented the four-digit start year flow: 2026 produces startDate 2026-09-01, end year 2027, endDate 2027-06-30 and title 2026-2027.
## Outcome
- Signal: useful
## Source Nodes
- AcademicYear
- AcademicCalendarAdminController
- Semester