Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule

This commit is contained in:
dipatrik10
2026-05-27 21:26:22 +03:00
67 changed files with 2588 additions and 546 deletions

View File

@@ -146,7 +146,7 @@ frontend/
### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
- Вкладка `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` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
@@ -171,19 +171,24 @@ frontend/
## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`:
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
```javascript
export async function apiFetch(endpoint, method = 'GET', body = null) {
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
headers: getHeaders(body ? 'application/json' : null),
credentials: 'same-origin',
body: body ? JSON.stringify(body) : undefined
});
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) return apiFetch(endpoint, method, body, false);
clearAuthState();
window.location.href = '/';
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
@@ -200,7 +205,7 @@ export const api = {
};
```
Токен берётся из `localStorage.getItem('token')`.
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
---
@@ -211,11 +216,12 @@ export const api = {
1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`:
- `token`UUID-токен
- `token`access JWT
- `role` — роль пользователя
- `departmentId` — кафедра пользователя
- `userId` — ID пользователя для личного расписания преподавателя
4. Перенаправляет на соответствующий интерфейс:
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
5. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace`
@@ -230,13 +236,13 @@ export const api = {
```javascript
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return token && role === 'ADMIN';
return getToken() && role === 'ADMIN';
}
```
### Выход
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
---