комит
This commit is contained in:
37
.env.example
37
.env.example
@@ -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
|
|
||||||
@@ -114,7 +114,8 @@ public class AcademicCalendarGridService {
|
|||||||
|
|
||||||
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
|
private void validateWeek(AcademicYear academicYear, LocalDate date, Integer weekNumber) {
|
||||||
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
|
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
|
||||||
int expectedWeek = Math.toIntExact(daysFromYearStart / 7 + 1);
|
int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1;
|
||||||
|
int expectedWeek = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1);
|
||||||
if (weekNumber != expectedWeek) {
|
if (weekNumber != expectedWeek) {
|
||||||
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
|
throw new IllegalArgumentException("Номер недели не соответствует дате учебного года");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,10 +49,11 @@ class AcademicCalendarGridServiceTest {
|
|||||||
|
|
||||||
private AcademicCalendarGridService service;
|
private AcademicCalendarGridService service;
|
||||||
private AcademicCalendarActivityType activityType;
|
private AcademicCalendarActivityType activityType;
|
||||||
|
private AcademicYear academicYear;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
AcademicYear academicYear = new AcademicYear();
|
academicYear = new AcademicYear();
|
||||||
academicYear.setId(1L);
|
academicYear.setId(1L);
|
||||||
academicYear.setTitle("2025-2026");
|
academicYear.setTitle("2025-2026");
|
||||||
academicYear.setStartDate(YEAR_START);
|
academicYear.setStartDate(YEAR_START);
|
||||||
@@ -197,6 +198,48 @@ class AcademicCalendarGridServiceTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alignsFirstWeekToMondayWhenAcademicYearStartsOnTuesday() {
|
||||||
|
LocalDate yearStart = LocalDate.of(2026, 9, 1);
|
||||||
|
academicYear.setStartDate(yearStart);
|
||||||
|
academicYear.setEndDate(LocalDate.of(2027, 6, 30));
|
||||||
|
|
||||||
|
service.replaceGrid(CALENDAR_ID, List.of(
|
||||||
|
validRow(yearStart, 1, 2),
|
||||||
|
validRow(LocalDate.of(2026, 9, 6), 1, 7),
|
||||||
|
validRow(LocalDate.of(2026, 9, 7), 2, 1),
|
||||||
|
validRow(LocalDate.of(2026, 9, 14), 3, 1)
|
||||||
|
));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||||
|
assertThat(replacementCaptor.getValue())
|
||||||
|
.extracting(AcademicCalendarDay::getWeekNumber)
|
||||||
|
.containsExactly(1, 1, 2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alignsFirstWeekToMondayWhenAcademicYearStartsOnWednesday() {
|
||||||
|
LocalDate yearStart = LocalDate.of(2027, 9, 1);
|
||||||
|
academicYear.setStartDate(yearStart);
|
||||||
|
academicYear.setEndDate(LocalDate.of(2028, 6, 30));
|
||||||
|
|
||||||
|
service.replaceGrid(CALENDAR_ID, List.of(
|
||||||
|
validRow(yearStart, 1, 3),
|
||||||
|
validRow(LocalDate.of(2027, 9, 5), 1, 7),
|
||||||
|
validRow(LocalDate.of(2027, 9, 6), 2, 1),
|
||||||
|
validRow(LocalDate.of(2027, 9, 13), 3, 1)
|
||||||
|
));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||||
|
assertThat(replacementCaptor.getValue())
|
||||||
|
.extracting(AcademicCalendarDay::getWeekNumber)
|
||||||
|
.containsExactly(1, 1, 2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
|
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
|
||||||
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
|
return row(date, weekNumber, dayOfWeek, ACTIVITY_TYPE_ID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
1. В разделе кафедр и специальностей администратор создаёт специальность и её профили.
|
1. В разделе кафедр и специальностей администратор создаёт специальность и её профили.
|
||||||
2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения.
|
2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения.
|
||||||
3. В разделе календарного графика администратор создаёт учебный год и семестры.
|
3. В разделе календарного графика администратор создаёт учебный год и семестры.
|
||||||
4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов.
|
4. Там же создаётся календарный учебный график: администратор выбирает учебный год, специальность, профиль, форму обучения из общего справочника и количество курсов. Название вручную не вводится — frontend формирует его в порядке `код специальности — профиль обучения — форма обучения — учебный год`.
|
||||||
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
|
5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат.
|
||||||
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
|
6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`.
|
||||||
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
|
7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам.
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
**Тело создания/обновления графика:**
|
**Тело создания/обновления графика:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "09.03.04 очная форма 2025-2026",
|
"title": "09.03.04 — Программная инженерия — Очная — 2025-2026",
|
||||||
"academicYearId": 1,
|
"academicYearId": 1,
|
||||||
"specialtyId": 2,
|
"specialtyId": 2,
|
||||||
"specialtyProfileId": 3,
|
"specialtyProfileId": 3,
|
||||||
@@ -570,6 +570,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
|
|
||||||
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
|
`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет.
|
||||||
`courseCount` должен быть в диапазоне `1..8`.
|
`courseCount` должен быть в диапазоне `1..8`.
|
||||||
|
Административный frontend формирует `title` автоматически из кода специальности, профиля, формы обучения и учебного года; отдельного поля ручного ввода в интерфейсе нет.
|
||||||
|
|
||||||
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
|
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
|
||||||
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
||||||
@@ -594,7 +595,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
удаления прежних строк backend проверяет весь список: он должен быть непустым и не
|
удаления прежних строк backend проверяет весь список: он должен быть непустым и не
|
||||||
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
|
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
|
||||||
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber` — с номером семидневного периода
|
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber` — с номером семидневного периода
|
||||||
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый
|
`понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
|
||||||
|
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
|
||||||
|
`(courseNumber, date)` не должен повторяться, каждый
|
||||||
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
|
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
|
||||||
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
|
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
|
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
|
||||||
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
|
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
|
||||||
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
|
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
|
||||||
расписания очищается только после commit.
|
расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе
|
||||||
|
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
|
||||||
|
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
|
||||||
|
к неделе 2.
|
||||||
|
|
||||||
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
|
||||||
1. Определяет семестр для каждой даты диапазона.
|
1. Определяет семестр для каждой даты диапазона.
|
||||||
|
|||||||
@@ -775,7 +775,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||||||
| `course_number` | INT | Номер курса |
|
| `course_number` | INT | Номер курса |
|
||||||
| `date` | DATE | Дата учебного года |
|
| `date` | DATE | Дата учебного года |
|
||||||
| `week_number` | INT | Номер недели от начала учебного года |
|
| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
|
||||||
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
||||||
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
||||||
|
|
||||||
@@ -920,13 +920,14 @@ lifecycle ресурсов, эффективная сетка целевого
|
|||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||||
|
| `V2__align_academic_calendar_weeks_to_monday.sql` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
По прямому решению владельца проекта все миграции V2–V7 объединены в V1, поскольку
|
По прямому решению владельца проекта прежние разработческие миграции V2–V7 были объединены
|
||||||
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно
|
в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана
|
||||||
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в
|
после фиксации baseline и накатывается поверх существующих tenant-БД без изменения
|
||||||
`flyway_schema_history` не поддерживается.
|
контрольной суммы V1.
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ frontend/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
|
│ └── build-vendor.mjs # Сборка `/vendor/otel.js` через esbuild
|
||||||
├── tests/
|
├── tests/
|
||||||
|
│ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика
|
||||||
|
│ ├── academic-calendar-title.test.mjs # Составное название календарного графика
|
||||||
|
│ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport
|
||||||
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
||||||
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||||
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
||||||
@@ -51,6 +54,7 @@ frontend/
|
|||||||
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
||||||
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
||||||
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
|
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
|
||||||
|
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
|
||||||
│ │ ├── utils.js # Утилиты
|
│ │ ├── utils.js # Утилиты
|
||||||
│ │ └── views/ # Модули представлений
|
│ │ └── views/ # Модули представлений
|
||||||
│ │ ├── dashboard.js # Дашборд
|
│ │ ├── dashboard.js # Дашборд
|
||||||
@@ -64,6 +68,8 @@ frontend/
|
|||||||
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
||||||
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
||||||
│ │ ├── schedule.js # Конструктор правил расписания
|
│ │ ├── schedule.js # Конструктор правил расписания
|
||||||
|
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
||||||
|
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
||||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||||
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
||||||
│ ├── views/ # HTML-шаблоны представлений
|
│ ├── views/ # HTML-шаблоны представлений
|
||||||
@@ -178,9 +184,9 @@ frontend/
|
|||||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Отдельного поля названия у календарного графика нет: frontend формирует его как `код специальности — профиль обучения — форма обучения — учебный год`, показывает одинаково в таблицах и селектах и отправляет вычисленное значение в `title` для совместимости с API. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`.
|
||||||
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
||||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||||
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
- Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование.
|
||||||
|
|
||||||
|
|||||||
@@ -2167,8 +2167,8 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(4px) scale(0.98);
|
transform: translateY(4px) scale(0.98);
|
||||||
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
|
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
|
||||||
right: 12px;
|
left: 0;
|
||||||
bottom: 12px;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-tooltip.visible {
|
.calendar-day-tooltip.visible {
|
||||||
|
|||||||
180
frontend/admin/js/date-input.js
Normal file
180
frontend/admin/js/date-input.js
Normal 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;
|
||||||
|
}
|
||||||
39
frontend/admin/js/views/academic-calendar-grid.js
Normal file
39
frontend/admin/js/views/academic-calendar-grid.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
21
frontend/admin/js/views/academic-calendar-title.js
Normal file
21
frontend/admin/js/views/academic-calendar-title.js
Normal 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) : '';
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
|
||||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||||
|
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
||||||
|
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||||||
@@ -16,6 +19,41 @@ const SEMESTER_LABELS = {
|
|||||||
spring: 'весенний'
|
spring: 'весенний'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CALENDAR_TOOLTIP_OFFSET = 14;
|
||||||
|
const CALENDAR_TOOLTIP_VIEWPORT_PADDING = 12;
|
||||||
|
|
||||||
|
export function calculateCalendarTooltipPosition({
|
||||||
|
pointerX,
|
||||||
|
pointerY,
|
||||||
|
tooltipWidth,
|
||||||
|
tooltipHeight,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight
|
||||||
|
}) {
|
||||||
|
const maxLeft = Math.max(
|
||||||
|
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||||
|
viewportWidth - tooltipWidth - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||||
|
);
|
||||||
|
const maxTop = Math.max(
|
||||||
|
CALENDAR_TOOLTIP_VIEWPORT_PADDING,
|
||||||
|
viewportHeight - tooltipHeight - CALENDAR_TOOLTIP_VIEWPORT_PADDING
|
||||||
|
);
|
||||||
|
let left = pointerX + CALENDAR_TOOLTIP_OFFSET;
|
||||||
|
let top = pointerY + CALENDAR_TOOLTIP_OFFSET;
|
||||||
|
|
||||||
|
if (left > maxLeft) {
|
||||||
|
left = pointerX - tooltipWidth - CALENDAR_TOOLTIP_OFFSET;
|
||||||
|
}
|
||||||
|
if (top > maxTop) {
|
||||||
|
top = pointerY - tooltipHeight - CALENDAR_TOOLTIP_OFFSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||||||
|
top: Math.round(Math.max(CALENDAR_TOOLTIP_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function initAcademicCalendar() {
|
export async function initAcademicCalendar() {
|
||||||
const academicYearForm = document.getElementById('academic-year-form');
|
const academicYearForm = document.getElementById('academic-year-form');
|
||||||
const academicYearFormTitle = document.getElementById('academic-year-form-title');
|
const academicYearFormTitle = document.getElementById('academic-year-form-title');
|
||||||
@@ -39,7 +77,6 @@ export async function initAcademicCalendar() {
|
|||||||
const calendarForm = document.getElementById('academic-calendar-form');
|
const calendarForm = document.getElementById('academic-calendar-form');
|
||||||
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
||||||
const calendarIdInput = document.getElementById('academic-calendar-id');
|
const calendarIdInput = document.getElementById('academic-calendar-id');
|
||||||
const calendarTitleInput = document.getElementById('academic-calendar-title');
|
|
||||||
const calendarYearSelect = document.getElementById('academic-calendar-year');
|
const calendarYearSelect = document.getElementById('academic-calendar-year');
|
||||||
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
|
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
|
||||||
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
|
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
|
||||||
@@ -100,6 +137,7 @@ export async function initAcademicCalendar() {
|
|||||||
let suppressCalendarDayClick = false;
|
let suppressCalendarDayClick = false;
|
||||||
let isSyncingCalendarDayDialog = false;
|
let isSyncingCalendarDayDialog = false;
|
||||||
let calendarDayTooltip = null;
|
let calendarDayTooltip = null;
|
||||||
|
let calendarDayTooltipPositionAnimation = null;
|
||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
@@ -119,6 +157,15 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
|
[
|
||||||
|
academicYearStartInput,
|
||||||
|
academicYearEndInput,
|
||||||
|
semesterStartInput,
|
||||||
|
semesterEndInput,
|
||||||
|
fillStartInput,
|
||||||
|
fillEndInput
|
||||||
|
].forEach(bindDateInputMask);
|
||||||
|
|
||||||
calendarTabButtons.forEach(button => {
|
calendarTabButtons.forEach(button => {
|
||||||
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
||||||
});
|
});
|
||||||
@@ -271,12 +318,12 @@ export async function initAcademicCalendar() {
|
|||||||
async function saveAcademicYear(event) {
|
async function saveAcademicYear(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
hideAlert('academic-year-alert');
|
hideAlert('academic-year-alert');
|
||||||
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: academicYearTitleInput.value.trim(),
|
title: academicYearTitleInput.value.trim(),
|
||||||
startDate: academicYearStartInput.value,
|
startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'),
|
||||||
endDate: academicYearEndInput.value
|
endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года')
|
||||||
};
|
};
|
||||||
try {
|
|
||||||
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
|
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
|
||||||
const id = academicYearIdInput.value;
|
const id = academicYearIdInput.value;
|
||||||
await (id
|
await (id
|
||||||
@@ -302,8 +349,8 @@ export async function initAcademicCalendar() {
|
|||||||
academicYearFormTitle.textContent = 'Редактирование учебного года';
|
academicYearFormTitle.textContent = 'Редактирование учебного года';
|
||||||
academicYearIdInput.value = year.id;
|
academicYearIdInput.value = year.id;
|
||||||
academicYearTitleInput.value = year.title || '';
|
academicYearTitleInput.value = year.title || '';
|
||||||
academicYearStartInput.value = year.startDate || '';
|
setDateInputIso(academicYearStartInput, year.startDate);
|
||||||
academicYearEndInput.value = year.endDate || '';
|
setDateInputIso(academicYearEndInput, year.endDate);
|
||||||
hideAlert('academic-year-alert');
|
hideAlert('academic-year-alert');
|
||||||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
return;
|
return;
|
||||||
@@ -316,8 +363,8 @@ export async function initAcademicCalendar() {
|
|||||||
semesterIdInput.value = semester.id;
|
semesterIdInput.value = semester.id;
|
||||||
semesterYearSelect.value = semester.academicYearId || '';
|
semesterYearSelect.value = semester.academicYearId || '';
|
||||||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||||||
semesterStartInput.value = semester.startDate || '';
|
setDateInputIso(semesterStartInput, semester.startDate);
|
||||||
semesterEndInput.value = semester.endDate || '';
|
setDateInputIso(semesterEndInput, semester.endDate);
|
||||||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||||||
hideAlert('semester-alert');
|
hideAlert('semester-alert');
|
||||||
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -348,12 +395,12 @@ export async function initAcademicCalendar() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
hideAlert('semester-alert');
|
hideAlert('semester-alert');
|
||||||
const yearId = semesterYearSelect.value;
|
const yearId = semesterYearSelect.value;
|
||||||
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
semesterType: semesterTypeSelect.value,
|
semesterType: semesterTypeSelect.value,
|
||||||
startDate: semesterStartInput.value,
|
startDate: readDateInputIso(semesterStartInput, 'Начало семестра'),
|
||||||
endDate: semesterEndInput.value
|
endDate: readDateInputIso(semesterEndInput, 'Окончание семестра')
|
||||||
};
|
};
|
||||||
try {
|
|
||||||
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
|
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
|
||||||
throw new Error('Заполните учебный год, тип и даты семестра');
|
throw new Error('Заполните учебный год, тип и даты семестра');
|
||||||
}
|
}
|
||||||
@@ -420,7 +467,7 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(calendar.title)}</td>
|
<td>${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</td>
|
||||||
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
||||||
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
||||||
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
||||||
@@ -441,7 +488,7 @@ export async function initAcademicCalendar() {
|
|||||||
hideAlert('academic-calendar-alert');
|
hideAlert('academic-calendar-alert');
|
||||||
const payload = {
|
const payload = {
|
||||||
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
|
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
|
||||||
title: calendarTitleInput.value.trim(),
|
title: buildCalendarTitleFromForm(),
|
||||||
academicYearId: Number(calendarYearSelect.value),
|
academicYearId: Number(calendarYearSelect.value),
|
||||||
specialtyId: Number(calendarSpecialtySelect.value),
|
specialtyId: Number(calendarSpecialtySelect.value),
|
||||||
specialtyProfileId: Number(calendarProfileSelect.value),
|
specialtyProfileId: Number(calendarProfileSelect.value),
|
||||||
@@ -457,7 +504,8 @@ export async function initAcademicCalendar() {
|
|||||||
const saved = id
|
const saved = id
|
||||||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||||||
: await api.post('/api/admin/academic-calendars', payload);
|
: await api.post('/api/admin/academic-calendars', payload);
|
||||||
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
|
const savedTitle = buildAcademicCalendarTitle(saved) || payload.title;
|
||||||
|
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
|
||||||
resetCalendarForm();
|
resetCalendarForm();
|
||||||
await loadCalendars();
|
await loadCalendars();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -477,7 +525,6 @@ export async function initAcademicCalendar() {
|
|||||||
if (!calendar) return;
|
if (!calendar) return;
|
||||||
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
||||||
calendarIdInput.value = calendar.id;
|
calendarIdInput.value = calendar.id;
|
||||||
calendarTitleInput.value = calendar.title || '';
|
|
||||||
calendarYearSelect.value = calendar.academicYearId || '';
|
calendarYearSelect.value = calendar.academicYearId || '';
|
||||||
calendarSpecialtySelect.value = calendar.specialtyId || '';
|
calendarSpecialtySelect.value = calendar.specialtyId || '';
|
||||||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
|
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
|
||||||
@@ -721,12 +768,19 @@ export async function initAcademicCalendar() {
|
|||||||
function applyCalendarFill() {
|
function applyCalendarFill() {
|
||||||
hideAlert('calendar-editor-alert');
|
hideAlert('calendar-editor-alert');
|
||||||
const activityId = fillActivitySelect.value;
|
const activityId = fillActivitySelect.value;
|
||||||
const start = fillStartInput.value;
|
if (!activityId || !fillStartInput.value || !fillEndInput.value) {
|
||||||
const end = fillEndInput.value;
|
|
||||||
if (!activityId || !start || !end) {
|
|
||||||
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
|
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
try {
|
||||||
|
start = readDateInputIso(fillStartInput, 'Дата начала диапазона');
|
||||||
|
end = readDateInputIso(fillEndInput, 'Дата окончания диапазона');
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('calendar-editor-alert', error.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (end < start) {
|
if (end < start) {
|
||||||
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
|
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -1051,12 +1105,33 @@ export async function initAcademicCalendar() {
|
|||||||
const tooltip = ensureCalendarDayTooltip();
|
const tooltip = ensureCalendarDayTooltip();
|
||||||
tooltip.innerHTML = renderCalendarDayTooltip(day);
|
tooltip.innerHTML = renderCalendarDayTooltip(day);
|
||||||
tooltip.classList.add('visible');
|
tooltip.classList.add('visible');
|
||||||
|
positionCalendarDayTooltip(tooltip, day, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCalendarDayTooltip() {
|
function hideCalendarDayTooltip() {
|
||||||
calendarDayTooltip?.classList.remove('visible');
|
calendarDayTooltip?.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function positionCalendarDayTooltip(tooltip, day, event) {
|
||||||
|
const dayRect = day.getBoundingClientRect();
|
||||||
|
const pointerX = Number.isFinite(event?.clientX) ? event.clientX : dayRect.right;
|
||||||
|
const pointerY = Number.isFinite(event?.clientY) ? event.clientY : dayRect.bottom;
|
||||||
|
const tooltipRect = tooltip.getBoundingClientRect();
|
||||||
|
const { left, top } = calculateCalendarTooltipPosition({
|
||||||
|
pointerX,
|
||||||
|
pointerY,
|
||||||
|
tooltipWidth: tooltipRect.width,
|
||||||
|
tooltipHeight: tooltipRect.height,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight
|
||||||
|
});
|
||||||
|
calendarDayTooltipPositionAnimation?.cancel();
|
||||||
|
calendarDayTooltipPositionAnimation = tooltip.animate(
|
||||||
|
[{ left: `${left}px`, top: `${top}px` }],
|
||||||
|
{ duration: 0, fill: 'forwards' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureCalendarDayTooltip() {
|
function ensureCalendarDayTooltip() {
|
||||||
if (calendarDayTooltip) return calendarDayTooltip;
|
if (calendarDayTooltip) return calendarDayTooltip;
|
||||||
calendarDayTooltip = document.createElement('div');
|
calendarDayTooltip = document.createElement('div');
|
||||||
@@ -1156,13 +1231,13 @@ export async function initAcademicCalendar() {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
||||||
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
||||||
dates.forEach((date, index) => {
|
dates.forEach(date => {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: null,
|
id: null,
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
courseNumber: course,
|
courseNumber: course,
|
||||||
date: date.iso,
|
date: date.iso,
|
||||||
weekNumber: Math.floor(index / 7) + 1,
|
weekNumber: academicCalendarWeekNumber(calendar.academicYearStartDate, date.iso),
|
||||||
dayOfWeek: date.dayOfWeek,
|
dayOfWeek: date.dayOfWeek,
|
||||||
activityTypeId: theoryActivityId(),
|
activityTypeId: theoryActivityId(),
|
||||||
activityCode: 'Т'
|
activityCode: 'Т'
|
||||||
@@ -1285,7 +1360,9 @@ export async function initAcademicCalendar() {
|
|||||||
function populateEditorCalendarSelect() {
|
function populateEditorCalendarSelect() {
|
||||||
const current = editorCalendarSelect.value;
|
const current = editorCalendarSelect.value;
|
||||||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||||||
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
|
calendars.map(calendar =>
|
||||||
|
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||||
|
).join('');
|
||||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||||
editorCalendarSelect.value = current;
|
editorCalendarSelect.value = current;
|
||||||
}
|
}
|
||||||
@@ -1297,7 +1374,7 @@ export async function initAcademicCalendar() {
|
|||||||
const current = subjectCalendarSelect.value;
|
const current = subjectCalendarSelect.value;
|
||||||
subjectCalendarSelect.innerHTML = calendars.length
|
subjectCalendarSelect.innerHTML = calendars.length
|
||||||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||||
).join('')
|
).join('')
|
||||||
: '<option value="">Нет графиков</option>';
|
: '<option value="">Нет графиков</option>';
|
||||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||||
@@ -1425,11 +1502,40 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function specialityLabel(speciality) {
|
function specialityLabel(speciality) {
|
||||||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
const code = specialityCode(speciality) || speciality.id;
|
||||||
const name = speciality.specialityName || speciality.name || '';
|
const name = speciality.specialityName || speciality.name || '';
|
||||||
return name ? `${code} - ${name}` : String(code);
|
return name ? `${code} - ${name}` : String(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function specialityCode(speciality) {
|
||||||
|
return speciality?.specialityCode
|
||||||
|
|| speciality?.specialtyCode
|
||||||
|
|| speciality?.speciality_code
|
||||||
|
|| speciality?.specialty_code
|
||||||
|
|| '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCalendarTitleFromForm() {
|
||||||
|
const specialty = specialties.find(item =>
|
||||||
|
String(item.id) === String(calendarSpecialtySelect.value)
|
||||||
|
);
|
||||||
|
const profile = (profilesBySpecialty.get(String(calendarSpecialtySelect.value)) || [])
|
||||||
|
.find(item => String(item.id) === String(calendarProfileSelect.value));
|
||||||
|
const studyForm = studyForms.find(item =>
|
||||||
|
String(item.id) === String(calendarStudyFormSelect.value)
|
||||||
|
);
|
||||||
|
const academicYear = academicYears.find(item =>
|
||||||
|
String(item.id) === String(calendarYearSelect.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildAcademicCalendarTitle({
|
||||||
|
specialtyCode: specialityCode(specialty),
|
||||||
|
specialtyProfileName: profile?.name,
|
||||||
|
studyFormName: studyForm?.name,
|
||||||
|
academicYearTitle: academicYear?.title
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function dayLabel(value) {
|
function dayLabel(value) {
|
||||||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
|
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
|
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||||||
|
|
||||||
async function fetchEducationForms() {
|
async function fetchEducationForms() {
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +385,7 @@ export async function initGroups() {
|
|||||||
: [];
|
: [];
|
||||||
manageCalendarSelect.innerHTML = matching.length
|
manageCalendarSelect.innerHTML = matching.length
|
||||||
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
? '<option value="">Выберите график</option>' + matching.map(calendar =>
|
||||||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||||
).join('')
|
).join('')
|
||||||
: '<option value="">Нет подходящих графиков</option>';
|
: '<option value="">Нет подходящих графиков</option>';
|
||||||
syncSelects(manageCalendarSelect);
|
syncSelects(manageCalendarSelect);
|
||||||
@@ -767,7 +768,7 @@ export async function initGroups() {
|
|||||||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||||||
<td>${escapeHtml(assignment.calendarTitle)}</td>
|
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
|
||||||
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||||||
@@ -776,6 +777,13 @@ export async function initGroups() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignmentCalendarTitle(assignment) {
|
||||||
|
const calendar = calendars.find(item =>
|
||||||
|
String(item.id) === String(assignment.calendarId)
|
||||||
|
);
|
||||||
|
return buildAcademicCalendarTitle(calendar) || assignment.calendarTitle || '-';
|
||||||
|
}
|
||||||
|
|
||||||
function renderAssignmentSubjects(subjects) {
|
function renderAssignmentSubjects(subjects) {
|
||||||
if (!subjects.length) {
|
if (!subjects.length) {
|
||||||
return '<span class="muted">Не привязаны</span>';
|
return '<span class="muted">Не привязаны</span>';
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="academic-year-start">Начало</label>
|
<label for="academic-year-start">Начало</label>
|
||||||
<input type="date" id="academic-year-start" required>
|
<input type="text" id="academic-year-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="academic-year-end">Окончание</label>
|
<label for="academic-year-end">Окончание</label>
|
||||||
<input type="date" id="academic-year-end" required>
|
<input type="text" id="academic-year-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
|
<button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
|
||||||
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
<button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
|
||||||
@@ -73,11 +73,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="semester-start">Начало</label>
|
<label for="semester-start">Начало</label>
|
||||||
<input type="date" id="semester-start" required>
|
<input type="text" id="semester-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="semester-end">Окончание</label>
|
<label for="semester-end">Окончание</label>
|
||||||
<input type="date" id="semester-end" required>
|
<input type="text" id="semester-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
|
<button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
|
||||||
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
|
<button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
|
||||||
@@ -94,10 +94,6 @@
|
|||||||
<form id="academic-calendar-form" novalidate>
|
<form id="academic-calendar-form" novalidate>
|
||||||
<input type="hidden" id="academic-calendar-id">
|
<input type="hidden" id="academic-calendar-id">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
|
||||||
<label for="academic-calendar-title">Название</label>
|
|
||||||
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="academic-calendar-year">Учебный год</label>
|
<label for="academic-calendar-year">Учебный год</label>
|
||||||
<select id="academic-calendar-year" required>
|
<select id="academic-calendar-year" required>
|
||||||
@@ -183,11 +179,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-fill-start">С даты</label>
|
<label for="calendar-fill-start">С даты</label>
|
||||||
<input type="date" id="calendar-fill-start">
|
<input type="text" id="calendar-fill-start" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-fill-end">По дату</label>
|
<label for="calendar-fill-end">По дату</label>
|
||||||
<input type="date" id="calendar-fill-end">
|
<input type="text" id="calendar-fill-end" data-date-input inputmode="numeric" maxlength="10" pattern="[0-9]{2}\.[0-9]{2}\.[0-9]{4}" placeholder="ДД.ММ.ГГГГ">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-fill-activity">Код</label>
|
<label for="calendar-fill-activity">Код</label>
|
||||||
|
|||||||
29
frontend/tests/academic-calendar-grid.test.mjs
Normal file
29
frontend/tests/academic-calendar-grid.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
31
frontend/tests/academic-calendar-title.test.mjs
Normal file
31
frontend/tests/academic-calendar-title.test.mjs
Normal 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'
|
||||||
|
}), '');
|
||||||
|
});
|
||||||
37
frontend/tests/academic-calendar-tooltip.test.mjs
Normal file
37
frontend/tests/academic-calendar-tooltip.test.mjs
Normal 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 });
|
||||||
|
});
|
||||||
91
frontend/tests/date-input.test.mjs
Normal file
91
frontend/tests/date-input.test.mjs
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user