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 @@ -
-
+
+
+ + +
+ +
@@ -20,7 +27,7 @@
-
+
@@ -35,7 +42,7 @@
-
+
@@ -57,23 +64,27 @@
-
- -
- - - - - - - - - - -
- Время - Аудитория -
+
+
+ +
+ + + + + + + +
+ Время + Аудитория +
+
+
+ +