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

View File

@@ -114,7 +114,8 @@ public class AcademicCalendarGridService {
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
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) {
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.StudentGroupCalendarAssignmentRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.springframework.dao.DataIntegrityViolationException;
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());
AcademicCalendar saved = saveCalendar(calendar);
clearScheduleCacheAfterCommit();
@@ -208,9 +214,6 @@ public class AcademicStructureService {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название графика обязательно");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
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 AcademicCalendarActivityType activityType;
private AcademicYear academicYear;
@BeforeEach
void setUp() {
AcademicYear academicYear = new AcademicYear();
academicYear = new AcademicYear();
academicYear.setId(1L);
academicYear.setTitle("2025-2026");
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) {
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
}

View File

@@ -168,6 +168,18 @@ class AcademicStructureServiceTest {
.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
void rejectsGroupDimensionChangeAndKeepsAssignment() {
when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID))
@@ -280,7 +292,7 @@ class AcademicStructureServiceTest {
private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) {
return new AcademicCalendarDto(
CALENDAR_ID,
"График 2026",
null,
YEAR_ID,
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. В разделе кафедр и специальностей администратор создаёт специальность и её профили.
2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения.
3. В разделе календарного графика администратор создаёт учебный год и семестры.
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
4. Там же создаётся календарный учебный график: администратор выбирает учебный год, специальность, профиль, форму обучения из общего справочника и количество курсов. Название вручную не вводится — frontend формирует его в порядке `код специальности — профиль обучения — форма обучения — учебный год`.
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.

View File

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

View File

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

View File

@@ -775,7 +775,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `course_number` | INT | Номер курса |
| `date` | DATE | Дата учебного года |
| `week_number` | INT | Номер недели от начала учебного года |
| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
| `day_of_week` | INT CHECK(17) | День недели ISO |
| `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-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__align_academic_calendar_weeks_to_monday.sql` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели |
### Этап разработки
По прямому решению владельца проекта все миграции V2V7 объединены в V1, поскольку
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в
`flyway_schema_history` не поддерживается.
По прямому решению владельца проекта прежние разработческие миграции V2V7 были объединены
в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана
после фиксации baseline и накатывается поверх существующих tenant-БД без изменения
контрольной суммы V1.
### Полный сброс БД (локально)

View File

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

View File

@@ -1219,6 +1219,18 @@ body.schedule-override-open {
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 {
flex-wrap: wrap;
justify-content: flex-end;

View File

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

View File

@@ -64,6 +64,10 @@
overflow: hidden;
}
[data-theme="light"] .sidebar .nav-item {
color: #334155;
}
.nav-item[hidden],
.settings-menu-item[hidden] {
display: none !important;
@@ -486,6 +490,19 @@
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 {
height: 1px;
background: rgba(229, 231, 235, 0.1);

View File

@@ -207,14 +207,6 @@
<!-- Main -->
<main class="main">
<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">
<h1 id="page-title">Загрузка...</h1>
<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: {
title: 'Расписание занятий',
title: 'Конструктор правил',
file: '/admin/views/schedule.html',
init: async () => {
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 sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
@@ -148,18 +147,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr
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)
sidebarCloseBtn?.addEventListener('click', () => {
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 { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.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 = [
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
@@ -16,6 +19,41 @@ const SEMESTER_LABELS = {
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() {
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
@@ -39,7 +77,6 @@ export async function initAcademicCalendar() {
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
@@ -100,6 +137,7 @@ export async function initAcademicCalendar() {
let suppressCalendarDayClick = false;
let isSyncingCalendarDayDialog = false;
let calendarDayTooltip = null;
let calendarDayTooltipPositionAnimation = null;
bindEvents();
@@ -119,6 +157,15 @@ export async function initAcademicCalendar() {
}
function bindEvents() {
[
academicYearStartInput,
academicYearEndInput,
semesterStartInput,
semesterEndInput,
fillStartInput,
fillEndInput
].forEach(bindDateInputMask);
calendarTabButtons.forEach(button => {
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
});
@@ -271,12 +318,12 @@ export async function initAcademicCalendar() {
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
try {
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
};
try {
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
@@ -302,8 +349,8 @@ export async function initAcademicCalendar() {
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || '';
academicYearEndInput.value = year.endDate || '';
setDateInputIso(academicYearStartInput, year.startDate);
setDateInputIso(academicYearEndInput, year.endDate);
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
@@ -316,8 +363,8 @@ export async function initAcademicCalendar() {
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || '';
semesterEndInput.value = semester.endDate || '';
setDateInputIso(semesterStartInput, semester.startDate);
setDateInputIso(semesterEndInput, semester.endDate);
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -348,12 +395,12 @@ export async function initAcademicCalendar() {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
try {
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
};
try {
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
@@ -401,26 +448,25 @@ export async function initAcademicCalendar() {
}
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 {
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
populateSubjectCalendarSelect();
} 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;
}
}
function renderCalendars() {
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;
}
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
@@ -441,7 +487,6 @@ export async function initAcademicCalendar() {
hideAlert('academic-calendar-alert');
const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
@@ -449,7 +494,7 @@ export async function initAcademicCalendar() {
courseCount: Number(calendarCourseCountInput.value)
};
try {
if (!payload.title || !payload.academicYearId || !payload.specialtyId
if (!payload.academicYearId || !payload.specialtyId
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
throw new Error('Заполните все поля календарного графика');
}
@@ -457,7 +502,8 @@ export async function initAcademicCalendar() {
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, 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();
await loadCalendars();
} catch (error) {
@@ -477,7 +523,6 @@ export async function initAcademicCalendar() {
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
@@ -721,12 +766,19 @@ export async function initAcademicCalendar() {
function applyCalendarFill() {
hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value;
const start = fillStartInput.value;
const end = fillEndInput.value;
if (!activityId || !start || !end) {
if (!activityId || !fillStartInput.value || !fillEndInput.value) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
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) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
@@ -1051,12 +1103,37 @@ export async function initAcademicCalendar() {
const tooltip = ensureCalendarDayTooltip();
tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
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() {
if (calendarDayTooltip) return calendarDayTooltip;
calendarDayTooltip = document.createElement('div');
@@ -1156,13 +1233,13 @@ export async function initAcademicCalendar() {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => {
dates.forEach(date => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: Math.floor(index / 7) + 1,
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(),
activityCode: 'Т'
@@ -1285,7 +1362,9 @@ export async function initAcademicCalendar() {
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
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))) {
editorCalendarSelect.value = current;
}
@@ -1297,7 +1376,7 @@ export async function initAcademicCalendar() {
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
@@ -1425,11 +1504,19 @@ export async function initAcademicCalendar() {
}
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 || '';
return name ? `${code} - ${name}` : String(code);
}
function specialityCode(speciality) {
return speciality?.specialityCode
|| speciality?.specialtyCode
|| speciality?.speciality_code
|| speciality?.specialty_code
|| '';
}
function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}

View File

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

View File

@@ -37,6 +37,7 @@ const CELL_PARITY_LAYOUT = [
];
const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
export async function initScheduleView() {
const session = getSession();
@@ -89,7 +90,7 @@ export async function initScheduleView() {
targetSelect?.addEventListener('change', () => {
clearInactiveEntityValues();
updateEntityFieldVisibility();
resetResults('Нажмите «Показать», чтобы обновить расписание');
resetResults(SCHEDULE_REFRESH_PROMPT);
});
loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
@@ -100,16 +101,21 @@ export async function initScheduleView() {
nextPeriodButton?.addEventListener('click', () => movePeriod(1));
dateInput?.addEventListener('change', () => {
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
resetResults(SCHEDULE_REFRESH_PROMPT);
});
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
resetResults(SCHEDULE_REFRESH_PROMPT);
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
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]');
if (!button) return;
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;
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) {
@@ -757,7 +774,7 @@ export async function initScheduleView() {
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = message;
setTableState(message);
setTableState(message, message === SCHEDULE_REFRESH_PROMPT);
countLabel.textContent = '0 занятий';
dayTabs.hidden = true;
dayTabs.innerHTML = '';

View File

@@ -30,6 +30,10 @@ const SEMESTER_LABELS = {
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;
export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить';
}
const LESSON_TYPE_LIMITS = [
{
key: 'lecture',
@@ -1364,7 +1368,11 @@ export async function initSchedule() {
}
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="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>
@@ -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>
${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>
<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>
`).join('');
}

View File

@@ -90,14 +90,6 @@
<!-- Основная область -->
<main class="main">
<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>
</header>

View File

@@ -60,7 +60,6 @@ const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const main = document.querySelector('.main');
@@ -70,18 +69,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr
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', () => {
if (window.innerWidth <= 768) {

View File

@@ -16,11 +16,11 @@
</div>
<div class="form-group">
<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 class="form-group">
<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>
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
@@ -73,11 +73,11 @@
</div>
<div class="form-group">
<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 class="form-group">
<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>
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
@@ -94,10 +94,6 @@
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<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">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
@@ -141,7 +137,6 @@
<table>
<thead>
<tr>
<th>Название</th>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
@@ -152,7 +147,7 @@
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
@@ -183,11 +178,11 @@
</div>
<div class="form-group">
<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 class="form-group">
<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 class="form-group">
<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'));
}
}