From a78a93f2f039f029383cf1c049300a9d8b64ee3a Mon Sep 17 00:00:00 2001 From: Zuev Date: Mon, 27 Jul 2026 02:23:26 +0300 Subject: [PATCH] =?UTF-8?q?#5=208=209=2010=2012=2013=2017=20-=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D1=8B.=20#15=20-=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcademicCalendarController.java | 6 +- .../app/service/AcademicStructureService.java | 11 +- .../utils/AcademicCalendarTitleBuilder.java | 38 +++++++ .../AcademicCalendarControllerTest.java | 98 ++++++++++++++++ .../service/AcademicStructureServiceTest.java | 14 ++- .../AcademicCalendarTitleBuilderTest.java | 40 +++++++ docs/API.md | 5 +- docs/BUSINESS_LOGIC.md | 4 + docs/FRONTEND.md | 11 +- frontend/admin/css/auditorium-workload.css | 12 ++ frontend/admin/css/components.css | 24 +++- frontend/admin/css/layout.css | 17 +++ frontend/admin/index.html | 8 -- frontend/admin/js/main.js | 15 +-- frontend/admin/js/views/academic-calendar.js | 41 ++----- frontend/admin/js/views/schedule-view.js | 29 ++++- frontend/admin/js/views/schedule.js | 12 +- frontend/admin/settings/index.html | 8 -- frontend/admin/settings/js/main.js | 13 --- frontend/admin/views/academic-calendar.html | 3 +- frontend/tests/admin-ui-regressions.test.mjs | 106 ++++++++++++++++++ 21 files changed, 413 insertions(+), 102 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/utils/AcademicCalendarTitleBuilder.java create mode 100644 backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java create mode 100644 backend/src/test/java/com/magistr/app/utils/AcademicCalendarTitleBuilderTest.java create mode 100644 frontend/tests/admin-ui-regressions.test.mjs diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 83074a7..e16703a 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -7,6 +7,7 @@ import com.magistr.app.repository.*; import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.ScheduleGeneratorService; +import com.magistr.app.utils.AcademicCalendarTitleBuilder; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -152,9 +153,6 @@ public class AcademicCalendarController { if (request == null) { throw new IllegalArgumentException("Передайте данные календарного графика"); } - if (request.title() == null || request.title().isBlank()) { - throw new IllegalArgumentException("Название графика обязательно"); - } if (request.courseCount() == null || request.courseCount() <= 0) { throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); } @@ -178,7 +176,7 @@ public class AcademicCalendarController { EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); - calendar.setTitle(request.title().trim()); + calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm)); calendar.setAcademicYear(year); calendar.setSpeciality(speciality); calendar.setSpecialtyProfile(profile); diff --git a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java index 24e3232..edd81de 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java @@ -20,6 +20,7 @@ import com.magistr.app.repository.SpecialtiesRepository; import com.magistr.app.repository.SpecialtyProfileRepository; import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import com.magistr.app.repository.SubgroupRepository; +import com.magistr.app.utils.AcademicCalendarTitleBuilder; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -126,7 +127,12 @@ public class AcademicStructureService { ); } - calendar.setTitle(request.title().trim()); + calendar.setTitle(AcademicCalendarTitleBuilder.build( + candidate.academicYear(), + candidate.speciality(), + candidate.profile(), + candidate.studyForm() + )); applyCalendarDimensions(calendar, candidate, request.courseCount()); AcademicCalendar saved = saveCalendar(calendar); clearScheduleCacheAfterCommit(); @@ -208,9 +214,6 @@ public class AcademicStructureService { if (request == null) { throw new IllegalArgumentException("Передайте данные календарного графика"); } - if (request.title() == null || request.title().isBlank()) { - throw new IllegalArgumentException("Название графика обязательно"); - } if (request.courseCount() == null || request.courseCount() <= 0) { throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); } diff --git a/backend/src/main/java/com/magistr/app/utils/AcademicCalendarTitleBuilder.java b/backend/src/main/java/com/magistr/app/utils/AcademicCalendarTitleBuilder.java new file mode 100644 index 0000000..bfd8860 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/utils/AcademicCalendarTitleBuilder.java @@ -0,0 +1,38 @@ +package com.magistr.app.utils; + +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; + +import java.util.List; + +public final class AcademicCalendarTitleBuilder { + + private static final String SEPARATOR = " — "; + + private AcademicCalendarTitleBuilder() { + } + + public static String build(AcademicYear academicYear, + Speciality speciality, + SpecialtyProfile profile, + EducationForm studyForm) { + List parts = List.of( + normalize(speciality == null ? null : speciality.getSpecialityCode()), + normalize(profile == null ? null : profile.getName()), + normalize(studyForm == null ? null : studyForm.getName()), + normalize(academicYear == null ? null : academicYear.getTitle()) + ); + if (parts.stream().anyMatch(String::isBlank)) { + throw new IllegalArgumentException( + "Невозможно сформировать название календарного графика из выбранных параметров" + ); + } + return String.join(SEPARATOR, parts); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java new file mode 100644 index 0000000..bc95735 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java @@ -0,0 +1,98 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.AcademicCalendarDto; +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; +import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarRepository; +import com.magistr.app.repository.AcademicCalendarSubjectRepository; +import com.magistr.app.repository.AcademicYearRepository; +import com.magistr.app.repository.EducationFormRepository; +import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; +import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.service.AcademicCalendarGridService; +import com.magistr.app.service.AcademicStructureService; +import com.magistr.app.service.ScheduleGeneratorService; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AcademicCalendarControllerTest { + + @Test + void createCalendarBuildsTitleWithoutClientValue() { + AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class); + AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class); + SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class); + SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class); + EducationFormRepository educationFormRepository = mock(EducationFormRepository.class); + + AcademicYear year = new AcademicYear(); + year.setId(1L); + year.setTitle("2026-2027"); + year.setStartDate(LocalDate.of(2026, 9, 1)); + year.setEndDate(LocalDate.of(2027, 6, 30)); + Speciality speciality = new Speciality(2L, "Программная инженерия", "09.03.04"); + SpecialtyProfile profile = new SpecialtyProfile(); + profile.setId(3L); + profile.setSpeciality(speciality); + profile.setName("Разработка ПО"); + EducationForm studyForm = new EducationForm(); + studyForm.setId(4L); + studyForm.setName("Очная"); + + when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year)); + when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality)); + when(profileRepository.findById(3L)).thenReturn(Optional.of(profile)); + when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm)); + when(calendarRepository.save(any(AcademicCalendar.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AcademicCalendarController controller = new AcademicCalendarController( + calendarRepository, + mock(AcademicCalendarDayRepository.class), + academicYearRepository, + specialtiesRepository, + profileRepository, + educationFormRepository, + mock(SubjectRepository.class), + mock(AcademicCalendarSubjectRepository.class), + mock(AcademicCalendarGridService.class), + mock(ScheduleGeneratorService.class), + mock(AcademicStructureService.class) + ); + AcademicCalendarDto request = new AcademicCalendarDto( + null, + null, + 1L, + null, + null, + null, + 2L, + null, + null, + 3L, + null, + 4L, + null, + 4 + ); + + var response = controller.createCalendar(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class); + assertThat(((AcademicCalendarDto) response.getBody()).title()) + .isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027"); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java index 9b8af15..31c5596 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java @@ -168,6 +168,18 @@ class AcademicStructureServiceTest { .hasMessage("Нельзя уменьшить количество курсов: есть дисциплины старших семестров"); } + @Test + void buildsCalendarTitleFromDimensionsInsteadOfRequestField() { + AcademicCalendar saved = service.updateCalendar( + CALENDAR_ID, + calendarRequest(4, FORM_ID) + ); + + assertThat(saved.getTitle()) + .isEqualTo("09.03.01 — Без профиля — Бакалавриат — 2025-2026"); + verify(calendarRepository).saveAndFlush(calendar); + } + @Test void rejectsGroupDimensionChangeAndKeepsAssignment() { when(assignmentRepository.findByGroupIdWithDetails(GROUP_ID)) @@ -280,7 +292,7 @@ class AcademicStructureServiceTest { private AcademicCalendarDto calendarRequest(int courseCount, long studyFormId) { return new AcademicCalendarDto( CALENDAR_ID, - "График 2026", + null, YEAR_ID, null, null, diff --git a/backend/src/test/java/com/magistr/app/utils/AcademicCalendarTitleBuilderTest.java b/backend/src/test/java/com/magistr/app/utils/AcademicCalendarTitleBuilderTest.java new file mode 100644 index 0000000..53de2f5 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/utils/AcademicCalendarTitleBuilderTest.java @@ -0,0 +1,40 @@ +package com.magistr.app.utils; + +import com.magistr.app.model.AcademicYear; +import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcademicCalendarTitleBuilderTest { + + @Test + void buildsTitleFromCalendarDimensions() { + AcademicYear year = new AcademicYear(); + year.setTitle(" 2026-2027 "); + Speciality speciality = new Speciality(); + speciality.setSpecialityCode(" 09.03.04 "); + SpecialtyProfile profile = new SpecialtyProfile(); + profile.setName(" Программная инженерия "); + EducationForm studyForm = new EducationForm(); + studyForm.setName(" Очная "); + + assertThat(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm)) + .isEqualTo("09.03.04 — Программная инженерия — Очная — 2026-2027"); + } + + @Test + void rejectsIncompleteDimensions() { + assertThatThrownBy(() -> AcademicCalendarTitleBuilder.build( + new AcademicYear(), + new Speciality(), + new SpecialtyProfile(), + new EducationForm() + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Невозможно сформировать название календарного графика из выбранных параметров"); + } +} diff --git a/docs/API.md b/docs/API.md index 902a417..2cc2f3d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -559,7 +559,6 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 **Тело создания/обновления графика:** ```json { - "title": "09.03.04 — Программная инженерия — Очная — 2025-2026", "academicYearId": 1, "specialtyId": 2, "specialtyProfileId": 3, @@ -570,7 +569,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 `studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. `courseCount` должен быть в диапазоне `1..8`. -Административный frontend формирует `title` автоматически из кода специальности, профиля, формы обучения и учебного года; отдельного поля ручного ввода в интерфейсе нет. +Поле `title` в запросе не требуется и не влияет на результат: backend всегда формирует +название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`. +Отдельного пользовательского названия у календарного графика нет. При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 6b55c56..ac5fb1d 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -153,6 +153,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | | `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар | +Название календарного учебного графика не является отдельным пользовательским атрибутом. +При создании и изменении backend всегда формирует его из кода специальности, названия +профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется. + Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 54cf230..475af37 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -29,6 +29,7 @@ frontend/ │ ├── academic-calendar-grid.test.mjs # Неполная первая неделя календарного графика │ ├── academic-calendar-title.test.mjs # Составное название календарного графика │ ├── academic-calendar-tooltip.test.mjs # Позиционирование подсказки возле курсора и границ viewport +│ ├── admin-ui-regressions.test.mjs # Заголовки, меню, действия слотов и компоновка вкладок │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки @@ -148,6 +149,9 @@ frontend/ Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели. Пункты меню, недоступные роли, не отображаются в sidebar: `main.js` выставляет `hidden`, а `layout.css` явно скрывает такие элементы, чтобы базовый `display: flex` у `.nav-item` не возвращал их на экран. +Верхние панели основной админки и settings SPA не содержат неработающей кнопки +бургер-меню. В светлой теме пункты и заголовки sidebar получают контрастные тёмные цвета +для всех ролей, включая `DEPARTMENT`. ### Разделы админ-панели @@ -178,13 +182,13 @@ frontend/ - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. -- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; в дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. -- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Отдельного поля названия у календарного графика нет: frontend формирует его как `код специальности — профиль обучения — форма обучения — учебный год`, показывает одинаково в таблицах и селектах и отправляет вычисленное значение в `title` для совместимости с API. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. +- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. @@ -374,6 +378,7 @@ CSS-переменные позволяют поддерживать светл - Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий. - Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке. - Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления. +- Каждый вариант `btn`, включая `primary`, `ghost`, `danger` и disabled-состояние, сохраняет видимую обводку в светлой и тёмной теме. - Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`. - Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 6378149..3b0104f 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -1219,6 +1219,18 @@ body.schedule-override-open { background: var(--bg-input); } +.schedule-view-state-actionable { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.schedule-view-state-reload { + min-width: 200px; +} + .schedule-view-day-tabs { flex-wrap: wrap; justify-content: flex-end; diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 78681cd..ce5f333 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -2,20 +2,24 @@ :root { --button-primary-bg: #8B5CF6; --button-primary-bg-hover: #7C3AED; + --button-primary-border: #C4B5FD; --button-primary-text: #FFFFFF; --button-secondary-bg: rgba(255, 255, 255, 0.04); --button-secondary-bg-hover: rgba(255, 255, 255, 0.08); --button-secondary-border: rgba(255, 255, 255, 0.10); --button-secondary-text: #F8FAFC; --button-ghost-bg-hover: rgba(255, 255, 255, 0.06); + --button-ghost-border: rgba(255, 255, 255, 0.18); --button-danger-bg: #DC2626; --button-danger-bg-hover: #B91C1C; + --button-danger-border: #F87171; --button-danger-subtle-bg: rgba(239, 68, 68, 0.13); --button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20); --button-danger-subtle-border: rgba(239, 68, 68, 0.25); --button-danger-subtle-text: #FECACA; --button-focus-ring: rgba(139, 92, 246, 0.45); --button-disabled-bg: rgba(148, 163, 184, 0.16); + --button-disabled-border: rgba(148, 163, 184, 0.32); --button-disabled-text: #94A3B8; --button-radius: 8px; } @@ -23,20 +27,24 @@ [data-theme="light"] { --button-primary-bg: #4F46E5; --button-primary-bg-hover: #4338CA; + --button-primary-border: #3730A3; --button-primary-text: #FFFFFF; --button-secondary-bg: #FFFFFF; --button-secondary-bg-hover: #F1F5F9; --button-secondary-border: #CBD5E1; --button-secondary-text: #334155; --button-ghost-bg-hover: #F8FAFC; + --button-ghost-border: #CBD5E1; --button-danger-bg: #DC2626; --button-danger-bg-hover: #B91C1C; + --button-danger-border: #991B1B; --button-danger-subtle-bg: #FEF2F2; --button-danger-subtle-bg-hover: #FEE2E2; --button-danger-subtle-border: #FECACA; --button-danger-subtle-text: #B91C1C; --button-focus-ring: rgba(79, 70, 229, 0.35); --button-disabled-bg: #CBD5E1; + --button-disabled-border: #94A3B8; --button-disabled-text: #64748B; } @@ -755,6 +763,12 @@ grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; } +.schedule-slot-remove { + min-width: 125px; + min-height: 44px; + padding-inline: 1rem; +} + .schedule-conflict-modal { z-index: 1200; padding: 0.35rem; @@ -3070,7 +3084,7 @@ input[type="number"] { justify-content: center; gap: 0.45rem; min-width: 0; - border: 1px solid transparent; + border: 1px solid var(--button-secondary-border); border-radius: var(--button-radius); background: transparent; color: inherit; @@ -3107,7 +3121,7 @@ input[type="number"] { .btn:disabled, .btn[aria-disabled="true"] { background: var(--button-disabled-bg); - border-color: transparent; + border-color: var(--button-disabled-border); color: var(--button-disabled-text); cursor: not-allowed; opacity: 0.75; @@ -3168,7 +3182,7 @@ input[type="number"] { .btn-primary { background: var(--button-primary-bg); - border-color: transparent; + border-color: var(--button-primary-border); color: var(--button-primary-text); box-shadow: none; } @@ -3192,7 +3206,7 @@ input[type="number"] { .btn-ghost { background: transparent; - border-color: transparent; + border-color: var(--button-ghost-border); color: var(--text-secondary); } @@ -3203,7 +3217,7 @@ input[type="number"] { .btn-danger { background: var(--button-danger-bg); - border-color: transparent; + border-color: var(--button-danger-border); color: #FFFFFF; } diff --git a/frontend/admin/css/layout.css b/frontend/admin/css/layout.css index 8bf7322..bcfa5cc 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -64,6 +64,10 @@ overflow: hidden; } +[data-theme="light"] .sidebar .nav-item { + color: #334155; +} + .nav-item[hidden], .settings-menu-item[hidden] { display: none !important; @@ -486,6 +490,19 @@ display: none !important; } +.sidebar-section-title { + padding: 0.5rem 1.25rem 0.25rem; + color: color-mix(in srgb, var(--text-secondary) 72%, transparent); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +[data-theme="light"] .sidebar-section-title { + color: #64748b !important; +} + .sidebar-section-divider { height: 1px; background: rgba(229, 231, 235, 0.1); diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 000ab0b..d57df64 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -207,14 +207,6 @@
-

Загрузка...

diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index f554495..81487f5 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -94,7 +94,7 @@ const ROUTES = { } }, schedule: { - title: 'Расписание занятий', + title: 'Конструктор правил', file: '/admin/views/schedule.html', init: async () => { const module = await import('./views/schedule.js'); @@ -127,7 +127,6 @@ const pageTitle = document.getElementById('page-title'); const navItems = document.querySelectorAll('.nav-item[data-tab]'); const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); -const menuToggle = document.getElementById('menu-toggle'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const btnLogout = document.getElementById('btn-logout'); const main = document.querySelector('.main'); @@ -148,18 +147,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr main.classList.add('sidebar-collapsed'); } -// Menu Toggle (Hamburger) -menuToggle.addEventListener('click', () => { - if (window.innerWidth <= 768) { - sidebar.classList.toggle('open'); - sidebarOverlay.classList.toggle('open'); - } else { - sidebar.classList.remove('collapsed'); - main.classList.remove('sidebar-collapsed'); - localStorage.setItem('sidebar-collapsed', 'false'); - } -}); - // Sidebar Close (X button) sidebarCloseBtn?.addEventListener('click', () => { if (window.innerWidth <= 768) { diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index b45a12a..da0d668 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -448,26 +448,25 @@ export async function initAcademicCalendar() { } async function loadCalendars() { - calendarsTbody.innerHTML = 'Загрузка...'; + calendarsTbody.innerHTML = 'Загрузка...'; try { calendars = await api.get('/api/admin/academic-calendars'); renderCalendars(); populateEditorCalendarSelect(); populateSubjectCalendarSelect(); } catch (error) { - calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; } } function renderCalendars() { if (!calendars.length) { - calendarsTbody.innerHTML = 'Календарные графики не созданы'; + calendarsTbody.innerHTML = 'Календарные графики не созданы'; return; } calendarsTbody.innerHTML = calendars.map(calendar => ` - ${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')} ${escapeHtml(calendar.academicYearTitle || '-')} ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} ${escapeHtml(calendar.specialtyProfileName || '-')} @@ -488,7 +487,6 @@ export async function initAcademicCalendar() { hideAlert('academic-calendar-alert'); const payload = { id: calendarIdInput.value ? Number(calendarIdInput.value) : null, - title: buildCalendarTitleFromForm(), academicYearId: Number(calendarYearSelect.value), specialtyId: Number(calendarSpecialtySelect.value), specialtyProfileId: Number(calendarProfileSelect.value), @@ -496,7 +494,7 @@ export async function initAcademicCalendar() { courseCount: Number(calendarCourseCountInput.value) }; try { - if (!payload.title || !payload.academicYearId || !payload.specialtyId + if (!payload.academicYearId || !payload.specialtyId || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { throw new Error('Заполните все поля календарного графика'); } @@ -504,7 +502,7 @@ export async function initAcademicCalendar() { const saved = id ? await api.put('/api/admin/academic-calendars/' + id, payload) : await api.post('/api/admin/academic-calendars', payload); - const savedTitle = buildAcademicCalendarTitle(saved) || payload.title; + const savedTitle = buildAcademicCalendarTitle(saved); showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success'); resetCalendarForm(); await loadCalendars(); @@ -1126,9 +1124,13 @@ export async function initAcademicCalendar() { viewportHeight: window.innerHeight }); calendarDayTooltipPositionAnimation?.cancel(); + const positionedTransform = `translate3d(${left}px, ${top}px, 0) scale(1)`; calendarDayTooltipPositionAnimation = tooltip.animate( - [{ left: `${left}px`, top: `${top}px` }], - { duration: 0, fill: 'forwards' } + [ + { transform: positionedTransform }, + { transform: positionedTransform } + ], + { duration: 1, fill: 'forwards' } ); } @@ -1515,27 +1517,6 @@ export async function initAcademicCalendar() { || ''; } - 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/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 500b58f..85b80a1 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -37,6 +37,7 @@ const CELL_PARITY_LAYOUT = [ ]; const MOBILE_QUERY = '(max-width: 760px)'; const DEFAULT_SEMESTER_WEEKS = 22; +const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание'; export async function initScheduleView() { const session = getSession(); @@ -89,7 +90,7 @@ export async function initScheduleView() { targetSelect?.addEventListener('change', () => { clearInactiveEntityValues(); updateEntityFieldVisibility(); - resetResults('Нажмите «Показать», чтобы обновить расписание'); + resetResults(SCHEDULE_REFRESH_PROMPT); }); loadButton?.addEventListener('click', loadData); previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); @@ -100,16 +101,21 @@ export async function initScheduleView() { nextPeriodButton?.addEventListener('click', () => movePeriod(1)); dateInput?.addEventListener('change', () => { updatePeriodLabel(); - resetResults('Нажмите «Показать», чтобы обновить расписание'); + resetResults(SCHEDULE_REFRESH_PROMPT); }); semesterSelect?.addEventListener('change', () => { synchronizeDateWithSemester(); updatePeriodLabel(); - resetResults('Нажмите «Показать», чтобы обновить расписание'); + resetResults(SCHEDULE_REFRESH_PROMPT); }); resultPreviousButton?.addEventListener('click', () => moveResult(-1)); resultNextButton?.addEventListener('click', () => moveResult(1)); tables?.addEventListener('click', event => { + const reloadButton = event.target.closest('[data-schedule-view-reload]'); + if (reloadButton) { + loadData(); + return; + } const button = event.target.closest('[data-schedule-override-edit]'); if (!button) return; const lesson = state.lessons.find(item => @@ -743,9 +749,20 @@ export async function initScheduleView() { `; } - function setTableState(message) { + function setTableState(message, showReloadButton = false) { if (!tables) return; - tables.innerHTML = `
${escapeHtml(message)}
`; + tables.innerHTML = ` +
+ ${escapeHtml(message)} + ${showReloadButton ? ` + + ` : ''} +
+ `; } function resetResults(message) { @@ -757,7 +774,7 @@ export async function initScheduleView() { renderResultNavigation(); tableTitle.textContent = 'Расписание'; tableCaption.textContent = message; - setTableState(message); + setTableState(message, message === SCHEDULE_REFRESH_PROMPT); countLabel.textContent = '0 занятий'; dayTabs.hidden = true; dayTabs.innerHTML = ''; diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index fb274b8..129ab2c 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -30,6 +30,10 @@ const SEMESTER_LABELS = { const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__'; const ACADEMIC_HOURS_PER_SLOT = 2; +export function scheduleSlotActionLabel(slotCount) { + return slotCount === 1 ? 'Очистить' : 'Удалить'; +} + const LESSON_TYPE_LIMITS = [ { key: 'lecture', @@ -1364,7 +1368,11 @@ export async function initSchedule() { } function renderRuleSlots(slots) { - ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` + const normalizedSlots = slots?.length ? slots : [{}]; + const isSingleSlot = normalizedSlots.length === 1; + const actionLabel = scheduleSlotActionLabel(normalizedSlots.length); + const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger'; + ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
@@ -1374,7 +1382,7 @@ export async function initSchedule() {
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
- +
`).join(''); } diff --git a/frontend/admin/settings/index.html b/frontend/admin/settings/index.html index e1aa02b..3ffd53b 100644 --- a/frontend/admin/settings/index.html +++ b/frontend/admin/settings/index.html @@ -90,14 +90,6 @@
-

Загрузка...

diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index a3fe82a..158bc8f 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -60,7 +60,6 @@ const pageTitle = document.getElementById('page-title'); const navItems = document.querySelectorAll('.nav-item[data-tab]'); const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); -const menuToggle = document.getElementById('menu-toggle'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const main = document.querySelector('.main'); @@ -70,18 +69,6 @@ if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'tr main.classList.add('sidebar-collapsed'); } -// Кнопка меню -menuToggle.addEventListener('click', () => { - if (window.innerWidth <= 768) { - sidebar.classList.toggle('open'); - sidebarOverlay.classList.toggle('open'); - } else { - sidebar.classList.remove('collapsed'); - main.classList.remove('sidebar-collapsed'); - localStorage.setItem('sidebar-collapsed', 'false'); - } -}); - // Сворачивание боковой панели sidebarCloseBtn?.addEventListener('click', () => { if (window.innerWidth <= 768) { diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index e3b47e3..fd9e010 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -137,7 +137,6 @@ - @@ -148,7 +147,7 @@ - +
Название Учебный год Специальность Профиль
Загрузка...Загрузка...
diff --git a/frontend/tests/admin-ui-regressions.test.mjs b/frontend/tests/admin-ui-regressions.test.mjs new file mode 100644 index 0000000..134d873 --- /dev/null +++ b/frontend/tests/admin-ui-regressions.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { scheduleSlotActionLabel } from '../admin/js/views/schedule.js'; + +const readFrontendFile = relativePath => readFile( + new URL(`../${relativePath}`, import.meta.url), + 'utf8' +); + +test('одиночный слот очищается, а несколько слотов удаляются', () => { + assert.equal(scheduleSlotActionLabel(1), 'Очистить'); + assert.equal(scheduleSlotActionLabel(2), 'Удалить'); + assert.equal(scheduleSlotActionLabel(5), 'Удалить'); +}); + +test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => { + const files = await Promise.all([ + readFrontendFile('admin/index.html'), + readFrontendFile('admin/js/main.js'), + readFrontendFile('admin/settings/index.html'), + readFrontendFile('admin/settings/js/main.js') + ]); + + files.forEach(content => assert.doesNotMatch(content, /menu-toggle/)); +}); + +test('заголовок вкладки соответствует конструктору правил', async () => { + const mainScript = await readFrontendFile('admin/js/main.js'); + assert.match(mainScript, /schedule:\s*\{\s*title:\s*'Конструктор правил'/); +}); + +test('основная кнопка показа находится в заголовке блока параметров', async () => { + const scheduleView = await readFrontendFile('admin/views/schedule-view.html'); + const controlHeaderIndex = scheduleView.indexOf('schedule-view-control-header'); + const controlGridIndex = scheduleView.indexOf('schedule-view-control-grid'); + const loadButtonIndex = scheduleView.indexOf('id="schedule-view-load"'); + + assert.ok(controlHeaderIndex >= 0); + assert.ok(loadButtonIndex > controlHeaderIndex); + assert.ok(loadButtonIndex < controlGridIndex); + assert.equal(scheduleView.match(/id="schedule-view-load"/g)?.length, 1); +}); + +test('пустое состояние расписания содержит собственную кнопку обновления', async () => { + const scheduleScript = await readFrontendFile('admin/js/views/schedule-view.js'); + + assert.match(scheduleScript, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/); + assert.match(scheduleScript, /data-schedule-view-reload/); + assert.match(scheduleScript, /closest\('\[data-schedule-view-reload\]'\)[\s\S]*?loadData\(\)/); + assert.match(scheduleScript, /setTableState\(message,\s*message === SCHEDULE_REFRESH_PROMPT\)/); +}); + +test('действия слотов имеют размер соседнего поля', async () => { + const [components, scheduleScript] = await Promise.all([ + readFrontendFile('admin/css/components.css'), + readFrontendFile('admin/js/views/schedule.js') + ]); + + assert.match(components, /\.schedule-slot-remove\s*\{[^}]*min-width:\s*125px[^}]*min-height:\s*44px/s); + assert.match(scheduleScript, /class="btn btn-md \$\{actionClass\} schedule-slot-remove"/); +}); + +test('все варианты системных кнопок имеют видимую обводку', async () => { + const components = await readFrontendFile('admin/css/components.css'); + + assert.match(components, /\.btn\s*\{[^}]*border:\s*1px solid var\(--button-secondary-border\)/s); + assert.match(components, /\.btn-primary\s*\{[^}]*border-color:\s*var\(--button-primary-border\)/s); + assert.match(components, /\.btn-ghost\s*\{[^}]*border-color:\s*var\(--button-ghost-border\)/s); + assert.match(components, /\.btn-danger\s*\{[^}]*border-color:\s*var\(--button-danger-border\)/s); +}); + +test('подсказка календарной сетки получает позицию напрямую от курсора', async () => { + const calendarScript = await readFrontendFile('admin/js/views/academic-calendar.js'); + + assert.match(calendarScript, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/); + assert.match(calendarScript, /\{ duration: 1, fill: 'forwards' \}/); + assert.doesNotMatch(calendarScript, /\.style\.(?:left|top)\s*=/); +}); + +test('светлая тема задаёт контрастные пункты и заголовки левого меню', async () => { + const [layout, index] = await Promise.all([ + readFrontendFile('admin/css/layout.css'), + readFrontendFile('admin/index.html') + ]); + + assert.match(layout, /\[data-theme="light"\] \.sidebar \.nav-item\s*\{[^}]*color:\s*#334155/s); + assert.match(layout, /\[data-theme="light"\] \.sidebar-section-title\s*\{[^}]*color:\s*#64748b !important/s); + assert.match(index, />Расписание<\/div>/); +}); + +test('таблица календарных графиков не содержит отдельный столбец названия', async () => { + const [calendarView, calendarScript] = await Promise.all([ + readFrontendFile('admin/views/academic-calendar.html'), + readFrontendFile('admin/js/views/academic-calendar.js') + ]); + const bodyIndex = calendarView.indexOf('id="academic-calendars-tbody"'); + const tableStart = calendarView.lastIndexOf('', bodyIndex); + const tableEnd = calendarView.indexOf('
', bodyIndex); + const calendarTable = calendarView.slice(tableStart, tableEnd); + + assert.doesNotMatch(calendarTable, /Название<\/th>/); + assert.match(calendarTable, /colspan="6"/); + assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/); +});