поправил вид сетки и прочие баги

This commit is contained in:
2026-05-28 00:47:51 +03:00
parent bbfee068da
commit 5b6ffa5c08
10 changed files with 338 additions and 77 deletions

View File

@@ -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<UserResponse> 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<Long, User> 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) {

View File

@@ -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<UserResponse> 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;
}
}

View File

@@ -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`, чтобы не терять преподавателей без записи в истории назначений.
---
## Кафедры и специальности

View File

@@ -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`; удаление пользователя работает как архивирование.

View File

@@ -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 {

View File

@@ -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));
@@ -990,26 +1016,32 @@
min-width: 34px;
height: 34px;
box-sizing: border-box;
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 {

View File

@@ -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 => `
<section class="calendar-semester-block">
<div class="calendar-semester-header">
<h4>${block.semesterNumber} семестр</h4>
<span>${escapeHtml(block.caption)}</span>
</div>
${renderSemesterTable(block.rows)}
${renderSemesterTable(block.rows, maxWeekColumns)}
</section>
`).join('');
}
function renderSemesterTable(rows) {
function renderSemesterTable(rows, targetWeekColumns = 0) {
if (!rows.length) {
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
}
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 `
<div class="calendar-semester-table-wrap">
<table class="calendar-semester-table">
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
</colgroup>
<thead>
<tr>
<th class="calendar-day-head">День</th>
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
${weekSlots.map(week => week === null
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
</tr>
</thead>
<tbody>
${DAY_OPTIONS.map(day => `
<tr>
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
${weeks.map(week => {
${weekSlots.map(week => {
if (week === null) {
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
}
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
}).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);

View File

@@ -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 => `
<article class="metric-card">
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
lastTeachers = Array.isArray(teachers) ? teachers : [];
renderTeachers();
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
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 => `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
lastWorkload = Array.isArray(workload) ? workload : [];
renderTeachers();
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
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 = `<div class="loading-row">${escapeHtml(teachersErrorMessage || 'Преподаватели не найдены')}</div>`;
}
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 `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(item.meta || 'Должность не указана')}</span>
<small>${escapeHtml(workloadLabel(item.workload, workloadErrorMessage))}</small>
</article>
`;
}
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;
}

View File

@@ -44,7 +44,6 @@
<h2>Преподаватели и нагрузка</h2>
</div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
</div>
</div>

View File

@@ -84,7 +84,7 @@
<div id="schedule-rule-slots" class="schedule-slots-list"></div>
</div>
<div class="form-row">
<div class="form-row schedule-rule-actions">
<button type="submit" class="btn-primary">Сохранить правило</button>
</div>
<div class="form-alert" id="schedule-rule-alert" role="alert"></div>