3 Commits

Author SHA1 Message Date
Zuev
a78a93f2f0 #5 8 9 10 12 13 17 - готовы. #15 - тест 2026-07-27 02:23:26 +03:00
dipatrik10
3e8722a26b Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-07-27 00:40:12 +03:00
dipatrik10
7e29c53ce9 комит 2026-07-27 00:33:16 +03:00
36 changed files with 1274 additions and 164 deletions

View File

@@ -1,37 +0,0 @@
# Скопируйте файл в .env и замените пустые значения локальными секретами.
POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword#<----
POSTGRES_DB=app_db
# Сгенерировать: openssl rand -base64 48
JWT_SECRET=#<----
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
JWT_REFRESH_COOKIE_NAME=magistr_refresh
JWT_REFRESH_COOKIE_SECURE=false
JWT_REFRESH_CLEANUP_ENABLED=true
JWT_REFRESH_CLEANUP_RETENTION=30d
JWT_REFRESH_CLEANUP_BATCH_SIZE=500
JWT_REFRESH_CLEANUP_MAX_BATCHES=20
JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000
JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000
# Защита входа. Указывайте только адреса/сети фактических reverse proxy.
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_CLEANUP_ENABLED=true
LOGIN_AUDIT_RETENTION=90d
LOGIN_AUDIT_CLEANUP_BATCH_SIZE=500
LOGIN_AUDIT_CLEANUP_MAX_BATCHES=20
LOGIN_AUDIT_CLEANUP_INTERVAL_MS=86400000
LOGIN_AUDIT_CLEANUP_INITIAL_DELAY_MS=120000
TRUSTED_PROXY_CIDRS=172.16.0.0/12
# Календарные бизнес-даты приложения.
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
# Локально Collector не входит в Compose. Укажите false при подключённом OTLP Collector.
OTEL_SDK_DISABLED=true

View File

@@ -7,6 +7,7 @@ import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -152,9 +153,6 @@ public class AcademicCalendarController {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика"); throw new IllegalArgumentException("Передайте данные календарного графика");
} }
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) { if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
} }
@@ -178,7 +176,7 @@ public class AcademicCalendarController {
EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(request.title().trim()); calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
calendar.setAcademicYear(year); calendar.setAcademicYear(year);
calendar.setSpeciality(speciality); calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile); calendar.setSpecialtyProfile(profile);

View File

@@ -114,7 +114,8 @@ public class AcademicCalendarGridService {
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) { private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date); long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
int expectedWeek = Math.toIntExact(daysFromYearStart / 7 + 1); int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1;
int expectedWeek = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1);
if (weekNumber != expectedWeek) { if (weekNumber != expectedWeek) {
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года"); throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
} }

View File

@@ -20,6 +20,7 @@ import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository; import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.repository.SubgroupRepository; import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import com.magistr.app.utils.CourseAndSemesterCalculator; import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -126,7 +127,12 @@ public class AcademicStructureService {
); );
} }
calendar.setTitle(request.title().trim()); calendar.setTitle(AcademicCalendarTitleBuilder.build(
candidate.academicYear(),
candidate.speciality(),
candidate.profile(),
candidate.studyForm()
));
applyCalendarDimensions(calendar, candidate, request.courseCount()); applyCalendarDimensions(calendar, candidate, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar); AcademicCalendar saved = saveCalendar(calendar);
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
@@ -208,9 +214,6 @@ public class AcademicStructureService {
if (request == null) { if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика"); throw new IllegalArgumentException("Передайте данные календарного графика");
} }
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) { if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
} }

View File

@@ -0,0 +1,38 @@
package com.magistr.app.utils;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import java.util.List;
public final class AcademicCalendarTitleBuilder {
private static final String SEPARATOR = "";
private AcademicCalendarTitleBuilder() {
}
public static String build(AcademicYear academicYear,
Speciality speciality,
SpecialtyProfile profile,
EducationForm studyForm) {
List<String> parts = List.of(
normalize(speciality == null ? null : speciality.getSpecialityCode()),
normalize(profile == null ? null : profile.getName()),
normalize(studyForm == null ? null : studyForm.getName()),
normalize(academicYear == null ? null : academicYear.getTitle())
);
if (parts.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException(
"Невозможно сформировать название календарного графика из выбранных параметров"
);
}
return String.join(SEPARATOR, parts);
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,23 @@
-- Недели календарного учебного графика выровнены по ISO-неделе:
-- понедельник — первый день, воскресенье — последний.
UPDATE academic_calendar_days AS calendar_day
SET week_number = (
(
calendar_day.date
- academic_year.start_date
+ EXTRACT(ISODOW FROM academic_year.start_date)::INTEGER
- 1
) / 7
) + 1
FROM academic_calendars AS calendar
JOIN academic_years AS academic_year
ON academic_year.id = calendar.academic_year_id
WHERE calendar_day.calendar_id = calendar.id
AND calendar_day.week_number IS DISTINCT FROM (
(
calendar_day.date
- academic_year.start_date
+ EXTRACT(ISODOW FROM academic_year.start_date)::INTEGER
- 1
) / 7
) + 1;

View File

@@ -0,0 +1,98 @@
package com.magistr.app.controller;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest {
@Test
void createCalendarBuildsTitleWithoutClientValue() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class);
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
AcademicYear year = new AcademicYear();
year.setId(1L);
year.setTitle("2026-2027");
year.setStartDate(LocalDate.of(2026, 9, 1));
year.setEndDate(LocalDate.of(2027, 6, 30));
Speciality speciality = new Speciality(2L, "Программная инженерия", "09.03.04");
SpecialtyProfile profile = new SpecialtyProfile();
profile.setId(3L);
profile.setSpeciality(speciality);
profile.setName("Разработка ПО");
EducationForm studyForm = new EducationForm();
studyForm.setId(4L);
studyForm.setName("Очная");
when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year));
when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(3L)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm));
when(calendarRepository.save(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarDayRepository.class),
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
mock(AcademicStructureService.class)
);
AcademicCalendarDto request = new AcademicCalendarDto(
null,
null,
1L,
null,
null,
null,
2L,
null,
null,
3L,
null,
4L,
null,
4
);
var response = controller.createCalendar(request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
}
}

View File

@@ -0,0 +1,218 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция нумерации недель календарного графика")
class AcademicCalendarWeekNumberMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 переносит первый понедельник после неполной недели в неделю 2")
void migrationAlignsWeeksToMondayFor2026And2027() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long calendar2026;
long calendar2027;
try (Connection connection = POSTGRES.createConnection("")) {
SeedReferences references = loadSeedReferences(connection);
long year2026 = insertAcademicYear(
connection,
"2026-2027",
LocalDate.of(2026, 9, 1),
LocalDate.of(2027, 6, 30)
);
calendar2026 = insertCalendar(connection, year2026, references, "График 2026-2027");
insertCalendarDay(connection, calendar2026, references.activityTypeId(),
LocalDate.of(2026, 9, 1), 1, 2);
insertCalendarDay(connection, calendar2026, references.activityTypeId(),
LocalDate.of(2026, 9, 7), 1, 1);
long year2027 = insertAcademicYear(
connection,
"2027-2028",
LocalDate.of(2027, 9, 1),
LocalDate.of(2028, 6, 30)
);
calendar2027 = insertCalendar(connection, year2027, references, "График 2027-2028");
insertCalendarDay(connection, calendar2027, references.activityTypeId(),
LocalDate.of(2027, 9, 1), 1, 3);
insertCalendarDay(connection, calendar2027, references.activityTypeId(),
LocalDate.of(2027, 9, 6), 1, 1);
}
Flyway latest = latestFlyway();
latest.migrate();
assertThat(latest.info().current()).isNotNull();
assertThat(latest.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(findWeekNumber(connection, calendar2026, LocalDate.of(2026, 9, 1))).isEqualTo(1);
assertThat(findWeekNumber(connection, calendar2026, LocalDate.of(2026, 9, 7))).isEqualTo(2);
assertThat(findWeekNumber(connection, calendar2027, LocalDate.of(2027, 9, 1))).isEqualTo(1);
assertThat(findWeekNumber(connection, calendar2027, LocalDate.of(2027, 9, 6))).isEqualTo(2);
}
}
private Flyway flyway(String targetVersion) {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion(targetVersion))
.cleanDisabled(false)
.load();
}
private Flyway latestFlyway() {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.cleanDisabled(false)
.load();
}
private SeedReferences loadSeedReferences(Connection connection) throws SQLException {
long specialtyId = queryLong(connection, "SELECT id FROM specialties ORDER BY id LIMIT 1");
long profileId = queryLong(
connection,
"SELECT id FROM specialty_profiles WHERE specialty_id = ? ORDER BY id LIMIT 1",
specialtyId
);
long studyFormId = queryLong(connection, "SELECT id FROM education_forms ORDER BY id LIMIT 1");
long activityTypeId = queryLong(
connection,
"SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'"
);
return new SeedReferences(specialtyId, profileId, studyFormId, activityTypeId);
}
private long insertAcademicYear(Connection connection,
String title,
LocalDate startDate,
LocalDate endDate) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES (?, ?, ?)
RETURNING id
""")) {
statement.setString(1, title);
statement.setDate(2, Date.valueOf(startDate));
statement.setDate(3, Date.valueOf(endDate));
return requiredLong(statement);
}
}
private long insertCalendar(Connection connection,
long academicYearId,
SeedReferences references,
String title) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
VALUES (?, ?, ?, ?, ?, 1)
RETURNING id
""")) {
statement.setString(1, title);
statement.setLong(2, academicYearId);
statement.setLong(3, references.specialtyId());
statement.setLong(4, references.profileId());
statement.setLong(5, references.studyFormId());
return requiredLong(statement);
}
}
private void insertCalendarDay(Connection connection,
long calendarId,
long activityTypeId,
LocalDate date,
int weekNumber,
int dayOfWeek) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (
calendar_id,
course_number,
date,
week_number,
day_of_week,
activity_type_id
)
VALUES (?, 1, ?, ?, ?, ?)
""")) {
statement.setLong(1, calendarId);
statement.setDate(2, Date.valueOf(date));
statement.setInt(3, weekNumber);
statement.setInt(4, dayOfWeek);
statement.setLong(5, activityTypeId);
statement.executeUpdate();
}
}
private int findWeekNumber(Connection connection,
long calendarId,
LocalDate date) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT week_number
FROM academic_calendar_days
WHERE calendar_id = ?
AND date = ?
""")) {
statement.setLong(1, calendarId);
statement.setDate(2, Date.valueOf(date));
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getInt(1);
}
}
}
private long queryLong(Connection connection, String sql, Object... arguments) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (int index = 0; index < arguments.length; index++) {
statement.setObject(index + 1, arguments[index]);
}
return requiredLong(statement);
}
}
private long requiredLong(PreparedStatement statement) throws SQLException {
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record SeedReferences(
long specialtyId,
long profileId,
long studyFormId,
long activityTypeId
) {
}
}

View File

@@ -49,10 +49,11 @@ class AcademicCalendarGridServiceTest {
private AcademicCalendarGridService service; private AcademicCalendarGridService service;
private AcademicCalendarActivityType activityType; private AcademicCalendarActivityType activityType;
private AcademicYear academicYear;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
AcademicYear academicYear = new AcademicYear(); academicYear = new AcademicYear();
academicYear.setId(1L); academicYear.setId(1L);
academicYear.setTitle("2025-2026"); academicYear.setTitle("2025-2026");
academicYear.setStartDate(YEAR_START); academicYear.setStartDate(YEAR_START);
@@ -197,6 +198,48 @@ class AcademicCalendarGridServiceTest {
}); });
} }
@Test
void alignsFirstWeekToMondayWhenAcademicYearStartsOnTuesday() {
LocalDate yearStart = LocalDate.of(2026, 9, 1);
academicYear.setStartDate(yearStart);
academicYear.setEndDate(LocalDate.of(2027, 6, 30));
service.replaceGrid(CALENDAR_ID, List.of(
validRow(yearStart, 1, 2),
validRow(LocalDate.of(2026, 9, 6), 1, 7),
validRow(LocalDate.of(2026, 9, 7), 2, 1),
validRow(LocalDate.of(2026, 9, 14), 3, 1)
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
}
@Test
void alignsFirstWeekToMondayWhenAcademicYearStartsOnWednesday() {
LocalDate yearStart = LocalDate.of(2027, 9, 1);
academicYear.setStartDate(yearStart);
academicYear.setEndDate(LocalDate.of(2028, 6, 30));
service.replaceGrid(CALENDAR_ID, List.of(
validRow(yearStart, 1, 3),
validRow(LocalDate.of(2027, 9, 5), 1, 7),
validRow(LocalDate.of(2027, 9, 6), 2, 1),
validRow(LocalDate.of(2027, 9, 13), 3, 1)
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
}
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) { private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID); return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
} }

View File

@@ -168,6 +168,18 @@ class AcademicStructureServiceTest {
.hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров"); .hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров");
} }
@Test
void buildsCalendarTitleFromDimensionsInsteadOfRequestField() {
AcademicCalendar saved = service.updateCalendar(
CALENDAR_ID,
calendarRequest(4, FORM_ID)
);
assertThat(saved.getTitle())
.isEqualTo("09.03.01 — Без профиля — Бакалавриат — 2025-2026");
verify(calendarRepository).saveAndFlush(calendar);
}
@Test @Test
void rejectsGroupDimensionChangeAndKeepsAssignment() { void rejectsGroupDimensionChangeAndKeepsAssignment() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID)) when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
@@ -280,7 +292,7 @@ class AcademicStructureServiceTest {
private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) { private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) {
return new AcademicCalendarDto( return new AcademicCalendarDto(
CALENDAR_ID, CALENDAR_ID,
"График 2026", null,
YEAR_ID, YEAR_ID,
null, null,
null, null,

View File

@@ -0,0 +1,40 @@
package com.magistr.app.utils;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AcademicCalendarTitleBuilderTest {
@Test
void buildsTitleFromCalendarDimensions() {
AcademicYear year = new AcademicYear();
year.setTitle(" 2026-2027 ");
Speciality speciality = new Speciality();
speciality.setSpecialityCode(" 09.03.04 ");
SpecialtyProfile profile = new SpecialtyProfile();
profile.setName(" Программная инженерия ");
EducationForm studyForm = new EducationForm();
studyForm.setName(" Очная ");
assertThat(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm))
.isEqualTo("09.03.04 — Программная инженерия — Очная — 2026-2027");
}
@Test
void rejectsIncompleteDimensions() {
assertThatThrownBy(() -> AcademicCalendarTitleBuilder.build(
new AcademicYear(),
new Speciality(),
new SpecialtyProfile(),
new EducationForm()
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Невозможно сформировать название календарного графика из выбранных параметров");
}
}

View File

@@ -63,7 +63,7 @@
1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. 1. В разделе кафедр и специальностей администратор создаёт специальность и её профили.
2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. 2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения.
3. В разделе календарного графика администратор создаёт учебный год и семестры. 3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 4. Там же создаётся календарный учебный график: администратор выбирает учебный год, специальность, профиль, форму обучения из общего справочника и количество курсов. Название вручную не вводится — frontend формирует его в порядке `код специальности — профиль обучения — форма обучения — учебный год`.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`. 6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам. 7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.

View File

@@ -559,7 +559,6 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
**Тело создания/обновления графика:** **Тело создания/обновления графика:**
```json ```json
{ {
"title": "09.03.04 очная форма 2025-2026",
"academicYearId": 1, "academicYearId": 1,
"specialtyId": 2, "specialtyId": 2,
"specialtyProfileId": 3, "specialtyProfileId": 3,
@@ -570,6 +569,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
`courseCount` должен быть в диапазоне `1..8`. `courseCount` должен быть в диапазоне `1..8`.
Поле `title` в запросе не требуется и не влияет на результат: backend всегда формирует
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
Отдельного пользовательского названия у календарного графика нет.
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
@@ -594,7 +596,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
удаления прежних строк backend проверяет весь список: он должен быть непустым и не удаления прежних строк backend проверяет весь список: он должен быть непустым и не
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год, содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода `dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый `понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
`(courseNumber, date)` не должен повторяться, каждый
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка `activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL. остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.

View File

@@ -153,6 +153,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар | | `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Название календарного учебного графика не является отдельным пользовательским атрибутом.
При создании и изменении backend всегда формирует его из кода специальности, названия
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
@@ -166,7 +170,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
расписания очищается только после commit. расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
к неделе 2.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу: Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона. 1. Определяет семестр для каждой даты диапазона.

View File

@@ -775,7 +775,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `course_number` | INT | Номер курса | | `course_number` | INT | Номер курса |
| `date` | DATE | Дата учебного года | | `date` | DATE | Дата учебного года |
| `week_number` | INT | Номер недели от начала учебного года | | `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
| `day_of_week` | INT CHECK(17) | День недели ISO | | `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
@@ -920,13 +920,14 @@ lifecycle ресурсов, эффективная сетка целевого
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__align_academic_calendar_weeks_to_monday.sql` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели |
### Этап разработки ### Этап разработки
По прямому решению владельца проекта все миграции V2V7 объединены в V1, поскольку По прямому решению владельца проекта прежние разработческие миграции V2V7 были объединены
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в после фиксации baseline и накатывается поверх существующих tenant-БД без изменения
`flyway_schema_history` не поддерживается. контрольной суммы V1.
### Полный сброс БД (локально) ### Полный сброс БД (локально)

View File

@@ -26,6 +26,10 @@ frontend/
├── scripts/ ├── scripts/
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild │ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
├── tests/ ├── tests/
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
│ ├── academic-calendar-title.test.mjs # Составное название календарного графика
│ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport
│ ├── admin-ui-regressions.test.mjs # Заголовки, меню, действия слотов и компоновка вкладок
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
@@ -51,6 +55,7 @@ frontend/
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям │ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization) │ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд │ │ ├── dashboard.js # Дашборд
@@ -64,6 +69,8 @@ frontend/
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
│ ├── views/ # HTML-шаблоны представлений │ ├── views/ # HTML-шаблоны представлений
@@ -142,6 +149,9 @@ frontend/
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели. Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран. Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран.
Верхние панели основной админки и settings SPA не содержат неработающей кнопки
бургер-меню. В светлой теме пункты и заголовки sidebar получают контрастные тёмные цвета
для всех ролей, включая `DEPARTMENT`.
### Разделы админ-панели ### Разделы админ-панели
@@ -172,15 +182,15 @@ frontend/
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `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` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Для `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`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в 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` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
@@ -368,6 +378,7 @@ CSS-переменные позволяют поддерживать светл
- Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий. - Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий.
- Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке. - Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке.
- Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления. - Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления.
- Каждый вариант `btn`, включая `primary`, `ghost`, `danger` и disabled-состояние, сохраняет видимую обводку в светлой и тёмной теме.
- Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`. - Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`.
- Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются. - Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.

View File

@@ -1219,6 +1219,18 @@ body.schedule-override-open {
background: var(--bg-input); background: var(--bg-input);
} }
.schedule-view-state-actionable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.schedule-view-state-reload {
min-width: 200px;
}
.schedule-view-day-tabs { .schedule-view-day-tabs {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -2,20 +2,24 @@
:root { :root {
--button-primary-bg: #8B5CF6; --button-primary-bg: #8B5CF6;
--button-primary-bg-hover: #7C3AED; --button-primary-bg-hover: #7C3AED;
--button-primary-border: #C4B5FD;
--button-primary-text: #FFFFFF; --button-primary-text: #FFFFFF;
--button-secondary-bg: rgba(255, 255, 255, 0.04); --button-secondary-bg: rgba(255, 255, 255, 0.04);
--button-secondary-bg-hover: rgba(255, 255, 255, 0.08); --button-secondary-bg-hover: rgba(255, 255, 255, 0.08);
--button-secondary-border: rgba(255, 255, 255, 0.10); --button-secondary-border: rgba(255, 255, 255, 0.10);
--button-secondary-text: #F8FAFC; --button-secondary-text: #F8FAFC;
--button-ghost-bg-hover: rgba(255, 255, 255, 0.06); --button-ghost-bg-hover: rgba(255, 255, 255, 0.06);
--button-ghost-border: rgba(255, 255, 255, 0.18);
--button-danger-bg: #DC2626; --button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C; --button-danger-bg-hover: #B91C1C;
--button-danger-border: #F87171;
--button-danger-subtle-bg: rgba(239, 68, 68, 0.13); --button-danger-subtle-bg: rgba(239, 68, 68, 0.13);
--button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20); --button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20);
--button-danger-subtle-border: rgba(239, 68, 68, 0.25); --button-danger-subtle-border: rgba(239, 68, 68, 0.25);
--button-danger-subtle-text: #FECACA; --button-danger-subtle-text: #FECACA;
--button-focus-ring: rgba(139, 92, 246, 0.45); --button-focus-ring: rgba(139, 92, 246, 0.45);
--button-disabled-bg: rgba(148, 163, 184, 0.16); --button-disabled-bg: rgba(148, 163, 184, 0.16);
--button-disabled-border: rgba(148, 163, 184, 0.32);
--button-disabled-text: #94A3B8; --button-disabled-text: #94A3B8;
--button-radius: 8px; --button-radius: 8px;
} }
@@ -23,20 +27,24 @@
[data-theme="light"] { [data-theme="light"] {
--button-primary-bg: #4F46E5; --button-primary-bg: #4F46E5;
--button-primary-bg-hover: #4338CA; --button-primary-bg-hover: #4338CA;
--button-primary-border: #3730A3;
--button-primary-text: #FFFFFF; --button-primary-text: #FFFFFF;
--button-secondary-bg: #FFFFFF; --button-secondary-bg: #FFFFFF;
--button-secondary-bg-hover: #F1F5F9; --button-secondary-bg-hover: #F1F5F9;
--button-secondary-border: #CBD5E1; --button-secondary-border: #CBD5E1;
--button-secondary-text: #334155; --button-secondary-text: #334155;
--button-ghost-bg-hover: #F8FAFC; --button-ghost-bg-hover: #F8FAFC;
--button-ghost-border: #CBD5E1;
--button-danger-bg: #DC2626; --button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C; --button-danger-bg-hover: #B91C1C;
--button-danger-border: #991B1B;
--button-danger-subtle-bg: #FEF2F2; --button-danger-subtle-bg: #FEF2F2;
--button-danger-subtle-bg-hover: #FEE2E2; --button-danger-subtle-bg-hover: #FEE2E2;
--button-danger-subtle-border: #FECACA; --button-danger-subtle-border: #FECACA;
--button-danger-subtle-text: #B91C1C; --button-danger-subtle-text: #B91C1C;
--button-focus-ring: rgba(79, 70, 229, 0.35); --button-focus-ring: rgba(79, 70, 229, 0.35);
--button-disabled-bg: #CBD5E1; --button-disabled-bg: #CBD5E1;
--button-disabled-border: #94A3B8;
--button-disabled-text: #64748B; --button-disabled-text: #64748B;
} }
@@ -755,6 +763,12 @@
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
} }
.schedule-slot-remove {
min-width: 125px;
min-height: 44px;
padding-inline: 1rem;
}
.schedule-conflict-modal { .schedule-conflict-modal {
z-index: 1200; z-index: 1200;
padding: 0.35rem; padding: 0.35rem;
@@ -2167,8 +2181,8 @@
visibility: hidden; visibility: hidden;
transform: translateY(4px) scale(0.98); transform: translateY(4px) scale(0.98);
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
right: 12px; left: 0;
bottom: 12px; top: 0;
} }
.calendar-day-tooltip.visible { .calendar-day-tooltip.visible {
@@ -3070,7 +3084,7 @@ input[type="number"] {
justify-content: center; justify-content: center;
gap: 0.45rem; gap: 0.45rem;
min-width: 0; min-width: 0;
border: 1px solid transparent; border: 1px solid var(--button-secondary-border);
border-radius: var(--button-radius); border-radius: var(--button-radius);
background: transparent; background: transparent;
color: inherit; color: inherit;
@@ -3107,7 +3121,7 @@ input[type="number"] {
.btn:disabled, .btn:disabled,
.btn[aria-disabled="true"] { .btn[aria-disabled="true"] {
background: var(--button-disabled-bg); background: var(--button-disabled-bg);
border-color: transparent; border-color: var(--button-disabled-border);
color: var(--button-disabled-text); color: var(--button-disabled-text);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.75; opacity: 0.75;
@@ -3168,7 +3182,7 @@ input[type="number"] {
.btn-primary { .btn-primary {
background: var(--button-primary-bg); background: var(--button-primary-bg);
border-color: transparent; border-color: var(--button-primary-border);
color: var(--button-primary-text); color: var(--button-primary-text);
box-shadow: none; box-shadow: none;
} }
@@ -3192,7 +3206,7 @@ input[type="number"] {
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
border-color: transparent; border-color: var(--button-ghost-border);
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -3203,7 +3217,7 @@ input[type="number"] {
.btn-danger { .btn-danger {
background: var(--button-danger-bg); background: var(--button-danger-bg);
border-color: transparent; border-color: var(--button-danger-border);
color: #FFFFFF; color: #FFFFFF;
} }

View File

@@ -64,6 +64,10 @@
overflow: hidden; overflow: hidden;
} }
[data-theme="light"] .sidebar .nav-item {
color: #334155;
}
.nav-item[hidden], .nav-item[hidden],
.settings-menu-item[hidden] { .settings-menu-item[hidden] {
display: none !important; display: none !important;
@@ -486,6 +490,19 @@
display: none !important; display: none !important;
} }
.sidebar-section-title {
padding: 0.5rem 1.25rem 0.25rem;
color: color-mix(in srgb, var(--text-secondary) 72%, transparent);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
[data-theme="light"] .sidebar-section-title {
color: #64748b !important;
}
.sidebar-section-divider { .sidebar-section-divider {
height: 1px; height: 1px;
background: rgba(229, 231, 235, 0.1); background: rgba(229, 231, 235, 0.1);

View File

@@ -207,14 +207,6 @@
<!-- Main --> <!-- Main -->
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<div class="topbar-title-group"> <div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1> <h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button> <button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>

View File

@@ -0,0 +1,180 @@
const DISPLAY_DATE_PATTERN = /^(\d{2})\.(\d{2})\.(\d{4})$/;
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const boundDateInputs = new WeakSet();
export function formatDateInputValue(value) {
const text = String(value || '');
const isoMatch = text.match(ISO_DATE_PATTERN);
if (isoMatch) {
return `${isoMatch[3]}.${isoMatch[2]}.${isoMatch[1]}`;
}
const digits = text.replace(/\D/g, '').slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
export function dateInputValueToIso(value) {
const text = String(value || '').trim();
if (!text) return '';
const match = text.match(DISPLAY_DATE_PATTERN);
if (!match) {
throw new TypeError('Введите дату полностью в формате ДД.ММ.ГГГГ (год — 4 цифры)');
}
const [, dayText, monthText, yearText] = match;
const day = Number(dayText);
const month = Number(monthText);
const year = Number(yearText);
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(month, year)) {
throw new TypeError('Введите существующую дату в формате ДД.ММ.ГГГГ');
}
return `${yearText}-${monthText}-${dayText}`;
}
export function isoDateToInputValue(value) {
const text = String(value || '').trim();
if (!text) return '';
const match = text.match(ISO_DATE_PATTERN);
if (!match) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const [, year, month, day] = match;
dateInputValueToIso(`${day}.${month}.${year}`);
return `${day}.${month}.${year}`;
}
export function bindDateInputMask(input) {
if (!input || boundDateInputs.has(input)) return;
boundDateInputs.add(input);
input.addEventListener('input', () => {
const rawValue = input.value;
const caret = input.selectionStart ?? rawValue.length;
const digitsBeforeCaret = rawValue.slice(0, caret).replace(/\D/g, '').length;
const caretWasAtEnd = caret === rawValue.length;
const formattedValue = formatDateInputValue(rawValue);
input.value = formattedValue;
clearDateInputError(input);
const nextCaret = caretPositionAfterDigits(formattedValue, digitsBeforeCaret, caretWasAtEnd);
input.setSelectionRange?.(nextCaret, nextCaret);
});
input.addEventListener('keydown', event => {
if (event.key === 'Backspace'
&& input.selectionStart === input.selectionEnd
&& input.selectionStart > 0
&& input.value[input.selectionStart - 1] === '.') {
event.preventDefault();
const nextCaret = input.selectionStart - 1;
input.setSelectionRange(nextCaret, nextCaret);
}
if (event.key === 'Delete'
&& input.selectionStart === input.selectionEnd
&& input.value[input.selectionStart] === '.') {
event.preventDefault();
const nextCaret = input.selectionStart + 1;
input.setSelectionRange(nextCaret, nextCaret);
}
});
input.addEventListener('blur', () => {
if (!input.value) {
clearDateInputError(input);
return;
}
try {
const isoValue = dateInputValueToIso(input.value);
input.value = isoDateToInputValue(isoValue);
clearDateInputError(input);
} catch (error) {
setDateInputError(input, error.message);
}
});
input.form?.addEventListener('reset', () => clearDateInputError(input));
}
export function readDateInputIso(input, label = 'Дата') {
try {
const value = dateInputValueToIso(input?.value);
clearDateInputError(input);
return value;
} catch (error) {
const message = `${label}: ${lowercaseFirst(error.message)}`;
setDateInputError(input, message);
input?.focus();
throw new Error(message);
}
}
export function setDateInputIso(input, value) {
if (!input) return;
input.value = isoDateToInputValue(value);
clearDateInputError(input);
}
function daysInMonth(month, year) {
const monthLengths = [
31,
isLeapYear(year) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31
];
return monthLengths[month - 1] || 0;
}
function isLeapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function caretPositionAfterDigits(value, digitCount, preferEnd) {
if (digitCount <= 0) return 0;
let seenDigits = 0;
for (let index = 0; index < value.length; index += 1) {
if (!/\d/.test(value[index])) continue;
seenDigits += 1;
if (seenDigits === digitCount) {
let position = index + 1;
if (preferEnd) {
while (value[position] === '.') position += 1;
}
return position;
}
}
return value.length;
}
function setDateInputError(input, message) {
if (!input) return;
input.setCustomValidity?.(message);
input.setAttribute?.('aria-invalid', 'true');
}
function clearDateInputError(input) {
if (!input) return;
input.setCustomValidity?.('');
input.removeAttribute?.('aria-invalid');
}
function lowercaseFirst(value) {
const text = String(value || '');
return text ? text[0].toLowerCase() + text.slice(1) : text;
}

View File

@@ -94,7 +94,7 @@ const ROUTES = {
} }
}, },
schedule: { schedule: {
title: 'Расписание занятий', title: 'Конструктор правил',
file: '/admin/views/schedule.html', file: '/admin/views/schedule.html',
init: async () => { init: async () => {
const module = await import('./views/schedule.js'); const module = await import('./views/schedule.js');
@@ -127,7 +127,6 @@ const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]'); const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay'); const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
@@ -148,18 +147,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr
main.classList.add('sidebar-collapsed'); main.classList.add('sidebar-collapsed');
} }
// Menu Toggle (Hamburger)
menuToggle.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
});
// Sidebar Close (X button) // Sidebar Close (X button)
sidebarCloseBtn?.addEventListener('click', () => { sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {

View File

@@ -0,0 +1,39 @@
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export function academicCalendarWeekNumber(academicYearStartIso, dateIso) {
const academicYearStart = parseIsoDate(academicYearStartIso);
const date = parseIsoDate(dateIso);
const daysFromYearStart = (date.timestamp - academicYearStart.timestamp) / MILLISECONDS_PER_DAY;
if (!Number.isInteger(daysFromYearStart) || daysFromYearStart < 0) {
throw new RangeError('Дата ячейки не может быть раньше начала учебного года');
}
const daysBeforeYearStartInFirstWeek = academicYearStart.dayOfWeek - 1;
return Math.floor((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7) + 1;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
if (!match) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const [, yearText, monthText, dayText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const timestamp = Date.UTC(year, month - 1, day);
const date = new Date(timestamp);
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
throw new TypeError('Ожидалась дата в формате ГГГГ-ММ-ДД');
}
const jsDayOfWeek = date.getUTCDay();
return {
timestamp,
dayOfWeek: jsDayOfWeek === 0 ? 7 : jsDayOfWeek
};
}

View File

@@ -0,0 +1,21 @@
const TITLE_SEPARATOR = ' — ';
function normalizeTitlePart(value) {
return String(value ?? '').trim();
}
export function buildAcademicCalendarTitle({
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
} = {}) {
const parts = [
specialtyCode,
specialtyProfileName,
studyFormName,
academicYearTitle
].map(normalizeTitlePart);
return parts.every(Boolean) ? parts.join(TITLE_SEPARATOR) : '';
}

View File

@@ -1,5 +1,8 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
const DAY_OPTIONS = [ const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' }, { value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -16,6 +19,41 @@ const SEMESTER_LABELS = {
spring: 'весенний' spring: 'весенний'
}; };
const CALENDAR_TOOLTIP_OFFSET = 14;
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
export function calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth,
tooltipHeight,
viewportWidth,
viewportHeight
}) {
const maxLeft = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
const maxTop = Math.max(
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
);
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
if (left > maxLeft) {
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
}
if (top > maxTop) {
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
}
return {
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
};
}
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');
@@ -39,7 +77,6 @@ export async function initAcademicCalendar() {
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');
const calendarIdInput = document.getElementById('academic-calendar-id'); const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year'); const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile'); const calendarProfileSelect = document.getElementById('academic-calendar-profile');
@@ -100,6 +137,7 @@ export async function initAcademicCalendar() {
let suppressCalendarDayClick = false; let suppressCalendarDayClick = false;
let isSyncingCalendarDayDialog = false; let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null; let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null;
bindEvents(); bindEvents();
@@ -119,6 +157,15 @@ export async function initAcademicCalendar() {
} }
function bindEvents() { function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => { calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab)); button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
}); });
@@ -271,12 +318,12 @@ export async function initAcademicCalendar() {
async function saveAcademicYear(event) { async function saveAcademicYear(event) {
event.preventDefault(); event.preventDefault();
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try { try {
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
};
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value; const id = academicYearIdInput.value;
await (id await (id
@@ -302,8 +349,8 @@ export async function initAcademicCalendar() {
academicYearFormTitle.textContent = 'Редактирование учебного года'; academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id; academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || ''; academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || ''; setDateInputIso(academicYearStartInput, year.startDate);
academicYearEndInput.value = year.endDate || ''; setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert'); hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; return;
@@ -316,8 +363,8 @@ export async function initAcademicCalendar() {
semesterIdInput.value = semester.id; semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || ''; semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || ''; setDateInputIso(semesterStartInput, semester.startDate);
semesterEndInput.value = semester.endDate || ''; setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect); syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert'); hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -348,12 +395,12 @@ export async function initAcademicCalendar() {
event.preventDefault(); event.preventDefault();
hideAlert('semester-alert'); hideAlert('semester-alert');
const yearId = semesterYearSelect.value; const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try { try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра'); throw new Error('Заполните учебный год, тип и даты семестра');
} }
@@ -401,26 +448,25 @@ export async function initAcademicCalendar() {
} }
async function loadCalendars() { async function loadCalendars() {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>'; calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try { try {
calendars = await api.get('/api/admin/academic-calendars'); calendars = await api.get('/api/admin/academic-calendars');
renderCalendars(); renderCalendars();
populateEditorCalendarSelect(); populateEditorCalendarSelect();
populateSubjectCalendarSelect(); populateSubjectCalendarSelect();
} catch (error) { } catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
} }
} }
function renderCalendars() { function renderCalendars() {
if (!calendars.length) { if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>'; calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
return; return;
} }
calendarsTbody.innerHTML = calendars.map(calendar => ` calendarsTbody.innerHTML = calendars.map(calendar => `
<tr> <tr>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td> <td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td> <td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td> <td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
@@ -441,7 +487,6 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert'); hideAlert('academic-calendar-alert');
const payload = { const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null, id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
academicYearId: Number(calendarYearSelect.value), academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value), specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value), specialtyProfileId: Number(calendarProfileSelect.value),
@@ -449,7 +494,7 @@ export async function initAcademicCalendar() {
courseCount: Number(calendarCourseCountInput.value) courseCount: Number(calendarCourseCountInput.value)
}; };
try { try {
if (!payload.title || !payload.academicYearId || !payload.specialtyId if (!payload.academicYearId || !payload.specialtyId
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
throw new Error('Заполните все поля календарного графика'); throw new Error('Заполните все поля календарного графика');
} }
@@ -457,7 +502,8 @@ export async function initAcademicCalendar() {
const saved = id const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload) ? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload); : await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); const savedTitle = buildAcademicCalendarTitle(saved);
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm(); resetCalendarForm();
await loadCalendars(); await loadCalendars();
} catch (error) { } catch (error) {
@@ -477,7 +523,6 @@ export async function initAcademicCalendar() {
if (!calendar) return; if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика'; calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id; calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || ''; calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || ''; calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
@@ -721,12 +766,19 @@ export async function initAcademicCalendar() {
function applyCalendarFill() { function applyCalendarFill() {
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value; const activityId = fillActivitySelect.value;
const start = fillStartInput.value; if (!activityId || !fillStartInput.value || !fillEndInput.value) {
const end = fillEndInput.value;
if (!activityId || !start || !end) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return; return;
} }
let start;
let end;
try {
start = readDateInputIso(fillStartInput, 'Дата начала диапазона');
end = readDateInputIso(fillEndInput, 'Дата окончания диапазона');
} catch (error) {
showAlert('calendar-editor-alert', error.message, 'error');
return;
}
if (end < start) { if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return; return;
@@ -1051,12 +1103,37 @@ export async function initAcademicCalendar() {
const tooltip = ensureCalendarDayTooltip(); const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day); tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible'); tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
} }
function hideCalendarDayTooltip() { function hideCalendarDayTooltip() {
calendarDayTooltip?.classList.remove('visible'); calendarDayTooltip?.classList.remove('visible');
} }
function positionCalendarDayTooltip(tooltip, day, event) {
const dayRect = day.getBoundingClientRect();
const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right;
const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom;
const tooltipRect = tooltip.getBoundingClientRect();
const { left, top } = calculateCalendarTooltipPosition({
pointerX,
pointerY,
tooltipWidth: tooltipRect.width,
tooltipHeight: tooltipRect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
});
calendarDayTooltipPositionAnimation?.cancel();
const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`;
calendarDayTooltipPositionAnimation = tooltip.animate(
[
{ transform: positionedTransform },
{ transform: positionedTransform }
],
{ duration: 1, fill: 'forwards' }
);
}
function ensureCalendarDayTooltip() { function ensureCalendarDayTooltip() {
if (calendarDayTooltip) return calendarDayTooltip; if (calendarDayTooltip) return calendarDayTooltip;
calendarDayTooltip = document.createElement('div'); calendarDayTooltip = document.createElement('div');
@@ -1156,13 +1233,13 @@ export async function initAcademicCalendar() {
const rows = []; const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) { for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => { dates.forEach(date => {
rows.push({ rows.push({
id: null, id: null,
calendarId: calendar.id, calendarId: calendar.id,
courseNumber: course, courseNumber: course,
date: date.iso, date: date.iso,
weekNumber: Math.floor(index / 7) + 1, weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek, dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(), activityTypeId: theoryActivityId(),
activityCode: 'Т' activityCode: 'Т'
@@ -1285,7 +1362,9 @@ export async function initAcademicCalendar() {
function populateEditorCalendarSelect() { function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value; const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' + editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join(''); calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) { if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current; editorCalendarSelect.value = current;
} }
@@ -1297,7 +1376,7 @@ export async function initAcademicCalendar() {
const current = subjectCalendarSelect.value; const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar => ? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('') ).join('')
: '<option value="">Нет графиков</option>'; : '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) { if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
@@ -1425,11 +1504,19 @@ export async function initAcademicCalendar() {
} }
function specialityLabel(speciality) { function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; const code = specialityCode(speciality) || speciality.id;
const name = speciality.specialityName || speciality.name || ''; const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code); return name ? `${code} - ${name}` : String(code);
} }
function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
function dayLabel(value) { function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
} }

View File

@@ -1,5 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
async function fetchEducationForms() { async function fetchEducationForms() {
try { try {
@@ -384,7 +385,7 @@ export async function initGroups() {
: []; : [];
manageCalendarSelect.innerHTML = matching.length manageCalendarSelect.innerHTML = matching.length
? '<option value="">Выберите график</option>' + matching.map(calendar => ? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>` `<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('') ).join('')
: '<option value="">Нет подходящих графиков</option>'; : '<option value="">Нет подходящих графиков</option>';
syncSelects(manageCalendarSelect); syncSelects(manageCalendarSelect);
@@ -767,7 +768,7 @@ export async function initGroups() {
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => ` manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
<tr> <tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td> <td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td> <td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td> <td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
<td> <td>
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
@@ -776,6 +777,13 @@ export async function initGroups() {
`).join(''); `).join('');
} }
function assignmentCalendarTitle(assignment) {
const calendar = calendars.find(item =>
String(item.id) === String(assignment.calendarId)
);
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
}
function renderAssignmentSubjects(subjects) { function renderAssignmentSubjects(subjects) {
if (!subjects.length) { if (!subjects.length) {
return '<span class="muted">Не привязаны</span>'; return '<span class="muted">Не привязаны</span>';

View File

@@ -37,6 +37,7 @@ const CELL_PARITY_LAYOUT = [
]; ];
const MOBILE_QUERY = '(max-width: 760px)'; const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22; const DEFAULT_SEMESTER_WEEKS = 22;
const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
export async function initScheduleView() { export async function initScheduleView() {
const session = getSession(); const session = getSession();
@@ -89,7 +90,7 @@ export async function initScheduleView() {
targetSelect?.addEventListener('change', () => { targetSelect?.addEventListener('change', () => {
clearInactiveEntityValues(); clearInactiveEntityValues();
updateEntityFieldVisibility(); updateEntityFieldVisibility();
resetResults('Нажмите «Показать», чтобы обновить расписание'); resetResults(SCHEDULE_REFRESH_PROMPT);
}); });
loadButton?.addEventListener('click', loadData); loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
@@ -100,16 +101,21 @@ export async function initScheduleView() {
nextPeriodButton?.addEventListener('click', () => movePeriod(1)); nextPeriodButton?.addEventListener('click', () => movePeriod(1));
dateInput?.addEventListener('change', () => { dateInput?.addEventListener('change', () => {
updatePeriodLabel(); updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание'); resetResults(SCHEDULE_REFRESH_PROMPT);
}); });
semesterSelect?.addEventListener('change', () => { semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester(); synchronizeDateWithSemester();
updatePeriodLabel(); updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание'); resetResults(SCHEDULE_REFRESH_PROMPT);
}); });
resultPreviousButton?.addEventListener('click', () => moveResult(-1)); resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1)); resultNextButton?.addEventListener('click', () => moveResult(1));
tables?.addEventListener('click', event => { tables?.addEventListener('click', event => {
const reloadButton = event.target.closest('[data-schedule-view-reload]');
if (reloadButton) {
loadData();
return;
}
const button = event.target.closest('[data-schedule-override-edit]'); const button = event.target.closest('[data-schedule-override-edit]');
if (!button) return; if (!button) return;
const lesson = state.lessons.find(item => const lesson = state.lessons.find(item =>
@@ -743,9 +749,20 @@ export async function initScheduleView() {
`; `;
} }
function setTableState(message) { function setTableState(message, showReloadButton = false) {
if (!tables) return; if (!tables) return;
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`; tables.innerHTML = `
<div class="loading-row schedule-view-state${showReloadButton ? ' schedule-view-state-actionable' : ''}">
<span>${escapeHtml(message)}</span>
${showReloadButton ? `
<button type="button"
class="btn btn-md btn-primary schedule-view-state-reload"
data-schedule-view-reload>
Показать расписание
</button>
` : ''}
</div>
`;
} }
function resetResults(message) { function resetResults(message) {
@@ -757,7 +774,7 @@ export async function initScheduleView() {
renderResultNavigation(); renderResultNavigation();
tableTitle.textContent = 'Расписание'; tableTitle.textContent = 'Расписание';
tableCaption.textContent = message; tableCaption.textContent = message;
setTableState(message); setTableState(message, message === SCHEDULE_REFRESH_PROMPT);
countLabel.textContent = '0 занятий'; countLabel.textContent = '0 занятий';
dayTabs.hidden = true; dayTabs.hidden = true;
dayTabs.innerHTML = ''; dayTabs.innerHTML = '';

View File

@@ -30,6 +30,10 @@ 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;
export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить';
}
const LESSON_TYPE_LIMITS = [ const LESSON_TYPE_LIMITS = [
{ {
key: 'lecture', key: 'lecture',
@@ -1364,7 +1368,11 @@ export async function initSchedule() {
} }
function renderRuleSlots(slots) { function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` const normalizedSlots = slots?.length ? slots : [{}];
const isSingleSlot = normalizedSlots.length === 1;
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}"> <div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div> <div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div> <div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
@@ -1374,7 +1382,7 @@ export async function initSchedule() {
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div> <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''} ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn btn-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button> <button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
</div> </div>
`).join(''); `).join('');
} }

View File

@@ -90,14 +90,6 @@
<!-- Основная область --> <!-- Основная область -->
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 id="page-title">Загрузка...</h1> <h1 id="page-title">Загрузка...</h1>
</header> </header>

View File

@@ -60,7 +60,6 @@ const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]'); const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay'); const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
@@ -70,18 +69,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr
main.classList.add('sidebar-collapsed'); main.classList.add('sidebar-collapsed');
} }
// Кнопка меню
menuToggle.addEventListener('click', () => {
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
} else {
sidebar.classList.remove('collapsed');
main.classList.remove('sidebar-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
});
// Сворачивание боковой панели // Сворачивание боковой панели
sidebarCloseBtn?.addEventListener('click', () => { sidebarCloseBtn?.addEventListener('click', () => {
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {

View File

@@ -16,11 +16,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-start">Начало</label> <label for="academic-year-start">Начало</label>
<input type="date" id="academic-year-start" required> <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>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="academic-year-end">Окончание</label> <label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required> <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>
</div> </div>
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button> <button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
@@ -73,11 +73,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="semester-start">Начало</label> <label for="semester-start">Начало</label>
<input type="date" id="semester-start" required> <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>
<div class="form-group"> <div class="form-group">
<label for="semester-end">Окончание</label> <label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required> <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> </div>
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button> <button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
@@ -94,10 +94,6 @@
<form id="academic-calendar-form" novalidate> <form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id"> <input type="hidden" id="academic-calendar-id">
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="academic-calendar-title">Название</label>
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
</div>
<div class="form-group"> <div class="form-group">
<label for="academic-calendar-year">Учебный год</label> <label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required> <select id="academic-calendar-year" required>
@@ -141,7 +137,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Название</th>
<th>Учебный год</th> <th>Учебный год</th>
<th>Специальность</th> <th>Специальность</th>
<th>Профиль</th> <th>Профиль</th>
@@ -152,7 +147,7 @@
</thead> </thead>
<tbody id="academic-calendars-tbody"> <tbody id="academic-calendars-tbody">
<tr> <tr>
<td colspan="7" class="loading-row">Загрузка...</td> <td colspan="6" class="loading-row">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -183,11 +178,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-start">С даты</label> <label for="calendar-fill-start">С даты</label>
<input type="date" id="calendar-fill-start"> <input type="text" id="calendar-fill-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-end">По дату</label> <label for="calendar-fill-end">По дату</label>
<input type="date" id="calendar-fill-end"> <input type="text" id="calendar-fill-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="calendar-fill-activity">Код</label> <label for="calendar-fill-activity">Код</label>

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { academicCalendarWeekNumber } from '../admin/js/views/academic-calendar-grid.js';
test('в 2026 году первая неделя оставляет понедельник перед 1 сентября пустым', () => {
const yearStart = '2026-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-01'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-06'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-07'), 2);
assert.equal(academicCalendarWeekNumber(yearStart, '2026-09-14'), 3);
});
test('в 2027 году первая неделя оставляет понедельник и вторник перед 1 сентября пустыми', () => {
const yearStart = '2027-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-01'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-05'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-06'), 2);
assert.equal(academicCalendarWeekNumber(yearStart, '2027-09-13'), 3);
});
test('учебный год с понедельника сохраняет полную первую неделю', () => {
const yearStart = '2025-09-01';
assert.equal(academicCalendarWeekNumber(yearStart, '2025-09-07'), 1);
assert.equal(academicCalendarWeekNumber(yearStart, '2025-09-08'), 2);
});

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildAcademicCalendarTitle } from '../admin/js/views/academic-calendar-title.js';
test('название календарного графика собирается в заданном порядке', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: '09.03.04',
specialtyProfileName: 'Программная инженерия',
studyFormName: 'Очная',
academicYearTitle: '2025-2026'
}), '09.03.04 — Программная инженерия — Очная — 2025-2026');
});
test('части названия очищаются от лишних пробелов', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: ' 09.03.04 ',
specialtyProfileName: ' Без профиля ',
studyFormName: ' Заочная ',
academicYearTitle: ' 2026-2027 '
}), '09.03.04 — Без профиля — Заочная — 2026-2027');
});
test('неполный набор полей не образует вводящее в заблуждение название', () => {
assert.equal(buildAcademicCalendarTitle({
specialtyCode: '09.03.04',
specialtyProfileName: '',
studyFormName: 'Очная',
academicYearTitle: '2025-2026'
}), '');
});

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateCalendarTooltipPosition } from '../admin/js/views/academic-calendar.js';
test('подсказка располагается с отступом справа и снизу от курсора', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 100,
pointerY: 80,
tooltipWidth: 280,
tooltipHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 114, top: 94 });
});
test('у правого нижнего края подсказка раскрывается влево и вверх', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 1260,
pointerY: 700,
tooltipWidth: 280,
tooltipHeight: 160,
viewportWidth: 1280,
viewportHeight: 720
}), { left: 966, top: 526 });
});
test('подсказка не выходит за минимальные отступы узкого viewport', () => {
assert.deepEqual(calculateCalendarTooltipPosition({
pointerX: 190,
pointerY: 110,
tooltipWidth: 176,
tooltipHeight: 96,
viewportWidth: 200,
viewportHeight: 120
}), { left: 12, top: 12 });
});

View File

@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { scheduleSlotActionLabel } from '../admin/js/views/schedule.js';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
'utf8'
);
test('одиночный слот очищается, а несколько слотов удаляются', () => {
assert.equal(scheduleSlotActionLabel(1), 'Очистить');
assert.equal(scheduleSlotActionLabel(2), 'Удалить');
assert.equal(scheduleSlotActionLabel(5), 'Удалить');
});
test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => {
const files = await Promise.all([
readFrontendFile('admin/index.html'),
readFrontendFile('admin/js/main.js'),
readFrontendFile('admin/settings/index.html'),
readFrontendFile('admin/settings/js/main.js')
]);
files.forEach(content => assert.doesNotMatch(content, /menu-toggle/));
});
test('заголовок вкладки соответствует конструктору правил', async () => {
const mainScript = await readFrontendFile('admin/js/main.js');
assert.match(mainScript, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
});
test('основная кнопка показа находится в заголовке блока параметров', async () => {
const scheduleView = await readFrontendFile('admin/views/schedule-view.html');
const controlHeaderIndex = scheduleView.indexOf('schedule-view-control-header');
const controlGridIndex = scheduleView.indexOf('schedule-view-control-grid');
const loadButtonIndex = scheduleView.indexOf('id="schedule-view-load"');
assert.ok(controlHeaderIndex >= 0);
assert.ok(loadButtonIndex > controlHeaderIndex);
assert.ok(loadButtonIndex < controlGridIndex);
assert.equal(scheduleView.match(/id="schedule-view-load"/g)?.length, 1);
});
test('пустое состояние расписания содержит собственную кнопку обновления', async () => {
const scheduleScript = await readFrontendFile('admin/js/views/schedule-view.js');
assert.match(scheduleScript, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
assert.match(scheduleScript, /data-schedule-view-reload/);
assert.match(scheduleScript, /closest\('\[data-schedule-view-reload\]'\)[\s\S]*?loadData\(\)/);
assert.match(scheduleScript, /setTableState\(message,\s*message === SCHEDULE_REFRESH_PROMPT\)/);
});
test('действия слотов имеют размер соседнего поля', async () => {
const [components, scheduleScript] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/js/views/schedule.js')
]);
assert.match(components, /\.schedule-slot-remove\s*\{[^}]*min-width:\s*125px[^}]*min-height:\s*44px/s);
assert.match(scheduleScript, /class="btn btn-md \$\{actionClass\} schedule-slot-remove"/);
});
test('все варианты системных кнопок имеют видимую обводку', async () => {
const components = await readFrontendFile('admin/css/components.css');
assert.match(components, /\.btn\s*\{[^}]*border:\s*1px solid var\(--button-secondary-border\)/s);
assert.match(components, /\.btn-primary\s*\{[^}]*border-color:\s*var\(--button-primary-border\)/s);
assert.match(components, /\.btn-ghost\s*\{[^}]*border-color:\s*var\(--button-ghost-border\)/s);
assert.match(components, /\.btn-danger\s*\{[^}]*border-color:\s*var\(--button-danger-border\)/s);
});
test('подсказка календарной сетки получает позицию напрямую от курсора', async () => {
const calendarScript = await readFrontendFile('admin/js/views/academic-calendar.js');
assert.match(calendarScript, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
assert.match(calendarScript, /\{ duration: 1, fill: 'forwards' \}/);
assert.doesNotMatch(calendarScript, /\.style\.(?:left|top)\s*=/);
});
test('светлая тема задаёт контрастные пункты и заголовки левого меню', async () => {
const [layout, index] = await Promise.all([
readFrontendFile('admin/css/layout.css'),
readFrontendFile('admin/index.html')
]);
assert.match(layout, /\[data-theme="light"\] \.sidebar \.nav-item\s*\{[^}]*color:\s*#334155/s);
assert.match(layout, /\[data-theme="light"\] \.sidebar-section-title\s*\{[^}]*color:\s*#64748b !important/s);
assert.match(index, />Расписание<\/div>/);
});
test('таблица календарных графиков не содержит отдельный столбец названия', async () => {
const [calendarView, calendarScript] = await Promise.all([
readFrontendFile('admin/views/academic-calendar.html'),
readFrontendFile('admin/js/views/academic-calendar.js')
]);
const bodyIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const tableStart = calendarView.lastIndexOf('<table>', bodyIndex);
const tableEnd = calendarView.indexOf('</table>', bodyIndex);
const calendarTable = calendarView.slice(tableStart, tableEnd);
assert.doesNotMatch(calendarTable, /<th>Название<\/th>/);
assert.match(calendarTable, /colspan="6"/);
assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/);
});

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
bindDateInputMask,
dateInputValueToIso,
formatDateInputValue,
isoDateToInputValue
} from '../admin/js/date-input.js';
test('маска расставляет точки при последовательном вводе восьми цифр', () => {
assert.equal(formatDateInputValue('0'), '0');
assert.equal(formatDateInputValue('01'), '01');
assert.equal(formatDateInputValue('010'), '01.0');
assert.equal(formatDateInputValue('0109'), '01.09');
assert.equal(formatDateInputValue('01092'), '01.09.2');
assert.equal(formatDateInputValue('01092026'), '01.09.2026');
});
test('маска удаляет посторонние символы и ограничивает год четырьмя цифрами', () => {
assert.equal(formatDateInputValue('01-09/2026'), '01.09.2026');
assert.equal(formatDateInputValue('010920261234'), '01.09.2026');
});
test('привязанная маска расставляет точки прямо во время ввода', () => {
const input = new DateInputStub();
bindDateInputMask(input);
typeText(input, '01092026');
assert.equal(input.value, '01.09.2026');
assert.equal(input.selectionStart, 10);
typeText(input, '12');
assert.equal(input.value, '01.09.2026');
assert.equal(input.selectionStart, 10);
});
test('дата преобразуется между форматом поля и ISO без потери значения', () => {
assert.equal(dateInputValueToIso('01.09.2026'), '2026-09-01');
assert.equal(isoDateToInputValue('2026-09-01'), '01.09.2026');
});
test('неполный, шестизначный год и несуществующие даты отклоняются', () => {
assert.throws(() => dateInputValueToIso('01.09.202'), /год — 4 цифры/);
assert.throws(() => dateInputValueToIso('01.09.202612'), /год — 4 цифры/);
assert.throws(() => dateInputValueToIso('31.02.2026'), /существующую дату/);
assert.equal(dateInputValueToIso('29.02.2024'), '2024-02-29');
assert.throws(() => dateInputValueToIso('29.02.2025'), /существующую дату/);
});
test('все поля дат календарного графика подключены к числовой маске', async () => {
const template = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
assert.equal(template.match(/\bdata-date-input\b/g)?.length, 6);
assert.equal(template.match(/\bmaxlength="10"/g)?.length, 6);
assert.doesNotMatch(template, /type="date"/);
});
class DateInputStub extends EventTarget {
constructor() {
super();
this.value = '';
this.selectionStart = 0;
this.selectionEnd = 0;
this.form = null;
}
setSelectionRange(start, end) {
this.selectionStart = start;
this.selectionEnd = end;
}
setCustomValidity() {}
setAttribute() {}
removeAttribute() {}
focus() {}
}
function typeText(input, text) {
for (const character of text) {
const start = input.selectionStart;
const end = input.selectionEnd;
input.value = input.value.slice(0, start) + character + input.value.slice(end);
input.selectionStart = start + 1;
input.selectionEnd = start + 1;
input.dispatchEvent(new Event('input'));
}
}