feat: улучшить учебные периоды и просмотр расписаний
This commit is contained in:
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 дней");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
docs/API.md
37
docs/API.md
@@ -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 дней. Ответ реестра содержит исходные дату, время, преподавателя,
|
||||||
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
|
||||||
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
|
||||||
|
|
||||||
|
|||||||
@@ -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 активных групп и
|
||||||
|
|||||||
@@ -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/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
64
frontend/tests/academic-year-builder.test.mjs
Normal file
64
frontend/tests/academic-year-builder.test.mjs
Normal 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, /Сохранить учебный год и семестры/);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
|||||||
33
frontend/tests/schedule-visual-menu.test.mjs
Normal file
33
frontend/tests/schedule-visual-menu.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user