diff --git a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java index a107358..d8ac277 100644 --- a/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java +++ b/backend/src/main/java/com/magistr/app/controller/DepartmentWorkspaceController.java @@ -4,9 +4,11 @@ import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.dto.CreateSubjectRequest; import com.magistr.app.dto.SubjectCommentDto; +import com.magistr.app.dto.UserResponse; import com.magistr.app.model.*; import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; import org.springframework.format.annotation.DateTimeFormat; @@ -14,6 +16,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -26,15 +29,18 @@ public class DepartmentWorkspaceController { private final SubjectRepository subjectRepository; private final SubjectCommentRepository subjectCommentRepository; private final UserRepository userRepository; + private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository; private final ScheduleQueryService scheduleQueryService; public DepartmentWorkspaceController(SubjectRepository subjectRepository, SubjectCommentRepository subjectCommentRepository, UserRepository userRepository, + TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository, ScheduleQueryService scheduleQueryService) { this.subjectRepository = subjectRepository; this.subjectCommentRepository = subjectCommentRepository; this.userRepository = userRepository; + this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository; this.scheduleQueryService = scheduleQueryService; } @@ -109,9 +115,25 @@ public class DepartmentWorkspaceController { } @GetMapping("/teachers") - public List getTeachers(@RequestParam(required = false) Long departmentId) { + public List getTeachers(@RequestParam(required = false) Long departmentId) { Long effectiveDepartmentId = effectiveDepartmentId(departmentId); - return userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED); + if (effectiveDepartmentId == null) { + return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream() + .map(this::toUserResponse) + .toList(); + } + + Map teachersById = new LinkedHashMap<>(); + teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now()) + .stream() + .map(TeacherDepartmentAssignment::getTeacher) + .filter(User::isActiveRecord) + .forEach(teacher -> teachersById.put(teacher.getId(), teacher)); + userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED) + .forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher)); + return teachersById.values().stream() + .map(this::toUserResponse) + .toList(); } @GetMapping("/schedule") @@ -137,6 +159,19 @@ public class DepartmentWorkspaceController { ); } + private UserResponse toUserResponse(User user) { + UserResponse response = new UserResponse( + user.getId(), + user.getUsername(), + user.getRole().name(), + user.getFullName(), + user.getJobTitle(), + user.getDepartmentId() + ); + response.setStatus(user.getStatus()); + return response; + } + private Long effectiveDepartmentId(Long requestedDepartmentId) { var user = AuthContext.getCurrentUser(); if (user != null && user.role() == Role.DEPARTMENT) { diff --git a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java index 37824a1..9f281e9 100644 --- a/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/DepartmentWorkspaceControllerTest.java @@ -2,15 +2,23 @@ package com.magistr.app.controller; import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthenticatedUser; +import com.magistr.app.dto.UserResponse; +import com.magistr.app.model.Department; +import com.magistr.app.model.LifecycleEntity; import com.magistr.app.model.Role; import com.magistr.app.model.Subject; +import com.magistr.app.model.TeacherDepartmentAssignment; +import com.magistr.app.model.User; import com.magistr.app.repository.SubjectCommentRepository; import com.magistr.app.repository.SubjectRepository; +import com.magistr.app.repository.TeacherDepartmentAssignmentRepository; import com.magistr.app.repository.UserRepository; import com.magistr.app.service.ScheduleQueryService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -35,6 +43,7 @@ class DepartmentWorkspaceControllerTest { subjectRepository, mock(SubjectCommentRepository.class), mock(UserRepository.class), + mock(TeacherDepartmentAssignmentRepository.class), mock(ScheduleQueryService.class) ); AuthContext.setCurrentUser(new AuthenticatedUser(1L, "кафедра", Role.DEPARTMENT, 2L)); @@ -43,4 +52,48 @@ class DepartmentWorkspaceControllerTest { assertThat(response.getStatusCode().value()).isEqualTo(404); } + + @Test + void teachersUseAssignmentsAndFallbackWithoutDuplicates() { + UserRepository userRepository = mock(UserRepository.class); + TeacherDepartmentAssignmentRepository assignmentRepository = mock(TeacherDepartmentAssignmentRepository.class); + User assignedTeacher = user(10L, "assigned", "Петров Препод Петрович", 2L); + User directTeacher = user(11L, "direct", "Препод Тест Тестович", 1L); + when(assignmentRepository.findDepartmentTeachersAtDate(1L, LocalDate.now())) + .thenReturn(List.of(assignment(assignedTeacher, 1L))); + when(userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, 1L, LifecycleEntity.STATUS_ARCHIVED)) + .thenReturn(List.of(directTeacher, assignedTeacher)); + DepartmentWorkspaceController controller = new DepartmentWorkspaceController( + mock(SubjectRepository.class), + mock(SubjectCommentRepository.class), + userRepository, + assignmentRepository, + mock(ScheduleQueryService.class) + ); + + List teachers = controller.getTeachers(1L); + + assertThat(teachers).extracting(UserResponse::getFullName) + .containsExactly("Петров Препод Петрович", "Препод Тест Тестович"); + } + + private TeacherDepartmentAssignment assignment(User teacher, Long departmentId) { + Department department = new Department(departmentId, "Кафедра", departmentId); + TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment(); + assignment.setTeacher(teacher); + assignment.setDepartment(department); + assignment.setValidFrom(LocalDate.now()); + return assignment; + } + + private User user(Long id, String username, String fullName, Long departmentId) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setRole(Role.TEACHER); + user.setFullName(fullName); + user.setJobTitle("Доцент"); + user.setDepartmentId(departmentId); + return user; + } } diff --git a/docs/API.md b/docs/API.md index a09293e..faa2506 100644 --- a/docs/API.md +++ b/docs/API.md @@ -541,6 +541,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 | `GET` | `/api/department/teachers` | Преподаватели кафедры | | `GET` | `/api/department/schedule` | Расписание кафедры | +`GET /api/department/teachers` возвращает актуальных преподавателей кафедры по `teacher_department_assignments` и дополнительно учитывает старую привязку `users.department_id`, чтобы не терять преподавателей без записи в истории назначений. + --- ## Кафедры и специальности diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7792a3d..7b9c1d7 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -147,12 +147,13 @@ frontend/ ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. -- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. +- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 9722ab7..c3cb0e9 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -582,6 +582,12 @@ animation-duration: 0.28s; } +.schedule-view-results-card { + display: flex; + flex-direction: column; + gap: 1rem; +} + .schedule-view-control-header, .schedule-view-results-header, .schedule-view-table-header { @@ -589,6 +595,7 @@ align-items: flex-start; justify-content: space-between; gap: 1rem; + flex-wrap: wrap; } .schedule-view-control-header h2, @@ -684,10 +691,11 @@ .schedule-view-result-tabs { display: flex; + flex-wrap: wrap; gap: 0.55rem; - margin-top: 1rem; + margin-top: 0; padding-bottom: 0.1rem; - overflow-x: auto; + overflow-x: visible; } .schedule-view-result-tab { diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c1864b6..96fc982 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -194,10 +194,6 @@ color: var(--text-secondary); } -.department-workload-list { - margin-top: 1rem; -} - .department-comment-list { display: grid; gap: 0.75rem; @@ -262,6 +258,8 @@ .schedule-slots-panel { margin-top: 1rem; + margin-bottom: 1.25rem; + min-width: 0; } .schedule-slots-list { @@ -269,6 +267,7 @@ flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; + overflow: visible; } .schedule-slot-row { @@ -282,10 +281,19 @@ border-radius: var(--radius-sm); } +.schedule-slot-row:last-child { + margin-bottom: 0; +} + .schedule-slot-row.has-subgroup { grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; } +.schedule-rule-actions { + margin-top: 0.25rem; + clear: both; +} + .slot-subgroup-multi .select-text { overflow: hidden; text-overflow: ellipsis; @@ -780,11 +788,15 @@ align-items: stretch; gap: 0.65rem; padding: 0.65rem; + overflow-x: hidden; + overflow-y: hidden; } .calendar-semester-block { display: flex; flex-direction: column; + width: 100%; + max-width: 100%; height: auto; min-height: 0; border: 1px solid var(--bg-card-border); @@ -830,9 +842,14 @@ } .calendar-semester-table { - width: max-content; - min-width: 100%; + --calendar-day-head-width: 32px; + --calendar-day-cell-size: 22px; + --calendar-min-table-width: 538px; + width: 100%; + min-width: var(--calendar-min-table-width); + max-width: none; min-height: 100%; + flex: 1 1 auto; border-collapse: collapse; table-layout: fixed; font-size: 0.62rem; @@ -841,32 +858,43 @@ .calendar-semester-table th, .calendar-semester-table td { + box-sizing: border-box; border: 1px solid var(--bg-card-border); } .calendar-semester-table tbody tr { - height: 19px; + height: var(--calendar-day-cell-size); +} + +.calendar-day-head-col { + width: var(--calendar-day-head-width); +} + +.calendar-week-col { + width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1)); } .calendar-semester-table thead th { position: sticky; top: 0; z-index: 1; - height: 18px; - min-width: 18px; - padding: 0 0.12rem; + height: var(--calendar-day-cell-size); + padding: 0; background: var(--bg-input); color: var(--text-secondary); font-weight: 700; text-align: center; + line-height: 1; + white-space: nowrap; } .calendar-semester-table .calendar-day-head { position: sticky; left: 0; z-index: 2; - width: 30px; - min-width: 30px; + width: var(--calendar-day-head-width); + min-width: var(--calendar-day-head-width); + max-width: var(--calendar-day-head-width); background: var(--bg-input); color: var(--text-secondary); font-weight: 700; @@ -875,10 +903,8 @@ .calendar-day { position: relative; - width: 18px; - min-width: 18px; - height: 19px; - min-height: 19px; + height: var(--calendar-day-cell-size); + min-height: var(--calendar-day-cell-size); padding: 0; background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card)); border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); @@ -986,30 +1012,36 @@ } .calendar-day-tooltip-code { - width: 34px; - min-width: 34px; - height: 34px; - box-sizing: border-box; + width: 34px; + min-width: 34px; + height: 34px; + box-sizing: border-box; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent); + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 800; + line-height: 1; + padding: 0; + text-align: center; +} - display: inline-grid; - place-items: center; - - border-radius: 8px; - background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); - border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent); - color: var(--text-primary); - - font-size: 0.86rem; - font-weight: 800; - line-height: 1; - - padding: 0; - text-align: center; +.calendar-day-tooltip-head .calendar-day-tooltip-code { + display: inline-grid; + place-items: center; } .calendar-day-tooltip-code span { - display: block; - transform: translateY(2px); + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: inherit; + font: inherit; + line-height: 1; + transform: none; } .calendar-day-tooltip-body { @@ -1019,12 +1051,14 @@ } .calendar-empty-day { - width: 18px; - min-width: 18px; - height: 19px; + height: var(--calendar-day-cell-size); background: color-mix(in srgb, var(--bg-input) 65%, transparent); } +.calendar-week-placeholder { + color: transparent; +} + .calendar-empty-semester { flex: 1; display: flex; @@ -1198,6 +1232,8 @@ .calendar-semesters-grid { grid-template-columns: 1fr; grid-auto-rows: auto; + overflow-x: auto; + scrollbar-width: thin; } .calendar-semester-block { diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index c3df8be..e9e8404 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -580,39 +580,56 @@ export async function initAcademicCalendar() { function renderCourseSemesters(course, courseRows) { const calendar = selectedEditorCalendar(); const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); + const maxWeekColumns = Math.max( + ...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length), + 1 + ); return semestersForCourse.map(block => `

${block.semesterNumber} семестр

${escapeHtml(block.caption)}
- ${renderSemesterTable(block.rows)} + ${renderSemesterTable(block.rows, maxWeekColumns)}
`).join(''); } - function renderSemesterTable(rows) { + function renderSemesterTable(rows, targetWeekColumns = 0) { if (!rows.length) { return '
Нет дат в этом семестре
'; } const rowsByWeek = groupBy(rows, row => row.weekNumber); const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); + const weekSlots = [...weeks]; + while (weekSlots.length < targetWeekColumns) { + weekSlots.push(null); + } const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); return `
- +
+ + + ${weekSlots.map(() => '').join('')} + - ${weeks.map(week => ``).join('')} + ${weekSlots.map(week => week === null + ? '' + : ``).join('')} ${DAY_OPTIONS.map(day => ` - ${weeks.map(week => { + ${weekSlots.map(week => { + if (week === null) { + return ''; + } const row = cellByWeekAndDay.get(`${week}:${day.value}`); return row ? renderCalendarDay(row) : ''; }).join('')} @@ -624,6 +641,10 @@ export async function initAcademicCalendar() { `; } + function uniqueWeekNumbers(rows) { + return Array.from(new Set(rows.map(row => row.weekNumber))); + } + function renderCalendarDay(row) { const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); const dayName = shortDayLabel(row.dayOfWeek); diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js index 36ebb97..ab9208a 100644 --- a/frontend/admin/js/views/department-workspace.js +++ b/frontend/admin/js/views/department-workspace.js @@ -21,11 +21,15 @@ export async function initDepartmentWorkspace() { const commentsSubjectInput = document.getElementById('department-comment-subject-id'); const commentsTextInput = document.getElementById('department-comment-text'); const commentsCloseButton = document.getElementById('department-comments-close'); + let lastTeachers = null; + let lastWorkload = null; + let teachersErrorMessage = ''; + let workloadErrorMessage = ''; setDefaultDates(startInput, endInput); refreshButton?.addEventListener('click', loadWorkspace); departmentSelect?.addEventListener('change', loadWorkspace); - startInput?.addEventListener('change', loadWorkload); + startInput?.addEventListener('change', handleStartDateChange); endInput?.addEventListener('change', loadWorkload); importForm?.addEventListener('submit', importSubjects); commentsForm?.addEventListener('submit', saveComment); @@ -53,6 +57,11 @@ export async function initDepartmentWorkspace() { async function loadWorkspace() { hideAlert('department-workspace-alert'); + lastTeachers = null; + lastWorkload = null; + teachersErrorMessage = ''; + workloadErrorMessage = ''; + renderTeachers(); await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]); } @@ -172,25 +181,28 @@ export async function initDepartmentWorkspace() { } async function loadTeachers() { - const container = document.getElementById('department-teachers-list'); - container.textContent = 'Загрузка...'; + lastTeachers = null; + teachersErrorMessage = ''; + renderTeachers(); try { const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`); - container.innerHTML = teachers.length - ? teachers.map(teacher => ` -
- ${escapeHtml(teacher.fullName || teacher.username || '-')} - ${escapeHtml(teacher.jobTitle || 'Должность не указана')} -
- `).join('') - : '
Преподаватели не найдены
'; + lastTeachers = Array.isArray(teachers) ? teachers : []; + renderTeachers(); } catch (error) { - container.innerHTML = `
${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}
`; + lastTeachers = []; + teachersErrorMessage = error.message || 'Ошибка загрузки преподавателей'; + renderTeachers(); } } async function loadWorkload() { - const container = document.getElementById('department-workload-list'); + if (!startInput.value || !endInput.value) { + lastWorkload = []; + workloadErrorMessage = 'Выберите начало и окончание периода'; + renderTeachers(); + return; + } + const params = new URLSearchParams({ startDate: startInput.value, endDate: endInput.value @@ -198,23 +210,84 @@ export async function initDepartmentWorkspace() { if (selectedDepartmentId()) { params.set('departmentId', selectedDepartmentId()); } - container.textContent = 'Загрузка...'; + lastWorkload = null; + workloadErrorMessage = ''; + renderTeachers(); try { const workload = await api.get(`/api/workload/teachers?${params}`); - container.innerHTML = workload.length - ? workload.map(item => ` -
- ${escapeHtml(item.name || '-')} - ${escapeHtml(String(item.academicHours ?? 0))} ак. ч. - ${escapeHtml(String(item.lessonCount ?? 0))} занятий -
- `).join('') - : '
Нагрузка не найдена
'; + lastWorkload = Array.isArray(workload) ? workload : []; + renderTeachers(); } catch (error) { - container.innerHTML = `
${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}
`; + lastWorkload = []; + workloadErrorMessage = error.message || 'Ошибка расчёта нагрузки'; + renderTeachers(); } } + async function handleStartDateChange() { + if (startInput.value && endInput.value && startInput.value > endInput.value) { + endInput.value = ''; + } + await loadWorkload(); + } + + function renderTeachers() { + const container = document.getElementById('department-teachers-list'); + if (!container) return; + + if (lastTeachers === null || lastWorkload === null) { + container.textContent = 'Загрузка...'; + return; + } + + const teachers = Array.isArray(lastTeachers) ? lastTeachers : []; + const workload = Array.isArray(lastWorkload) ? lastWorkload : []; + const workloadByTeacher = new Map(); + const usedWorkloadKeys = new Set(); + + workload + .filter(item => item && (item.id || item.name)) + .forEach(item => { + workloadKeys(item).forEach(key => { + if (!workloadByTeacher.has(key)) { + workloadByTeacher.set(key, item); + } + }); + }); + + const rows = teachers.map(teacher => { + const workloadItem = workloadKeys(teacher) + .map(key => workloadByTeacher.get(key)) + .find(Boolean); + if (workloadItem) { + workloadKeys(workloadItem).forEach(key => usedWorkloadKeys.add(key)); + } + return renderTeacherMetric({ + name: teacher.fullName || teacher.username || '-', + meta: teacher.jobTitle || 'Должность не указана', + workload: workloadItem + }, workloadErrorMessage); + }); + + workload + .filter(item => item && (item.id || item.name)) + .filter(item => workloadKeys(item).every(key => !usedWorkloadKeys.has(key))) + .forEach(item => { + rows.push(renderTeacherMetric({ + name: item.name || '-', + meta: 'Есть занятия в расписании кафедры', + workload: item + }, workloadErrorMessage)); + }); + + if (rows.length) { + container.innerHTML = rows.join(''); + return; + } + + container.innerHTML = `
${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}
`; + } + function selectedDepartmentId() { return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value; } @@ -261,3 +334,36 @@ function subjectCountLabel(count) { if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`; return `${count} дисциплин`; } + +function renderTeacherMetric(item, workloadErrorMessage) { + return ` +
+ ${escapeHtml(item.name || '-')} + ${escapeHtml(item.meta || 'Должность не указана')} + ${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))} +
+ `; +} + +function workloadLabel(item, workloadErrorMessage) { + if (workloadErrorMessage) { + return workloadErrorMessage; + } + if (!item) { + return '0 ак. ч., 0 занятий за период'; + } + const hours = String(item.academicHours ?? 0); + const lessons = String(item.lessonCount ?? 0); + return `${hours} ак. ч., ${lessons} занятий за период`; +} + +function workloadKeys(item) { + return [ + item.id == null ? null : `id:${item.id}`, + normalizeTeacherName(item.fullName || item.name || item.username) + ].filter(Boolean); +} + +function normalizeTeacherName(value) { + return value ? `name:${String(value).trim().toLowerCase()}` : null; +} diff --git a/frontend/admin/views/department-workspace.html b/frontend/admin/views/department-workspace.html index 781f56e..7539b7b 100644 --- a/frontend/admin/views/department-workspace.html +++ b/frontend/admin/views/department-workspace.html @@ -44,7 +44,6 @@

Преподаватели и нагрузка

Загрузка...
-
Загрузка...
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 49f3f7b..4fc6913 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -84,7 +84,7 @@
-
+
День${week}${week}
${escapeHtml(day.shortLabel)}