From 7e29c53ce92055d2be61aa5f5b24f4830a4bd2d0 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Mon, 27 Jul 2026 00:33:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 37 --- .../service/AcademicCalendarGridService.java | 3 +- ...lign_academic_calendar_weeks_to_monday.sql | 23 ++ ...darWeekNumberMigrationIntegrationTest.java | 218 ++++++++++++++++++ .../AcademicCalendarGridServiceTest.java | 45 +++- docs/ACADEMIC_CALENDAR.md | 2 +- docs/API.md | 7 +- docs/BUSINESS_LOGIC.md | 5 +- docs/DATABASE.md | 11 +- docs/FRONTEND.md | 10 +- frontend/admin/css/components.css | 4 +- frontend/admin/js/date-input.js | 180 +++++++++++++++ .../admin/js/views/academic-calendar-grid.js | 39 ++++ .../admin/js/views/academic-calendar-title.js | 21 ++ frontend/admin/js/views/academic-calendar.js | 160 ++++++++++--- frontend/admin/js/views/groups.js | 12 +- frontend/admin/views/academic-calendar.html | 16 +- .../tests/academic-calendar-grid.test.mjs | 29 +++ .../tests/academic-calendar-title.test.mjs | 31 +++ .../tests/academic-calendar-tooltip.test.mjs | 37 +++ frontend/tests/date-input.test.mjs | 91 ++++++++ 21 files changed, 890 insertions(+), 91 deletions(-) delete mode 100644 .env.example create mode 100644 backend/src/main/resources/db/migration/V2__align_academic_calendar_weeks_to_monday.sql create mode 100644 backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberMigrationIntegrationTest.java create mode 100644 frontend/admin/js/date-input.js create mode 100644 frontend/admin/js/views/academic-calendar-grid.js create mode 100644 frontend/admin/js/views/academic-calendar-title.js create mode 100644 frontend/tests/academic-calendar-grid.test.mjs create mode 100644 frontend/tests/academic-calendar-title.test.mjs create mode 100644 frontend/tests/academic-calendar-tooltip.test.mjs create mode 100644 frontend/tests/date-input.test.mjs diff --git a/.env.example b/.env.example deleted file mode 100644 index 24fdebd..0000000 --- a/.env.example +++ /dev/null @@ -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 diff --git a/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java index 7489da0..17a9103 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java @@ -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("Номер недели не соответствует дате учебного года"); } diff --git a/backend/src/main/resources/db/migration/V2__align_academic_calendar_weeks_to_monday.sql b/backend/src/main/resources/db/migration/V2__align_academic_calendar_weeks_to_monday.sql new file mode 100644 index 0000000..ecc153c --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__align_academic_calendar_weeks_to_monday.sql @@ -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; diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberMigrationIntegrationTest.java new file mode 100644 index 0000000..c32a4fc --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberMigrationIntegrationTest.java @@ -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 + ) { + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java index b996c66..3bab8fe 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java @@ -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> 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> 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); } diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index 7e3cb74..8b0c498 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -63,7 +63,7 @@ 1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. 2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. 3. В разделе календарного графика администратор создаёт учебный год и семестры. -4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. +4. Там же создаётся календарный учебный график: администратор выбирает учебный год, специальность, профиль, форму обучения из общего справочника и количество курсов. Название вручную не вводится — frontend формирует его в порядке `код специальности — профиль обучения — форма обучения — учебный год`. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 6. На вкладке дисциплин графика администратор вручную привязывает дисциплины к учебным семестрам: например, для четырёх курсов доступны семестры `1..8`. 7. В разделе групп администратор назначает календарный график группе на нужный учебный год; вместе с назначением виден список дисциплин по семестрам. diff --git a/docs/API.md b/docs/API.md index 66ff3b3..902a417 100644 --- a/docs/API.md +++ b/docs/API.md @@ -559,7 +559,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 **Тело создания/обновления графика:** ```json { - "title": "09.03.04 очная форма 2025-2026", + "title": "09.03.04 — Программная инженерия — Очная — 2025-2026", "academicYearId": 1, "specialtyId": 2, "specialtyProfileId": 3, @@ -570,6 +570,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `courseCount` должен быть в диапазоне `1..8`. +Административный frontend формирует `title` автоматически из кода специальности, профиля, формы обучения и учебного года; отдельного поля ручного ввода в интерфейсе нет. При `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 проверяет весь список: он должен быть непустым и не содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год, `dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber` — с номером семидневного периода -от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый +`понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели +до начала года не входят в JSON и отображаются пустыми ячейками. Ключ +`(courseNumber, date)` не должен повторяться, каждый `activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка остаётся без изменений; `calendarId` из строки не переопределяет ID в URL. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 57765b5..6b55c56 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -166,7 +166,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш -расписания очищается только после commit. +расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе +`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него +остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже +к неделе 2. Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index a898570..d267987 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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(1–7) | День недели 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` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели | ### Этап разработки -По прямому решению владельца проекта все миграции V2–V7 объединены в V1, поскольку -клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно -пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в -`flyway_schema_history` не поддерживается. +По прямому решению владельца проекта прежние разработческие миграции V2–V7 были объединены +в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана +после фиксации baseline и накатывается поверх существующих tenant-БД без изменения +контрольной суммы V1. ### Полный сброс БД (локально) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6f12fed..54cf230 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -26,6 +26,9 @@ 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 │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки @@ -51,6 +54,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 +68,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-шаблоны представлений @@ -178,9 +184,9 @@ frontend/ - Вкладка `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`. +- Вкладка `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`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. -- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 08662d7..78681cd 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -2167,8 +2167,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 { diff --git a/frontend/admin/js/date-input.js b/frontend/admin/js/date-input.js new file mode 100644 index 0000000..7b265b2 --- /dev/null +++ b/frontend/admin/js/date-input.js @@ -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; +} diff --git a/frontend/admin/js/views/academic-calendar-grid.js b/frontend/admin/js/views/academic-calendar-grid.js new file mode 100644 index 0000000..2c4d7e2 --- /dev/null +++ b/frontend/admin/js/views/academic-calendar-grid.js @@ -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 + }; +} diff --git a/frontend/admin/js/views/academic-calendar-title.js b/frontend/admin/js/views/academic-calendar-title.js new file mode 100644 index 0000000..98fcbc0 --- /dev/null +++ b/frontend/admin/js/views/academic-calendar-title.js @@ -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) : ''; +} diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index de46715..b45a12a 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -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'); - const payload = { - title: academicYearTitleInput.value.trim(), - startDate: academicYearStartInput.value, - endDate: academicYearEndInput.value - }; try { + const payload = { + title: academicYearTitleInput.value.trim(), + startDate: readDateInputIso(academicYearStartInput, 'Начало учебного года'), + endDate: readDateInputIso(academicYearEndInput, 'Окончание учебного года') + }; 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; - const payload = { - semesterType: semesterTypeSelect.value, - startDate: semesterStartInput.value, - endDate: semesterEndInput.value - }; try { + const payload = { + semesterType: semesterTypeSelect.value, + startDate: readDateInputIso(semesterStartInput, 'Начало семестра'), + endDate: readDateInputIso(semesterEndInput, 'Окончание семестра') + }; if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { throw new Error('Заполните учебный год, тип и даты семестра'); } @@ -420,7 +467,7 @@ export async function initAcademicCalendar() { } calendarsTbody.innerHTML = calendars.map(calendar => ` - ${escapeHtml(calendar.title)} + ${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')} ${escapeHtml(calendar.academicYearTitle || '-')} ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} ${escapeHtml(calendar.specialtyProfileName || '-')} @@ -441,7 +488,7 @@ export async function initAcademicCalendar() { hideAlert('academic-calendar-alert'); const payload = { id: calendarIdInput.value ? Number(calendarIdInput.value) : null, - title: calendarTitleInput.value.trim(), + title: buildCalendarTitleFromForm(), academicYearId: Number(calendarYearSelect.value), specialtyId: Number(calendarSpecialtySelect.value), specialtyProfileId: Number(calendarProfileSelect.value), @@ -457,7 +504,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) || payload.title; + showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success'); resetCalendarForm(); await loadCalendars(); } catch (error) { @@ -477,7 +525,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 +768,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 +1105,33 @@ 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(); + calendarDayTooltipPositionAnimation = tooltip.animate( + [{ left: `${left}px`, top: `${top}px` }], + { duration: 0, fill: 'forwards' } + ); + } + function ensureCalendarDayTooltip() { if (calendarDayTooltip) return calendarDayTooltip; calendarDayTooltip = document.createElement('div'); @@ -1156,13 +1231,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 +1360,9 @@ export async function initAcademicCalendar() { function populateEditorCalendarSelect() { const current = editorCalendarSelect.value; editorCalendarSelect.innerHTML = '' + - calendars.map(calendar => ``).join(''); + calendars.map(calendar => + `` + ).join(''); if (current && calendars.some(calendar => String(calendar.id) === String(current))) { editorCalendarSelect.value = current; } @@ -1297,7 +1374,7 @@ export async function initAcademicCalendar() { const current = subjectCalendarSelect.value; subjectCalendarSelect.innerHTML = calendars.length ? '' + calendars.map(calendar => - `` + `` ).join('') : ''; if (current && calendars.some(calendar => String(calendar.id) === String(current))) { @@ -1425,11 +1502,40 @@ 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 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) { return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 754525e..b9e6150 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -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 ? '' + matching.map(calendar => - `` + `` ).join('') : ''; syncSelects(manageCalendarSelect); @@ -767,7 +768,7 @@ export async function initGroups() { manageCalendarTbody.innerHTML = currentAssignments.map(assignment => ` ${escapeHtml(assignment.academicYearTitle)} - ${escapeHtml(assignment.calendarTitle)} + ${escapeHtml(assignmentCalendarTitle(assignment))} ${renderAssignmentSubjects(assignment.subjects || [])} @@ -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 'Не привязаны'; diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index 915f0c7..e3b47e3 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -16,11 +16,11 @@
- +
- +
@@ -73,11 +73,11 @@
- +
- +
@@ -94,10 +94,6 @@
-
- - -
+
- +
diff --git a/frontend/tests/academic-calendar-grid.test.mjs b/frontend/tests/academic-calendar-grid.test.mjs new file mode 100644 index 0000000..4af09cc --- /dev/null +++ b/frontend/tests/academic-calendar-grid.test.mjs @@ -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); +}); diff --git a/frontend/tests/academic-calendar-title.test.mjs b/frontend/tests/academic-calendar-title.test.mjs new file mode 100644 index 0000000..cf7427a --- /dev/null +++ b/frontend/tests/academic-calendar-title.test.mjs @@ -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' + }), ''); +}); diff --git a/frontend/tests/academic-calendar-tooltip.test.mjs b/frontend/tests/academic-calendar-tooltip.test.mjs new file mode 100644 index 0000000..b4ee866 --- /dev/null +++ b/frontend/tests/academic-calendar-tooltip.test.mjs @@ -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 }); +}); diff --git a/frontend/tests/date-input.test.mjs b/frontend/tests/date-input.test.mjs new file mode 100644 index 0000000..eb089ee --- /dev/null +++ b/frontend/tests/date-input.test.mjs @@ -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')); + } +}