Compare commits
1 Commits
3e8722a26b
...
Redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78a93f2f0 |
@@ -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);
|
||||
|
||||
@@ -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("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.magistr.app.utils;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class AcademicCalendarTitleBuilder {
|
||||
|
||||
private static final String SEPARATOR = " — ";
|
||||
|
||||
private AcademicCalendarTitleBuilder() {
|
||||
}
|
||||
|
||||
public static String build(AcademicYear academicYear,
|
||||
Speciality speciality,
|
||||
SpecialtyProfile profile,
|
||||
EducationForm studyForm) {
|
||||
List<String> parts = List.of(
|
||||
normalize(speciality == null ? null : speciality.getSpecialityCode()),
|
||||
normalize(profile == null ? null : profile.getName()),
|
||||
normalize(studyForm == null ? null : studyForm.getName()),
|
||||
normalize(academicYear == null ? null : academicYear.getTitle())
|
||||
);
|
||||
if (parts.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Невозможно сформировать название календарного графика из выбранных параметров"
|
||||
);
|
||||
}
|
||||
return String.join(SEPARATOR, parts);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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("Невозможно сформировать название календарного графика из выбранных параметров");
|
||||
}
|
||||
}
|
||||
@@ -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 блокирует график и повторно проверяет
|
||||
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
||||
|
||||
@@ -153,6 +153,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
|
||||
|
||||
Название календарного учебного графика не является отдельным пользовательским атрибутом.
|
||||
При создании и изменении backend всегда формирует его из кода специальности, названия
|
||||
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
|
||||
|
||||
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
||||
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
||||
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
|
||||
|
||||
@@ -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` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -207,14 +207,6 @@
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-title-group">
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -448,26 +448,25 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
calendars = await api.get('/api/admin/academic-calendars');
|
||||
renderCalendars();
|
||||
populateEditorCalendarSelect();
|
||||
populateSubjectCalendarSelect();
|
||||
} catch (error) {
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendars() {
|
||||
if (!calendars.length) {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
|
||||
return;
|
||||
}
|
||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||
<tr>
|
||||
<td>${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</td>
|
||||
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
||||
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
||||
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
||||
@@ -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 || '-';
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const CELL_PARITY_LAYOUT = [
|
||||
];
|
||||
const MOBILE_QUERY = '(max-width: 760px)';
|
||||
const DEFAULT_SEMESTER_WEEKS = 22;
|
||||
const SCHEDULE_REFRESH_PROMPT = 'Нажмите «Показать», чтобы обновить расписание';
|
||||
|
||||
export async function initScheduleView() {
|
||||
const session = getSession();
|
||||
@@ -89,7 +90,7 @@ export async function initScheduleView() {
|
||||
targetSelect?.addEventListener('change', () => {
|
||||
clearInactiveEntityValues();
|
||||
updateEntityFieldVisibility();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
});
|
||||
loadButton?.addEventListener('click', loadData);
|
||||
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
|
||||
@@ -100,16 +101,21 @@ export async function initScheduleView() {
|
||||
nextPeriodButton?.addEventListener('click', () => movePeriod(1));
|
||||
dateInput?.addEventListener('change', () => {
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
});
|
||||
semesterSelect?.addEventListener('change', () => {
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
resetResults(SCHEDULE_REFRESH_PROMPT);
|
||||
});
|
||||
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
|
||||
resultNextButton?.addEventListener('click', () => moveResult(1));
|
||||
tables?.addEventListener('click', event => {
|
||||
const reloadButton = event.target.closest('[data-schedule-view-reload]');
|
||||
if (reloadButton) {
|
||||
loadData();
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('[data-schedule-override-edit]');
|
||||
if (!button) return;
|
||||
const lesson = state.lessons.find(item =>
|
||||
@@ -743,9 +749,20 @@ export async function initScheduleView() {
|
||||
`;
|
||||
}
|
||||
|
||||
function setTableState(message) {
|
||||
function setTableState(message, showReloadButton = false) {
|
||||
if (!tables) return;
|
||||
tables.innerHTML = `<div class="loading-row schedule-view-state">${escapeHtml(message)}</div>`;
|
||||
tables.innerHTML = `
|
||||
<div class="loading-row schedule-view-state${showReloadButton ? ' schedule-view-state-actionable' : ''}">
|
||||
<span>${escapeHtml(message)}</span>
|
||||
${showReloadButton ? `
|
||||
<button type="button"
|
||||
class="btn btn-md btn-primary schedule-view-state-reload"
|
||||
data-schedule-view-reload>
|
||||
Показать расписание
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resetResults(message) {
|
||||
@@ -757,7 +774,7 @@ export async function initScheduleView() {
|
||||
renderResultNavigation();
|
||||
tableTitle.textContent = 'Расписание';
|
||||
tableCaption.textContent = message;
|
||||
setTableState(message);
|
||||
setTableState(message, message === SCHEDULE_REFRESH_PROMPT);
|
||||
countLabel.textContent = '0 занятий';
|
||||
dayTabs.hidden = true;
|
||||
dayTabs.innerHTML = '';
|
||||
|
||||
@@ -30,6 +30,10 @@ const SEMESTER_LABELS = {
|
||||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||||
const ACADEMIC_HOURS_PER_SLOT = 2;
|
||||
|
||||
export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
const LESSON_TYPE_LIMITS = [
|
||||
{
|
||||
key: 'lecture',
|
||||
@@ -1364,7 +1368,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderRuleSlots(slots) {
|
||||
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
|
||||
const normalizedSlots = slots?.length ? slots : [{}];
|
||||
const isSingleSlot = normalizedSlots.length === 1;
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
|
||||
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
|
||||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
@@ -1374,7 +1382,7 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
<button type="button" class="btn btn-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button>
|
||||
<button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -90,14 +90,6 @@
|
||||
<!-- Основная область -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 id="page-title">Загрузка...</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Учебный год</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
@@ -148,7 +147,7 @@
|
||||
</thead>
|
||||
<tbody id="academic-calendars-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
<td colspan="6" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
106
frontend/tests/admin-ui-regressions.test.mjs
Normal file
106
frontend/tests/admin-ui-regressions.test.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { scheduleSlotActionLabel } from '../admin/js/views/schedule.js';
|
||||
|
||||
const readFrontendFile = relativePath => readFile(
|
||||
new URL(`../${relativePath}`, import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('одиночный слот очищается, а несколько слотов удаляются', () => {
|
||||
assert.equal(scheduleSlotActionLabel(1), 'Очистить');
|
||||
assert.equal(scheduleSlotActionLabel(2), 'Удалить');
|
||||
assert.equal(scheduleSlotActionLabel(5), 'Удалить');
|
||||
});
|
||||
|
||||
test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => {
|
||||
const files = await Promise.all([
|
||||
readFrontendFile('admin/index.html'),
|
||||
readFrontendFile('admin/js/main.js'),
|
||||
readFrontendFile('admin/settings/index.html'),
|
||||
readFrontendFile('admin/settings/js/main.js')
|
||||
]);
|
||||
|
||||
files.forEach(content => assert.doesNotMatch(content, /menu-toggle/));
|
||||
});
|
||||
|
||||
test('заголовок вкладки соответствует конструктору правил', async () => {
|
||||
const mainScript = await readFrontendFile('admin/js/main.js');
|
||||
assert.match(mainScript, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
|
||||
});
|
||||
|
||||
test('основная кнопка показа находится в заголовке блока параметров', async () => {
|
||||
const scheduleView = await readFrontendFile('admin/views/schedule-view.html');
|
||||
const controlHeaderIndex = scheduleView.indexOf('schedule-view-control-header');
|
||||
const controlGridIndex = scheduleView.indexOf('schedule-view-control-grid');
|
||||
const loadButtonIndex = scheduleView.indexOf('id="schedule-view-load"');
|
||||
|
||||
assert.ok(controlHeaderIndex >= 0);
|
||||
assert.ok(loadButtonIndex > controlHeaderIndex);
|
||||
assert.ok(loadButtonIndex < controlGridIndex);
|
||||
assert.equal(scheduleView.match(/id="schedule-view-load"/g)?.length, 1);
|
||||
});
|
||||
|
||||
test('пустое состояние расписания содержит собственную кнопку обновления', async () => {
|
||||
const scheduleScript = await readFrontendFile('admin/js/views/schedule-view.js');
|
||||
|
||||
assert.match(scheduleScript, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
|
||||
assert.match(scheduleScript, /data-schedule-view-reload/);
|
||||
assert.match(scheduleScript, /closest\('\[data-schedule-view-reload\]'\)[\s\S]*?loadData\(\)/);
|
||||
assert.match(scheduleScript, /setTableState\(message,\s*message === SCHEDULE_REFRESH_PROMPT\)/);
|
||||
});
|
||||
|
||||
test('действия слотов имеют размер соседнего поля', async () => {
|
||||
const [components, scheduleScript] = await Promise.all([
|
||||
readFrontendFile('admin/css/components.css'),
|
||||
readFrontendFile('admin/js/views/schedule.js')
|
||||
]);
|
||||
|
||||
assert.match(components, /\.schedule-slot-remove\s*\{[^}]*min-width:\s*125px[^}]*min-height:\s*44px/s);
|
||||
assert.match(scheduleScript, /class="btn btn-md \$\{actionClass\} schedule-slot-remove"/);
|
||||
});
|
||||
|
||||
test('все варианты системных кнопок имеют видимую обводку', async () => {
|
||||
const components = await readFrontendFile('admin/css/components.css');
|
||||
|
||||
assert.match(components, /\.btn\s*\{[^}]*border:\s*1px solid var\(--button-secondary-border\)/s);
|
||||
assert.match(components, /\.btn-primary\s*\{[^}]*border-color:\s*var\(--button-primary-border\)/s);
|
||||
assert.match(components, /\.btn-ghost\s*\{[^}]*border-color:\s*var\(--button-ghost-border\)/s);
|
||||
assert.match(components, /\.btn-danger\s*\{[^}]*border-color:\s*var\(--button-danger-border\)/s);
|
||||
});
|
||||
|
||||
test('подсказка календарной сетки получает позицию напрямую от курсора', async () => {
|
||||
const calendarScript = await readFrontendFile('admin/js/views/academic-calendar.js');
|
||||
|
||||
assert.match(calendarScript, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
|
||||
assert.match(calendarScript, /\{ duration: 1, fill: 'forwards' \}/);
|
||||
assert.doesNotMatch(calendarScript, /\.style\.(?:left|top)\s*=/);
|
||||
});
|
||||
|
||||
test('светлая тема задаёт контрастные пункты и заголовки левого меню', async () => {
|
||||
const [layout, index] = await Promise.all([
|
||||
readFrontendFile('admin/css/layout.css'),
|
||||
readFrontendFile('admin/index.html')
|
||||
]);
|
||||
|
||||
assert.match(layout, /\[data-theme="light"\] \.sidebar \.nav-item\s*\{[^}]*color:\s*#334155/s);
|
||||
assert.match(layout, /\[data-theme="light"\] \.sidebar-section-title\s*\{[^}]*color:\s*#64748b !important/s);
|
||||
assert.match(index, />Расписание<\/div>/);
|
||||
});
|
||||
|
||||
test('таблица календарных графиков не содержит отдельный столбец названия', async () => {
|
||||
const [calendarView, calendarScript] = await Promise.all([
|
||||
readFrontendFile('admin/views/academic-calendar.html'),
|
||||
readFrontendFile('admin/js/views/academic-calendar.js')
|
||||
]);
|
||||
const bodyIndex = calendarView.indexOf('id="academic-calendars-tbody"');
|
||||
const tableStart = calendarView.lastIndexOf('<table>', bodyIndex);
|
||||
const tableEnd = calendarView.indexOf('</table>', bodyIndex);
|
||||
const calendarTable = calendarView.slice(tableStart, tableEnd);
|
||||
|
||||
assert.doesNotMatch(calendarTable, /<th>Название<\/th>/);
|
||||
assert.match(calendarTable, /colspan="6"/);
|
||||
assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/);
|
||||
});
|
||||
Reference in New Issue
Block a user