diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index 31b8404..1439ce6 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -29,6 +29,7 @@ frontend/
│ │ ├── layout.css # Раскладка (sidebar, topbar, content)
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна
+│ │ ├── auditorium-workload.css # Таблицы загруженности аудиторий
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
@@ -45,6 +46,7 @@ frontend/
│ │ ├── profiles.js # Профили обучения специальностей
│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики
+│ │ ├── auditorium-workload.js # Загруженность аудиторий
│ │ ├── database.js # Управление тенантами
│ │ └── departments-data.js # Создание кафедры/специальности
│ ├── views/ # HTML-шаблоны представлений
@@ -57,6 +59,7 @@ frontend/
│ │ ├── profiles.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
+│ │ ├── auditorium-workload.html
│ │ ├── database.html
│ │ └── departments-data.html
│ │
@@ -114,14 +117,14 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` |
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
-| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` |
+| `auditorium-workload` | Динамическая загруженность аудиторий: сводная матрица по дате или совмещённая таблица выбранной аудитории по чётной/нечётной неделе | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
| `database` | Тенанты | `/api/database` |
| `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` |
### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
-- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
+- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. В поле «Отображение» можно выбрать конкретную аудиторию; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной аудитории растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css
index eb01efb..584aa8f 100644
--- a/frontend/admin/css/auditorium-workload.css
+++ b/frontend/admin/css/auditorium-workload.css
@@ -9,6 +9,45 @@
position: relative;
}
+.workload-filter-row {
+ flex-wrap: wrap;
+}
+
+.workload-display-control {
+ flex: 1 1 260px;
+ min-width: 240px;
+}
+
+.workload-display-select {
+ width: 100%;
+ min-height: 38px;
+ padding: 0.45rem 2.25rem 0.45rem 1rem;
+ background: var(--bg-input);
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.85rem;
+ outline: none;
+ transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
+}
+
+.workload-display-select:hover {
+ background: var(--bg-hover);
+}
+
+.workload-display-select:focus {
+ background: var(--bg-input-focus);
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+}
+
+.workload-overview-filter[hidden],
+#workload-overview-block[hidden],
+#room-workload-block[hidden] {
+ display: none !important;
+}
+
.workload-table {
width: 100%;
border-collapse: collapse;
@@ -219,6 +258,14 @@
font-size: 0.75rem;
}
+.lesson-subgroup {
+ color: var(--accent-hover);
+ font-size: 0.72rem;
+ font-weight: 600;
+ margin-bottom: 0.1rem;
+ line-height: 1.2;
+}
+
.lesson-teacher {
color: var(--text-secondary);
font-size: 0.75rem;
@@ -231,6 +278,292 @@
line-height: 1.2;
}
+.room-workload-block {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.room-workload-summary {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin: -0.75rem 0 0.25rem;
+ color: var(--text-secondary);
+ font-size: 0.86rem;
+}
+
+.room-summary-main {
+ display: inline-flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-right: 0.25rem;
+}
+
+.room-summary-main strong {
+ color: var(--text-primary);
+ font-size: 0.98rem;
+}
+
+.room-summary-chip,
+.room-summary-warning {
+ display: inline-flex;
+ align-items: center;
+ min-height: 28px;
+ padding: 0.25rem 0.55rem;
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ line-height: 1.15;
+}
+
+.room-summary-warning {
+ color: var(--warning);
+ background: color-mix(in srgb, var(--warning) 10%, var(--bg-input));
+}
+
+.room-workload-tables {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.room-week-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.room-week-heading h3 {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 1rem;
+ line-height: 1.2;
+}
+
+.room-week-heading span {
+ color: var(--text-secondary);
+ font-size: 0.82rem;
+}
+
+.room-week-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - 19rem);
+}
+
+.room-grid-container {
+ flex: 1;
+ height: calc(100vh - 24rem);
+ min-height: 420px;
+ max-height: none;
+}
+
+.room-week-table {
+ min-width: 1040px;
+}
+
+.room-week-table th,
+.room-week-table td {
+ min-width: 126px;
+ height: 172px;
+}
+
+.room-week-table .room-time-heading,
+.room-week-table .room-time-cell {
+ position: sticky;
+ left: 0;
+ min-width: 128px;
+ width: 128px;
+ background: var(--bg-input);
+ box-shadow: 1px 0 0 var(--bg-card-border);
+}
+
+.room-week-table .room-time-heading {
+ z-index: 20;
+}
+
+.room-week-table .room-time-cell {
+ z-index: 5;
+ color: var(--text-primary);
+ font-weight: 600;
+ vertical-align: middle;
+}
+
+.room-week-table .room-time-cell strong,
+.room-week-table .room-time-cell span,
+.room-week-table .room-day-cell strong,
+.room-week-table .room-day-cell span,
+.room-week-table .room-day-cell small {
+ display: block;
+}
+
+.room-week-table .room-time-cell strong {
+ font-size: 0.86rem;
+ line-height: 1.15;
+}
+
+.room-week-table .room-time-cell span {
+ margin-top: 0.2rem;
+ color: var(--text-secondary);
+ font-size: 0.72rem;
+ line-height: 1.15;
+}
+
+.room-week-table .room-day-cell strong {
+ color: var(--text-primary);
+ font-size: 0.84rem;
+ line-height: 1.1;
+}
+
+.room-week-table .room-day-cell > span {
+ margin-top: 0.15rem;
+ color: var(--text-secondary);
+ font-size: 0.76rem;
+ line-height: 1.1;
+}
+
+.room-week-table .room-day-cell small {
+ margin-top: 0.25rem;
+ color: var(--text-secondary);
+ font-size: 0.68rem;
+ line-height: 1.1;
+}
+
+.room-day-parity-dates {
+ display: grid;
+ gap: 0.2rem;
+ margin-top: 0.45rem;
+}
+
+.room-day-parity-dates span {
+ padding: 0.16rem 0.35rem;
+ border-radius: 999px;
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ font-size: 0.68rem;
+ font-weight: 700;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.room-combined-cell {
+ height: 1px;
+ padding: 0 !important;
+ background: var(--bg-primary);
+}
+
+.room-single-cell {
+ padding: 0 !important;
+}
+
+.room-week-unified {
+ min-height: 172px;
+ height: 100%;
+ padding: 0.45rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.25rem;
+}
+
+.room-week-unified-busy {
+ justify-content: flex-start;
+ background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
+}
+
+.room-week-unified-busy > .lesson-card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-bottom: 0;
+}
+
+.room-week-unified-free {
+ background: color-mix(in srgb, var(--success) 4%, var(--bg-primary));
+}
+
+.room-week-unified-missing,
+.room-week-unified-empty {
+ background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary));
+}
+
+.room-week-half {
+ position: relative;
+ min-height: 86px;
+ padding: 1.4rem 0.45rem 0.45rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.25rem;
+}
+
+.room-week-half + .room-week-half {
+ border-top: 1px solid var(--bg-card-border);
+}
+
+.room-week-half-busy {
+ justify-content: flex-start;
+ background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
+}
+
+.room-week-half-free {
+ background: color-mix(in srgb, var(--success) 4%, var(--bg-primary));
+}
+
+.room-week-half-missing,
+.room-week-half-empty {
+ background: color-mix(in srgb, var(--warning) 5%, var(--bg-primary));
+}
+
+.room-half-marker {
+ position: absolute;
+ top: 0.35rem;
+ left: 0.45rem;
+ display: inline-flex;
+ align-items: center;
+ max-width: calc(100% - 0.9rem);
+ padding: 0.1rem 0.35rem;
+ border-radius: 999px;
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ font-size: 0.64rem;
+ font-weight: 700;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.room-week-half-odd .room-half-marker {
+ color: var(--accent-hover);
+}
+
+.room-week-half-even .room-half-marker {
+ color: var(--success);
+}
+
+.slot-unavailable {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 58px;
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+}
+
+.room-workload-state {
+ border: 1px solid var(--bg-card-border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+}
+
/* Настройка scrollbar для контейнера сетки */
.workload-grid-container::-webkit-scrollbar {
width: 8px;
@@ -246,3 +579,21 @@
[data-theme="light"] .workload-grid-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
+
+@media (max-width: 768px) {
+ .workload-display-control {
+ min-width: 100%;
+ }
+
+ .room-week-heading {
+ align-items: flex-start;
+ }
+
+ .room-week-panel {
+ min-height: auto;
+ }
+
+ .room-grid-container {
+ height: 520px;
+ }
+}
diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js
index d7f12d2..a8b8446 100644
--- a/frontend/admin/js/views/auditorium-workload.js
+++ b/frontend/admin/js/views/auditorium-workload.js
@@ -2,6 +2,9 @@ import { api } from '../api.js';
import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
const NO_BUILDING = '__no_building__';
+const DISPLAY_ALL = 'all';
+const ROOM_PREFIX = 'room:';
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DAY_SHORT_LABELS = {
1: 'Пн',
@@ -13,6 +16,23 @@ const DAY_SHORT_LABELS = {
7: 'Вс'
};
+const DAY_FULL_LABELS = {
+ 1: 'Понедельник',
+ 2: 'Вторник',
+ 3: 'Среда',
+ 4: 'Четверг',
+ 5: 'Пятница',
+ 6: 'Суббота',
+ 7: 'Воскресенье'
+};
+
+const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7];
+
+const CELL_PARITY_LAYOUT = [
+ { parity: 'ODD', label: 'Нечётная' },
+ { parity: 'EVEN', label: 'Чётная' }
+];
+
const CAPACITY_BUCKETS = [
{ id: 'small', name: 'До 30 мест', match: capacity => Number(capacity || 0) < 30 },
{ id: 'medium', name: '30 - 60 мест', match: capacity => Number(capacity || 0) >= 30 && Number(capacity || 0) <= 60 },
@@ -23,16 +43,26 @@ const CAPACITY_BUCKETS = [
export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh');
+ const displaySelect = document.getElementById('workload-display-select');
const headerRow = document.getElementById('workload-header-row');
const tbody = document.getElementById('workload-tbody');
const summary = document.getElementById('workload-summary');
+ const overviewBlock = document.getElementById('workload-overview-block');
+ const roomBlock = document.getElementById('room-workload-block');
+ const roomSummary = document.getElementById('room-workload-summary');
+ const roomTables = document.getElementById('room-workload-tables');
+ const overviewFilters = Array.from(document.querySelectorAll('.workload-overview-filter'));
+ const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = [];
let timeSlots = [];
let equipments = [];
let groups = [];
+ let semesters = [];
let lessonsByRoomSlot = new Map();
let failedGroupLoads = 0;
+ let currentView = DISPLAY_ALL;
+ let selectedRoomId = null;
setDefaultDate();
initFilters();
@@ -42,11 +72,7 @@ export async function initAuditoriumWorkload() {
function setDefaultDate() {
if (!dateInput || dateInput.value) return;
const today = new Date();
- dateInput.value = [
- today.getFullYear(),
- String(today.getMonth() + 1).padStart(2, '0'),
- String(today.getDate()).padStart(2, '0')
- ].join('-');
+ dateInput.value = formatIsoDate(today);
}
function initFilters() {
@@ -56,8 +82,13 @@ export async function initAuditoriumWorkload() {
}
function bindEvents() {
- refreshButton?.addEventListener('click', loadScheduleForSelectedDate);
- dateInput?.addEventListener('change', loadScheduleForSelectedDate);
+ refreshButton?.addEventListener('click', loadCurrentView);
+ dateInput?.addEventListener('change', loadCurrentView);
+ displaySelect?.addEventListener('change', () => {
+ applyDisplaySelection();
+ syncDisplayMode();
+ loadCurrentView();
+ });
[
['building-checkboxes', 'building-text', 'Все корпуса'],
@@ -67,7 +98,9 @@ export async function initAuditoriumWorkload() {
const container = document.getElementById(containerId);
container?.addEventListener('change', () => {
updateFilterText(containerId, textId, emptyText);
- renderGrid();
+ if (currentView === DISPLAY_ALL) {
+ renderGrid();
+ }
});
});
}
@@ -75,16 +108,44 @@ export async function initAuditoriumWorkload() {
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
try {
- [classrooms, equipments, groups] = await Promise.all([
+ const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
api.get('/api/classrooms'),
api.get('/api/equipments'),
- api.get('/api/groups')
+ api.get('/api/groups'),
+ api.get('/api/admin/calendar/years').catch(() => [])
]);
- classrooms = [...classrooms].sort((a, b) => naturalCompare(a.name, b.name));
+ classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name));
+ equipments = loadedEquipments;
+ groups = loadedGroups;
+ semesters = (academicYears || []).flatMap(year => year.semesters || []);
populateFilters();
- await loadScheduleForSelectedDate();
+ populateDisplayOptions();
+ applyDisplaySelection();
+ syncDisplayMode();
+ await loadCurrentView();
} catch (error) {
- setTableError(error.message || 'Ошибка загрузки данных загруженности аудиторий');
+ const message = error.message || 'Ошибка загрузки данных загруженности аудиторий';
+ setTableError(message);
+ setRoomError(message);
+ }
+ }
+
+ async function loadCurrentView() {
+ applyDisplaySelection();
+ syncDisplayMode();
+ try {
+ if (currentView === DISPLAY_ALL) {
+ await loadScheduleForSelectedDate();
+ } else {
+ await loadRoomWorkload();
+ }
+ } catch (error) {
+ const message = error.message || 'Ошибка загрузки загруженности аудиторий';
+ if (currentView === DISPLAY_ALL) {
+ setTableError(message);
+ } else {
+ setRoomError(message);
+ }
}
}
@@ -96,6 +157,7 @@ export async function initAuditoriumWorkload() {
}
hideAlert('workload-alert');
+ failedGroupLoads = 0;
setTableLoading('Загрузка расписания на выбранную дату...');
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
@@ -106,21 +168,15 @@ export async function initAuditoriumWorkload() {
return;
}
- const params = group => new URLSearchParams({
- groupId: String(group.id),
- startDate: date,
- endDate: date
- }).toString();
-
const responses = await Promise.allSettled(
- groups.map(group => api.get(`/api/schedule?${params(group)}`))
+ groups.map(group => api.get(`/api/schedule?${scheduleParams(group, date, date)}`))
);
failedGroupLoads = responses.filter(response => response.status === 'rejected').length;
const lessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
- lessonsByRoomSlot = groupLessons(lessons);
+ lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
@@ -128,6 +184,105 @@ export async function initAuditoriumWorkload() {
}
}
+ async function loadRoomWorkload() {
+ const date = dateInput?.value;
+ const room = selectedRoom();
+ if (!date) {
+ showAlert('workload-alert', 'Выберите дату', 'error');
+ setRoomError('Выберите дату для расчёта двухнедельного периода');
+ return;
+ }
+ if (!room) {
+ showAlert('workload-alert', 'Выберите аудиторию', 'error');
+ setRoomError('Выберите аудиторию в поле «Отображение»');
+ return;
+ }
+
+ hideAlert('workload-alert');
+ setRoomLoading('Загрузка расписания аудитории на две недели...');
+ const range = twoWeekRange(date);
+ const dates = datesBetween(range.startDate, range.endDate);
+ const [slotLoad, lessonLoad] = await Promise.all([
+ loadSlotsForDates(dates),
+ loadRoomLessons(range, room.id)
+ ]);
+
+ renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
+ failedSlotLoads: slotLoad.failedLoads,
+ failedGroupLoads: lessonLoad.failedLoads,
+ parityLessons: lessonLoad.parityLessons
+ });
+
+ const warnings = [];
+ if (lessonLoad.failedLoads > 0) warnings.push(`групп не загрузилось: ${lessonLoad.failedLoads}`);
+ if (slotLoad.failedLoads > 0) warnings.push(`сеток времени не загрузилось: ${slotLoad.failedLoads}`);
+ if (warnings.length) {
+ showAlert('workload-alert', `Часть данных не загрузилась (${warnings.join(', ')}). Показаны доступные данные.`, 'error');
+ }
+ }
+
+ async function loadSlotsForDates(dates) {
+ const responses = await Promise.allSettled(
+ dates.map(date => api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`))
+ );
+ const slotsByDate = new Map();
+ responses.forEach((response, index) => {
+ const slots = response.status === 'fulfilled' ? response.value || [] : [];
+ slotsByDate.set(dates[index], [...slots].sort(compareSlots));
+ });
+ return {
+ slotsByDate,
+ failedLoads: responses.filter(response => response.status === 'rejected').length
+ };
+ }
+
+ async function loadRoomLessons(range, roomId) {
+ if (!groups.length) {
+ return {
+ lessons: [],
+ lessonsByDateSlot: new Map(),
+ parityLessons: [],
+ failedLoads: 0
+ };
+ }
+
+ const responses = await Promise.allSettled(
+ groups.map(group => api.get(`/api/schedule?${scheduleParams(group, range.startDate, range.endDate)}`))
+ );
+ const parityLessons = responses
+ .filter(response => response.status === 'fulfilled')
+ .flatMap(response => response.value || []);
+ const lessons = uniqueLessons(
+ parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
+ );
+ const lessonsByDateSlot = new Map();
+ lessons.forEach(lesson => {
+ const slotKey = lessonSlotKey(lesson);
+ if (!lesson.date || slotKey == null) return;
+ const key = dateSlotKey(lesson.date, slotKey);
+ if (!lessonsByDateSlot.has(key)) lessonsByDateSlot.set(key, []);
+ lessonsByDateSlot.get(key).push(lesson);
+ });
+ lessonsByDateSlot.forEach(items => {
+ items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
+ });
+
+ return {
+ lessons,
+ lessonsByDateSlot,
+ parityLessons,
+ failedLoads: responses.filter(response => response.status === 'rejected').length
+ };
+ }
+
+ function scheduleParams(group, startDate, endDate) {
+ return new URLSearchParams({
+ groupId: String(group.id),
+ startDate,
+ endDate
+ }).toString();
+ }
+
function populateFilters() {
renderCheckboxes(
'building-checkboxes',
@@ -153,6 +308,49 @@ export async function initAuditoriumWorkload() {
updateFilterText('equipment-checkboxes', 'equipment-text', 'Любое оборудование');
}
+ function populateDisplayOptions() {
+ if (!displaySelect) return;
+ const selectedValue = displaySelect.value;
+ displaySelect.innerHTML = '';
+ displaySelect.append(new Option('Все аудитории', DISPLAY_ALL));
+ classrooms.forEach(room => {
+ const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', ');
+ const suffix = details ? ` · ${details}` : '';
+ displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
+ });
+ if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
+ displaySelect.value = selectedValue;
+ }
+ }
+
+ function applyDisplaySelection() {
+ const value = displaySelect?.value || DISPLAY_ALL;
+ if (value.startsWith(ROOM_PREFIX)) {
+ currentView = 'room';
+ selectedRoomId = value.slice(ROOM_PREFIX.length);
+ return;
+ }
+ currentView = DISPLAY_ALL;
+ selectedRoomId = null;
+ }
+
+ function syncDisplayMode() {
+ const roomMode = currentView !== DISPLAY_ALL;
+ overviewFilters.forEach(filter => {
+ filter.hidden = roomMode;
+ });
+ if (overviewBlock) overviewBlock.hidden = roomMode;
+ if (roomBlock) roomBlock.hidden = !roomMode;
+ if (dateLabel) {
+ dateLabel.textContent = roomMode ? 'Дата в периоде' : 'Дата';
+ }
+ }
+
+ function selectedRoom() {
+ if (!selectedRoomId) return null;
+ return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
+ }
+
function buildingOptions() {
const values = new Map();
classrooms.forEach(room => {
@@ -193,7 +391,7 @@ export async function initAuditoriumWorkload() {
`).join('');
}
- function groupLessons(lessons) {
+ function uniqueLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return;
@@ -209,17 +407,24 @@ export async function initAuditoriumWorkload() {
if (existing) {
existing.groupNames = mergeNames(existing.groupNames, lesson.groupNames);
existing.groupIds = mergeIds(existing.groupIds, lesson.groupIds);
+ existing.subgroupNames = mergeNames(existing.subgroupNames, lesson.subgroupNames);
+ existing.subgroupIds = mergeIds(existing.subgroupIds, lesson.subgroupIds);
return;
}
unique.set(key, {
...lesson,
groupNames: [...(lesson.groupNames || [])],
- groupIds: [...(lesson.groupIds || [])]
+ groupIds: [...(lesson.groupIds || [])],
+ subgroupNames: [...(lesson.subgroupNames || [])],
+ subgroupIds: [...(lesson.subgroupIds || [])]
});
});
+ return Array.from(unique.values());
+ }
+ function groupLessonsByRoomSlot(lessons) {
const grouped = new Map();
- unique.forEach(lesson => {
+ uniqueLessons(lessons).forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
@@ -288,11 +493,298 @@ export async function initAuditoriumWorkload() {
});
}
+ function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
+ const slotRows = buildSlotRows(dates, slotsByDate, lessons);
+ const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons);
+ renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
+
+ if (!slotRows.length) {
+ roomTables.innerHTML = '
За выбранный период нет временных слотов
';
+ return;
+ }
+
+ roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
+ }
+
+ function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
+ if (!roomSummary) return;
+ const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot);
+ const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot);
+ const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
+ const roomMeta = [
+ room.capacity ? `${room.capacity} мест` : null,
+ room.building || null,
+ room.floor ? `${room.floor} этаж` : null
+ ].filter(Boolean).join(', ');
+ const warnings = [];
+ if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
+ if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
+ if (unknownParityDates) warnings.push('часть дат вне семестра');
+
+ roomSummary.innerHTML = `
+
+ ${escapeHtml(room.name)}
+ ${roomMeta ? `${escapeHtml(roomMeta)}` : ''}
+
+ ${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}
+ Занято: ${occupiedCells} из ${totalCells} (${percent}%)
+ ${warnings.length ? `${escapeHtml(warnings.join(', '))}` : ''}
+ `;
+ }
+
+ function renderRoomCombinedWeekTable(datesByDay, slotRows, slotsByDate, lessonsByDateSlot) {
+ const dayHeaders = DAY_ORDER.map(day => `
+
+ ${escapeHtml(dayShort(day))}
+ ${escapeHtml(DAY_FULL_LABELS[day] || '')}
+
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(datesByDay[day]?.[parity], label)).join('')}
+
+ |
+ `).join('');
+ const rows = slotRows.map(row => `
+
+ |
+ ${escapeHtml(row.orderLabel)}
+ ${escapeHtml(row.timeLabel)}
+ |
+ ${DAY_ORDER.map(day => renderRoomCombinedSlotCell(datesByDay[day] || {}, row, slotsByDate, lessonsByDateSlot)).join('')}
+
+ `).join('');
+
+ return `
+
+
+
Нагрузка аудитории по неделям
+ ${escapeHtml(roomWeekCaption(datesByDay))}
+
+
+
+
+
+ | Время |
+ ${dayHeaders}
+
+
+
+ ${rows}
+
+
+
+
+ `;
+ }
+
+ function renderDayParityDate(date, label) {
+ return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`;
+ }
+
+ function renderRoomCombinedSlotCell(datesByParity, row, slotsByDate, lessonsByDateSlot) {
+ const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({
+ parity,
+ label,
+ state: roomSlotState(datesByParity[parity], row, slotsByDate, lessonsByDateSlot)
+ }));
+
+ if (roomSlotStatesEqual(states[0].state, states[1].state)) {
+ return `${renderRoomUnifiedSlot(states[0].state)} | `;
+ }
+
+ const halves = states.map(({ parity, label, state }) =>
+ renderRoomSlotHalf(state, parity, label)
+ ).join('');
+ return `${halves} | `;
+ }
+
+ function roomSlotState(date, row, slotsByDate, lessonsByDateSlot) {
+ if (!date) {
+ return { kind: 'empty', lessons: [] };
+ }
+
+ const key = dateSlotKey(date, row.key);
+ const lessons = lessonsByDateSlot.get(key) || [];
+ const slotExists = hasSlotForDate(date, row.key, slotsByDate) || lessons.length > 0;
+ if (lessons.length) {
+ return { kind: 'busy', lessons };
+ }
+ if (!slotExists) {
+ return { kind: 'missing', lessons: [] };
+ }
+ return { kind: 'free', lessons: [] };
+ }
+
+ function renderRoomUnifiedSlot(state) {
+ if (state.kind === 'busy') {
+ return `${state.lessons.map(renderLessonCard).join('')}
`;
+ }
+ if (state.kind === 'missing') {
+ return 'Нет слота
';
+ }
+ if (state.kind === 'empty') {
+ return 'Нет даты
';
+ }
+ return 'Свободно
';
+ }
+
+ function renderRoomSlotHalf(state, parity, label) {
+ if (state.kind === 'busy') {
+ return `
+
+ ${escapeHtml(label)}
+ ${state.lessons.map(renderLessonCard).join('')}
+
+ `;
+ }
+ if (state.kind === 'missing') {
+ return `
+
+ ${escapeHtml(label)}
+ Нет слота
+
+ `;
+ }
+ if (state.kind === 'empty') {
+ return `
+
+ ${escapeHtml(label)}
+ Нет даты
+
+ `;
+ }
+ return `
+
+ ${escapeHtml(label)}
+ Свободно
+
+ `;
+ }
+
+ function roomSlotStatesEqual(first, second) {
+ if (first.kind !== second.kind) return false;
+ if (first.kind !== 'busy') return true;
+ return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
+ }
+
+ function lessonSetSignature(lessons) {
+ return lessons
+ .map(lessonContentSignature)
+ .sort((a, b) => naturalCompare(a, b))
+ .join('|');
+ }
+
+ function lessonContentSignature(lesson) {
+ return [
+ lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
+ lesson.subjectId || lesson.subjectName || '',
+ lesson.teacherId || lesson.teacherName || '',
+ lesson.lessonTypeId || lesson.lessonTypeName || '',
+ lesson.lessonFormat || '',
+ valueListSignature(lesson.groupIds, lesson.groupNames),
+ valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
+ ].join(':');
+ }
+
+ function valueListSignature(primary = [], fallback = []) {
+ const primaryList = Array.isArray(primary) ? primary : [];
+ const fallbackList = Array.isArray(fallback) ? fallback : [];
+ const source = primaryList.length ? primaryList : fallbackList;
+ return [...source]
+ .map(value => String(value))
+ .sort((a, b) => naturalCompare(a, b))
+ .join(',');
+ }
+
+ function buildSlotRows(dates, slotsByDate, lessons) {
+ const rows = new Map();
+ dates.forEach(date => {
+ (slotsByDate.get(date) || []).forEach(slot => {
+ const key = slotOrderKey(slot);
+ if (key == null) return;
+ putSlotRow(rows, key, slot.orderNumber, slot.startTime, slot.endTime);
+ });
+ });
+ lessons.forEach(lesson => {
+ const key = lessonSlotKey(lesson);
+ if (key == null) return;
+ putSlotRow(rows, key, lesson.timeSlotOrder, lesson.startTime, lesson.endTime);
+ });
+ return Array.from(rows.values()).sort(compareSlotRows);
+ }
+
+ function putSlotRow(rows, key, orderNumber, startTime, endTime) {
+ const existing = rows.get(key);
+ const next = {
+ key,
+ orderNumber,
+ startTime,
+ endTime,
+ orderLabel: orderNumber ? `${orderNumber} пара` : 'Пара',
+ timeLabel: startTime || endTime ? `${trimTime(startTime)}-${trimTime(endTime)}` : 'Время не указано'
+ };
+ if (!existing || existing.timeLabel === 'Время не указано') {
+ rows.set(key, next);
+ }
+ }
+
+ function buildDateGridByDay(dates, lessons) {
+ const datesByDay = {};
+ let unknownParityDates = 0;
+ dates.forEach((date, index) => {
+ let parity = parityForDate(date, lessons);
+ if (!parity) {
+ unknownParityDates += 1;
+ parity = index < 7 ? 'ODD' : 'EVEN';
+ }
+ const day = isoDay(date);
+ if (!datesByDay[day]) datesByDay[day] = {};
+ datesByDay[day][parity] = date;
+ });
+ return { datesByDay, unknownParityDates };
+ }
+
+ function parityForDate(date, lessons) {
+ const lessonParity = lessons.find(lesson => lesson.date === date && ['ODD', 'EVEN'].includes(lesson.parity))?.parity;
+ if (lessonParity) return lessonParity;
+ const weekNumber = weekNumberForDate(date);
+ if (!weekNumber) return null;
+ return weekNumber % 2 === 0 ? 'EVEN' : 'ODD';
+ }
+
+ function weekNumberForDate(date) {
+ const semester = semesters.find(item => item.startDate <= date && item.endDate >= date);
+ if (!semester) return null;
+ const days = Math.floor((parseIsoDate(date) - parseIsoDate(semester.startDate)) / MS_PER_DAY);
+ return Math.floor(days / 7) + 1;
+ }
+
+ function uniqueWeekNumbers(dates) {
+ return Array.from(new Set(dates.map(weekNumberForDate).filter(Boolean)))
+ .sort((a, b) => a - b);
+ }
+
+ function roomWeekCaption(datesByDay) {
+ return CELL_PARITY_LAYOUT
+ .map(({ parity, label }) => {
+ const dates = DAY_ORDER.map(day => datesByDay[day]?.[parity]).filter(Boolean);
+ const weeks = uniqueWeekNumbers(dates);
+ const weekText = weeks.length ? `учебная неделя ${weeks.join(', ')}` : 'неделя вне семестра';
+ const positionText = parity === 'ODD' ? 'сверху' : 'снизу';
+ return `${label} ${positionText}, ${weekText}`;
+ })
+ .join('; ');
+ }
+
+ function hasSlotForDate(date, key, slotsByDate) {
+ return (slotsByDate.get(date) || []).some(slot => slotOrderKey(slot) === key);
+ }
+
function renderLessonCard(lesson) {
+ const subgroupText = (lesson.subgroupNames || []).join(', ');
return `
${escapeHtml(lesson.subjectName || 'Без дисциплины')}
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
+ ${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''}
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
${escapeHtml([lesson.lessonTypeName, lesson.lessonFormat].filter(Boolean).join(' · '))}
@@ -349,6 +841,17 @@ export async function initAuditoriumWorkload() {
showAlert('workload-alert', message, 'error');
}
+ function setRoomLoading(message) {
+ if (roomSummary) roomSummary.textContent = '';
+ if (roomTables) roomTables.innerHTML = `${escapeHtml(message)}
`;
+ }
+
+ function setRoomError(message) {
+ if (roomSummary) roomSummary.textContent = '';
+ if (roomTables) roomTables.innerHTML = `${escapeHtml(message)}
`;
+ showAlert('workload-alert', message, 'error');
+ }
+
function renderSummary(roomCount, occupiedCells, totalCells) {
if (!summary) return;
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
@@ -364,10 +867,40 @@ export async function initAuditoriumWorkload() {
).length, 0);
}
+ function countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot) {
+ return dates.reduce((count, date) => {
+ const keys = new Set((slotsByDate.get(date) || []).map(slotOrderKey).filter(key => key != null));
+ lessonsByDateSlot.forEach((items, key) => {
+ if (items.length && key.startsWith(`${date}:`)) {
+ keys.add(key.slice(date.length + 1));
+ }
+ });
+ return count + keys.size;
+ }, 0);
+ }
+
+ function countOccupiedRoomCells(lessonsByDateSlot) {
+ return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
+ }
+
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
}
+ function dateSlotKey(date, slotKey) {
+ return `${date}:${slotKey}`;
+ }
+
+ function slotOrderKey(slot) {
+ const value = slot?.orderNumber ?? slot?.timeSlotOrder ?? slot?.id;
+ return value == null ? null : String(value);
+ }
+
+ function lessonSlotKey(lesson) {
+ const value = lesson?.timeSlotOrder ?? lesson?.timeSlotId;
+ return value == null ? null : String(value);
+ }
+
function buildingKey(room) {
return room.building ? String(room.building) : NO_BUILDING;
}
@@ -386,9 +919,7 @@ export async function initAuditoriumWorkload() {
function selectedIsoDay() {
if (!dateInput?.value) return null;
- const date = new Date(`${dateInput.value}T00:00:00`);
- const day = date.getDay();
- return day === 0 ? 7 : day;
+ return isoDay(dateInput.value);
}
function scopeShort(slot) {
@@ -402,15 +933,81 @@ export async function initAuditoriumWorkload() {
return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-';
}
+ function isoDay(value) {
+ const day = parseIsoDate(value).getDay();
+ return day === 0 ? 7 : day;
+ }
+
+ function twoWeekRange(value) {
+ const selected = parseIsoDate(value);
+ const start = addDays(selected, 1 - isoDay(value));
+ const end = addDays(start, 13);
+ return {
+ startDate: formatIsoDate(start),
+ endDate: formatIsoDate(end)
+ };
+ }
+
+ function datesBetween(startDate, endDate) {
+ const dates = [];
+ for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date = addDays(date, 1)) {
+ dates.push(formatIsoDate(date));
+ }
+ return dates;
+ }
+
+ function parseIsoDate(value) {
+ const [year, month, day] = String(value).split('-').map(Number);
+ return new Date(year, month - 1, day);
+ }
+
+ function addDays(date, days) {
+ const next = new Date(date);
+ next.setDate(next.getDate() + days);
+ return next;
+ }
+
function trimTime(value) {
return value ? String(value).slice(0, 5) : '';
}
+ function formatIsoDate(date) {
+ return [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, '0'),
+ String(date.getDate()).padStart(2, '0')
+ ].join('-');
+ }
+
function formatDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
+ function formatDateShort(value) {
+ const [, month, day] = String(value).split('-');
+ return `${day}.${month}`;
+ }
+
+ function compareSlots(a, b) {
+ return Number(a.orderNumber || 0) - Number(b.orderNumber || 0)
+ || naturalCompare(a.startTime, b.startTime);
+ }
+
+ function compareSlotRows(a, b) {
+ const first = Number(a.orderNumber || a.key);
+ const second = Number(b.orderNumber || b.key);
+ if (!Number.isNaN(first) && !Number.isNaN(second) && first !== second) {
+ return first - second;
+ }
+ return naturalCompare(a.startTime, b.startTime)
+ || naturalCompare(a.key, b.key);
+ }
+
+ function sameId(first, second) {
+ return String(first) === String(second);
+ }
+
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
diff --git a/frontend/admin/views/auditorium-workload.html b/frontend/admin/views/auditorium-workload.html
index 38a3bda..ccf4f30 100644
--- a/frontend/admin/views/auditorium-workload.html
+++ b/frontend/admin/views/auditorium-workload.html
@@ -4,8 +4,15 @@
-