From f49fa95d5a6f5f3915d539637007541cb77ba172 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 20 May 2026 15:02:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUSINESS_LOGIC.md | 2 +- docs/FRONTEND.md | 4 +- frontend/admin/css/auditorium-workload.css | 317 +++++++++++- frontend/admin/js/views/schedule-view.js | 531 ++++++++++++++++++--- frontend/admin/views/schedule-view.html | 189 +++++--- 5 files changed, 887 insertions(+), 156 deletions(-) diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index e78a6be..2fa695c 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -9,7 +9,7 @@ | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | -| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 8ee0d29..c5f5027 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -137,7 +137,7 @@ frontend/ | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | -| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон и результат разбивается на отдельные совмещённые таблицы чётной/нечётной недели по группам, преподавателям или аудиториям | `/api/schedule/search` | +| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `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/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | @@ -147,7 +147,7 @@ frontend/ ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. -- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице. Пользователь выбирает одну дату в периоде; frontend сам запрашивает двухнедельный диапазон от понедельника выбранной недели, чтобы обе половины совмещённой таблицы были заполнены одним периодом. Чтобы ячейка вроде «понедельник, 1 пара» не смешивала занятия разных групп и аудиторий, результат разбивается на отдельные таблицы. Поле «Таблицы по» поддерживает авто-режим, группы, преподавателей и аудитории; авто-режим выбирает преподавателей при фильтре преподавателя, аудитории при фильтре аудитории, иначе группы. Каждая таблица строится двухнедельными блоками: строки — пары, столбцы — дни недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. +- Вкладка `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`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index ed5fad2..9722ab7 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -569,19 +569,205 @@ background: var(--bg-input); } -.schedule-view-table-stack { - display: flex; - flex-direction: column; +.schedule-view-shell { + min-height: calc(100vh - 8.5rem); + display: grid; + grid-template-rows: auto auto minmax(420px, 1fr); gap: 1rem; } +.schedule-view-control-card, +.schedule-view-results-card, +.schedule-view-table-card { + animation-duration: 0.28s; +} + +.schedule-view-control-header, +.schedule-view-results-header, +.schedule-view-table-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.schedule-view-control-header h2, +.schedule-view-results-header h2, +.schedule-view-table-header h2 { + margin-bottom: 0.3rem; +} + +.schedule-view-control-grid, +.schedule-view-extra-grid { + display: grid; + gap: 1rem; + align-items: end; +} + +.schedule-view-control-grid { + grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr); + margin-top: 1rem; +} + +.schedule-view-extra-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + margin-top: 0.9rem; +} + +.schedule-view-period-control { + min-width: 0; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; +} + +.schedule-view-period-buttons, +.schedule-view-result-actions, +.schedule-view-day-tabs { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.schedule-view-shell .btn-secondary { + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); +} + +.schedule-view-shell .btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent-hover); + transform: translateY(-1px); +} + +.schedule-view-shell .btn-secondary:disabled { + cursor: default; + opacity: 0.45; +} + +.schedule-view-extra-filters { + margin-top: 0.9rem; + border-top: 1px solid var(--bg-card-border); + padding-top: 0.85rem; +} + +.schedule-view-extra-filters summary { + width: fit-content; + color: var(--text-secondary); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; +} + +.schedule-view-result-actions { + min-height: 42px; + justify-content: flex-end; + color: var(--text-secondary); + font-size: 0.88rem; + text-align: right; +} + +.schedule-view-result-tabs { + display: flex; + gap: 0.55rem; + margin-top: 1rem; + padding-bottom: 0.1rem; + overflow-x: auto; +} + +.schedule-view-result-tab { + flex: 0 0 auto; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 0.18rem; + min-width: 150px; + max-width: 240px; + padding: 0.65rem 0.85rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), transform var(--transition); +} + +.schedule-view-result-tab span, +.schedule-view-result-tab small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-view-result-tab small { + color: var(--text-secondary); + font-size: 0.72rem; +} + +.schedule-view-result-tab:hover, +.schedule-view-result-tab.active { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-input)); + border-color: var(--accent); + transform: translateY(-1px); +} + +.schedule-view-empty-result { + display: inline-flex; + align-items: center; + min-height: 38px; + color: var(--text-secondary); + font-size: 0.88rem; +} + +.schedule-view-table-card { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.schedule-view-table-header { + flex: 0 0 auto; + margin-bottom: 1rem; +} + +.schedule-view-table-stage { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + .schedule-view-week-panel { + flex: 1 1 auto; + min-height: 0; display: flex; flex-direction: column; gap: 0; } .schedule-view-grid-container { + flex: 1 1 auto; + min-height: 0; + height: 100%; max-height: none; } @@ -600,11 +786,80 @@ } .schedule-view-state { + min-height: 100%; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-input); } +.schedule-view-day-tabs { + flex-wrap: wrap; + justify-content: flex-end; +} + +.schedule-view-day-tab { + min-width: 42px; + min-height: 34px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font: inherit; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.schedule-view-day-tab.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, var(--bg-input)); + color: var(--text-primary); +} + +.schedule-view-mobile-day { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.schedule-view-mobile-day-heading, +.schedule-view-mobile-slot { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.schedule-view-mobile-day-heading { + padding: 0.8rem 0.9rem; +} + +.schedule-view-mobile-day-heading strong, +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time strong, +.schedule-view-mobile-slot-time span { + display: block; +} + +.schedule-view-mobile-day-heading span, +.schedule-view-mobile-slot-time span { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.schedule-view-mobile-slot { + overflow: hidden; +} + +.schedule-view-mobile-slot-time { + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.schedule-view-mobile-slot-content .room-week-unified { + min-height: 124px; +} + /* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; @@ -621,6 +876,12 @@ background: rgba(0, 0, 0, 0.15); } +@media (max-width: 1180px) { + .schedule-view-control-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 768px) { .workload-target-control, .workload-display-control { @@ -638,4 +899,54 @@ .room-grid-container { height: 520px; } + + .schedule-view-shell { + min-height: auto; + grid-template-rows: auto; + } + + .schedule-view-control-header, + .schedule-view-results-header, + .schedule-view-table-header { + display: grid; + grid-template-columns: 1fr; + } + + .schedule-view-control-grid, + .schedule-view-extra-grid { + grid-template-columns: 1fr; + } + + .schedule-view-period-buttons, + .schedule-view-result-actions, + .schedule-view-day-tabs { + width: 100%; + } + + .schedule-view-period-buttons .btn-secondary, + .schedule-view-result-actions .btn-secondary { + flex: 1; + } + + .schedule-view-result-actions { + justify-content: stretch; + text-align: center; + } + + .schedule-view-result-actions span { + flex: 2; + align-self: center; + } + + .schedule-view-result-tab { + min-width: 72%; + } + + .schedule-view-table-card { + min-height: 520px; + } + + .schedule-view-day-tabs { + justify-content: flex-start; + } } diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 787075a..6f64179 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -2,6 +2,10 @@ import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; +const TARGET_GROUPS = 'groups'; +const TARGET_TEACHERS = 'teachers'; +const TARGET_CLASSROOMS = 'classrooms'; +const TARGET_DEPARTMENTS = 'departments'; const PARITY_LABELS = { BOTH: 'каждая', ODD: 'нечётная', @@ -30,104 +34,418 @@ const CELL_PARITY_LAYOUT = [ { parity: 'ODD', label: 'Нечётная' }, { parity: 'EVEN', label: 'Чётная' } ]; -const SPLIT_AUTO = 'auto'; -const SPLIT_GROUPS = 'groups'; -const SPLIT_TEACHERS = 'teachers'; -const SPLIT_CLASSROOMS = 'classrooms'; +const MOBILE_QUERY = '(max-width: 760px)'; export async function initScheduleView() { const role = localStorage.getItem('role'); - const userDepartmentId = localStorage.getItem('departmentId'); + const userDepartmentId = normalizeId(localStorage.getItem('departmentId')); const dateInput = document.getElementById('schedule-view-date'); + const targetSelect = document.getElementById('schedule-view-target'); const loadButton = document.getElementById('schedule-view-load'); - const departmentField = document.getElementById('schedule-view-department-field'); - const departmentSelect = document.getElementById('schedule-view-department'); - const tables = document.getElementById('schedule-view-tables'); + const previousPeriodButton = document.getElementById('schedule-view-prev-period'); + const todayButton = document.getElementById('schedule-view-today'); + const nextPeriodButton = document.getElementById('schedule-view-next-period'); + const resultPreviousButton = document.getElementById('schedule-view-result-prev'); + const resultNextButton = document.getElementById('schedule-view-result-next'); + const resultTabs = document.getElementById('schedule-view-result-tabs'); + const activeLabel = document.getElementById('schedule-view-active-label'); const countLabel = document.getElementById('schedule-view-count'); + const periodLabel = document.getElementById('schedule-view-period-label'); + const tableTitle = document.getElementById('schedule-view-table-title'); + const tableCaption = document.getElementById('schedule-view-table-caption'); + const dayTabs = document.getElementById('schedule-view-day-tabs'); + const tables = document.getElementById('schedule-view-tables'); + const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); + const departmentSelect = document.getElementById('schedule-view-department'); + + const state = { + dictionaries: { + groups: [], + teachers: [], + classrooms: [], + departments: [] + }, + sections: [], + activeSectionIndex: 0, + mobileDay: 1, + range: null, + totalLessons: 0 + }; + const mobileMedia = window.matchMedia(MOBILE_QUERY); setDefaultDate(dateInput); + configureDepartmentTarget(); + updatePeriodLabel(); + updateEntityFieldVisibility(); + renderResultNavigation(); + + targetSelect?.addEventListener('change', () => { + clearInactiveEntityValues(); + updateEntityFieldVisibility(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); loadButton?.addEventListener('click', loadData); + previousPeriodButton?.addEventListener('click', () => movePeriod(-1)); + todayButton?.addEventListener('click', () => { + dateInput.value = toIsoDate(new Date()); + loadData(); + }); + nextPeriodButton?.addEventListener('click', () => movePeriod(1)); + dateInput?.addEventListener('change', () => { + updatePeriodLabel(); + resetResults('Нажмите «Показать», чтобы обновить расписание'); + }); + resultPreviousButton?.addEventListener('click', () => moveResult(-1)); + resultNextButton?.addEventListener('click', () => moveResult(1)); + if (typeof mobileMedia.addEventListener === 'function') { + mobileMedia.addEventListener('change', renderActiveSection); + } else if (typeof mobileMedia.addListener === 'function') { + mobileMedia.addListener(renderActiveSection); + } try { - await loadDictionaries(role, userDepartmentId); - if (role === ROLE_DEPARTMENT) { - departmentField.hidden = true; - departmentSelect.value = userDepartmentId || ''; - } + state.dictionaries = await loadDictionaries(role, userDepartmentId); + configureDepartmentAccess(); + updateEntityFieldVisibility(); await loadData(); } catch (error) { - setTableState(error.message || 'Ошибка загрузки справочников'); + const message = error.message || 'Ошибка загрузки справочников'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + } + + function configureDepartmentTarget() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + } + + function configureDepartmentAccess() { + if (role !== ROLE_DEPARTMENT) return; + targetSelect.value = TARGET_DEPARTMENTS; + targetSelect.disabled = true; + if (departmentSelect) { + departmentSelect.value = userDepartmentId || ''; + departmentSelect.disabled = true; + if (userDepartmentId && departmentSelect.value !== userDepartmentId) { + departmentSelect.insertAdjacentHTML('afterbegin', + ``); + departmentSelect.value = userDepartmentId; + } + } + } + + function updateEntityFieldVisibility() { + const target = activeTarget(); + entityFields.forEach(field => { + field.hidden = field.dataset.scheduleViewTargetField !== target; + }); + } + + function clearInactiveEntityValues() { + const target = activeTarget(); + [ + [TARGET_GROUPS, 'schedule-view-group'], + [TARGET_TEACHERS, 'schedule-view-teacher'], + [TARGET_CLASSROOMS, 'schedule-view-classroom'], + [TARGET_DEPARTMENTS, 'schedule-view-department'] + ].forEach(([fieldTarget, id]) => { + if (fieldTarget === target) return; + if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return; + const select = document.getElementById(id); + if (select) select.value = ''; + }); + } + + function activeTarget() { + if (role === ROLE_DEPARTMENT) return TARGET_DEPARTMENTS; + return targetSelect?.value || TARGET_GROUPS; + } + + async function movePeriod(direction) { + if (!dateInput.value) { + dateInput.value = toIsoDate(new Date()); + } + const range = twoWeekRange(dateInput.value); + const nextDate = addDays(parseIsoDate(range.startDate), direction * 14); + dateInput.value = toIsoDate(nextDate); + await loadData(); } async function loadData() { hideAlert('schedule-view-alert'); - setTableState('Загрузка...'); - countLabel.textContent = 'Загрузка...'; if (!dateInput.value) { const message = 'Выберите дату в периоде'; showAlert('schedule-view-alert', message, 'error'); - setTableState(message); - countLabel.textContent = '0 занятий'; + resetResults(message); return; } + + if (role === ROLE_DEPARTMENT && !userDepartmentId) { + const message = 'Не удалось определить кафедру пользователя'; + showAlert('schedule-view-alert', message, 'error'); + resetResults(message); + return; + } + + const target = activeTarget(); const range = twoWeekRange(dateInput.value); + const previousKey = state.sections[state.activeSectionIndex]?.key; + state.range = range; + updatePeriodLabel(range); + setTableState('Загрузка...'); + setActiveSummary('Загрузка расписаний...'); + countLabel.textContent = 'Загрузка...'; + resultTabs.innerHTML = ''; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + const params = new URLSearchParams({ startDate: range.startDate, endDate: range.endDate }); - addOptional(params, 'groupId', valueOf('schedule-view-group')); - addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); - addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); - addOptional(params, 'departmentId', role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value); + applyTargetFilter(params, target); addOptional(params, 'subjectId', valueOf('schedule-view-subject')); addOptional(params, 'lessonTypeId', valueOf('schedule-view-lesson-type')); addOptional(params, 'parity', valueOf('schedule-view-parity')); try { const lessons = await api.get(`/api/schedule/search?${params}`); - renderLessons(lessons, range); + renderLessons(lessons, range, target, previousKey); } catch (error) { const message = error.message || 'Ошибка загрузки расписания'; showAlert('schedule-view-alert', message, 'error'); - setTableState(message); - countLabel.textContent = '0 занятий'; + resetResults(message); } } - function renderLessons(lessons, range) { - countLabel.textContent = lessonCountLabel(lessons.length); + function applyTargetFilter(params, target) { + if (role === ROLE_DEPARTMENT) { + params.set('departmentId', userDepartmentId); + return; + } + if (target === TARGET_GROUPS) { + addOptional(params, 'groupId', valueOf('schedule-view-group')); + return; + } + if (target === TARGET_TEACHERS) { + addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); + return; + } + if (target === TARGET_CLASSROOMS) { + addOptional(params, 'classroomId', valueOf('schedule-view-classroom')); + return; + } + if (target === TARGET_DEPARTMENTS) { + addOptional(params, 'departmentId', valueOf('schedule-view-department')); + } + } + + function renderLessons(lessons, range, target, previousKey) { + state.totalLessons = lessons.length; if (!lessons.length) { + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); setTableState('Занятия не найдены'); + countLabel.textContent = '0 занятий'; return; } - const sections = buildScheduleSections(lessons); + + const sections = buildScheduleSections(lessons, splitTypeForTarget(target), buildSectionContext(target)); if (!sections.length) { - setTableState('Нет данных для выбранного разреза таблиц'); + state.sections = []; + state.activeSectionIndex = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = periodCaption(range); + setTableState('Нет данных для выбранного режима просмотра'); + countLabel.textContent = lessonCountLabel(lessons.length); return; } - const tableCount = sections.reduce((count, section) => - count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 - ); - if (!tableCount) { - setTableState('Выберите корректный период'); - return; + + state.sections = sections; + state.activeSectionIndex = Math.max(0, sections.findIndex(section => section.key === previousKey)); + if (state.activeSectionIndex < 0) { + state.activeSectionIndex = 0; } - countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; - tables.innerHTML = sections - .map(section => renderScheduleSection(section, range)) - .join(''); + state.mobileDay = preferredMobileDay(range); + renderResultNavigation(); + renderActiveSection(); } - function renderScheduleSection(section, range) { + function buildSectionContext(target) { + const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; + const selectedDepartmentId = role === ROLE_DEPARTMENT + ? userDepartmentId + : (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : ''); + const allowedGroupIds = new Set(); + if (selectedGroupId) { + allowedGroupIds.add(selectedGroupId); + } else if (selectedDepartmentId) { + state.dictionaries.groups + .filter(group => String(group.departmentId) === String(selectedDepartmentId)) + .forEach(group => allowedGroupIds.add(String(group.id))); + } + return { + selectedGroupId, + selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', + allowedGroupIds, + groupNamesById: new Map(state.dictionaries.groups.map(group => [String(group.id), group.name])) + }; + } + + function splitTypeForTarget(target) { + if (target === TARGET_TEACHERS) return TARGET_TEACHERS; + if (target === TARGET_CLASSROOMS) return TARGET_CLASSROOMS; + return TARGET_GROUPS; + } + + function renderResultNavigation() { + const section = activeSection(); + const totalSections = state.sections.length; + countLabel.textContent = state.totalLessons + ? `${lessonCountLabel(state.totalLessons)}, ${sectionCountLabel(totalSections)}` + : '0 занятий'; + + resultPreviousButton.disabled = totalSections < 2; + resultNextButton.disabled = totalSections < 2; + + if (!section) { + setActiveSummary('Нет выбранного расписания'); + resultTabs.innerHTML = 'Ничего не найдено'; + return; + } + + setActiveSummary(`${section.title} · ${lessonCountLabel(section.lessons.length)} · ${state.activeSectionIndex + 1} из ${totalSections}`); + resultTabs.innerHTML = state.sections.map((item, index) => ` + + `).join(''); + resultTabs.querySelectorAll('[data-schedule-view-section]').forEach(button => { + button.addEventListener('click', () => { + state.activeSectionIndex = Number(button.dataset.scheduleViewSection); + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + }); + }); + } + + function setActiveSummary(text) { + activeLabel.textContent = text; + } + + function moveResult(direction) { + if (state.sections.length < 2) return; + state.activeSectionIndex = (state.activeSectionIndex + direction + state.sections.length) % state.sections.length; + state.mobileDay = preferredMobileDay(state.range); + renderResultNavigation(); + renderActiveSection(); + } + + function activeSection() { + return state.sections[state.activeSectionIndex] || null; + } + + function renderActiveSection() { + const section = activeSection(); + if (!section || !state.range) { + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + return; + } + + tableTitle.textContent = section.title; + tableCaption.textContent = `${lessonCountLabel(section.lessons.length)} · ${periodCaption(state.range)}`; + + if (mobileMedia.matches) { + renderMobileDayTabs(); + renderMobileSchedule(section, state.range); + return; + } + + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + renderDesktopSchedule(section, state.range); + } + + function renderDesktopSchedule(section, range) { const slots = collectSlots(section.lessons); const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); - return blocks + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) .join(''); } + function renderMobileDayTabs() { + dayTabs.hidden = false; + dayTabs.innerHTML = DAY_ORDER.map(day => ` + + `).join(''); + dayTabs.querySelectorAll('[data-schedule-view-day]').forEach(button => { + button.addEventListener('click', () => { + state.mobileDay = Number(button.dataset.scheduleViewDay); + renderActiveSection(); + }); + }); + } + + function renderMobileSchedule(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + if (!blocks.length || !slots.length) { + setTableState('Занятия не найдены'); + return; + } + tables.innerHTML = blocks.map(block => renderMobileDayBlock(block, slots, lessonsByDateSlot)).join(''); + } + + function renderMobileDayBlock(block, slots, lessonsByDateSlot) { + const dayDates = block.datesByDay[state.mobileDay] || {}; + const rows = slots.map(slot => { + const states = slotStatesForDates(dayDates, slot, lessonsByDateSlot); + return ` +
+
+ ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} +
+
+ ${renderSlotContent(states)} +
+
+ `; + }).join(''); + + return ` +
+
+ ${escapeHtml(DAY_FULL_LABELS[state.mobileDay] || dayShort(state.mobileDay))} + ${escapeHtml(mobileDayCaption(dayDates))} +
+ ${rows} +
+ `; + } + function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { const dayHeaders = DAY_ORDER.map(day => ` @@ -152,8 +470,8 @@ export async function initScheduleView() { return `
-

${escapeHtml(section.title)} · ${escapeHtml(block.label)}

- ${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)} +

${escapeHtml(section.title)}

+ ${escapeHtml(combinedWeekCaption(block))}
@@ -175,20 +493,26 @@ export async function initScheduleView() { } function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { - const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + const states = slotStatesForDates(datesByParity, slot, lessonsByDateSlot); + const singleCellClass = slotStatesEqual(states[0].state, states[1].state) ? ' room-single-cell' : ''; + return ``; + } + + function slotStatesForDates(datesByParity, slot, lessonsByDateSlot) { + return CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ parity, label, state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) })); + } + function renderSlotContent(states) { if (slotStatesEqual(states[0].state, states[1].state)) { - return ``; + return renderUnifiedSlot(states[0].state); } - - const halves = states.map(({ parity, label, state }) => + return states.map(({ parity, label, state }) => renderSlotHalf(state, parity, label) ).join(''); - return ``; } function scheduleSlotState(date, slot, lessonsByDateSlot) { @@ -298,6 +622,38 @@ export async function initScheduleView() { if (!tables) return; tables.innerHTML = `
${escapeHtml(message)}
`; } + + function resetResults(message) { + state.sections = []; + state.activeSectionIndex = 0; + state.mobileDay = 1; + state.totalLessons = 0; + renderResultNavigation(); + tableTitle.textContent = 'Расписание'; + tableCaption.textContent = message; + setTableState(message); + countLabel.textContent = '0 занятий'; + dayTabs.hidden = true; + dayTabs.innerHTML = ''; + } + + function updatePeriodLabel(range) { + if (!periodLabel) return; + if (!dateInput.value) { + periodLabel.textContent = 'Период не выбран'; + return; + } + periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value)); + } + + function groupNameById(id) { + return state.dictionaries.groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`; + } + + function preferredMobileDay(range) { + if (!range) return 1; + return isoDay(dateInput.value || range.startDate); + } } async function loadDictionaries(role, userDepartmentId) { @@ -310,16 +666,23 @@ async function loadDictionaries(role, userDepartmentId) { api.get('/api/lesson-types') ]); - fillSelect('schedule-view-group', groups, item => item.id, item => item.name, 'Все группы'); + const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId + ? groups.filter(group => String(group.departmentId) === String(userDepartmentId)) + : groups; + + fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы'); fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели'); fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории'); fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); - if (role === ROLE_DEPARTMENT && userDepartmentId) { - document.getElementById('schedule-view-department').value = userDepartmentId; - } + return { + groups, + teachers, + classrooms, + departments + }; } function setDefaultDate(dateInput) { @@ -342,6 +705,11 @@ function valueOf(id) { return document.getElementById(id)?.value || ''; } +function normalizeId(value) { + if (!value || value === 'null' || value === 'undefined') return ''; + return String(value); +} + function roomLabel(room) { return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); } @@ -385,12 +753,11 @@ function groupLessonsByDateSlot(lessons) { return grouped; } -function buildScheduleSections(lessons) { - const splitType = resolveSplitType(); +function buildScheduleSections(lessons, splitType, context) { const sections = new Map(); lessons.forEach(lesson => { - splitEntitiesForLesson(lesson, splitType).forEach(entity => { + splitEntitiesForLesson(lesson, splitType, context).forEach(entity => { if (!sections.has(entity.key)) { sections.set(entity.key, { key: entity.key, @@ -410,45 +777,42 @@ function buildScheduleSections(lessons) { .sort((a, b) => naturalCompare(a.title, b.title)); } -function resolveSplitType() { - const requested = valueOf('schedule-view-split'); - if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) { - return requested; - } - if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS; - if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS; - return SPLIT_GROUPS; -} - -function splitEntitiesForLesson(lesson, splitType) { - if (splitType === SPLIT_TEACHERS) { +function splitEntitiesForLesson(lesson, splitType, context) { + if (splitType === TARGET_TEACHERS) { return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; } - if (splitType === SPLIT_CLASSROOMS) { + if (splitType === TARGET_CLASSROOMS) { return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; } - return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); + if (context.selectedGroupId) { + return [singleEntity('group', context.selectedGroupId, context.selectedGroupName, 'Группа не указана')]; + } + return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана', context.allowedGroupIds, context.groupNamesById); } function singleEntity(prefix, id, name, fallback) { const title = name || fallback; return { + id, key: `${prefix}:${id ?? title}`, title }; } -function listEntities(prefix, ids = [], names = [], fallback) { +function listEntities(prefix, ids = [], names = [], fallback, allowedIds = new Set(), namesById = new Map()) { const safeIds = Array.isArray(ids) ? ids : []; const safeNames = Array.isArray(names) ? names : []; if (!safeIds.length && !safeNames.length) { - return [singleEntity(prefix, null, fallback, fallback)]; + return allowedIds.size ? [] : [singleEntity(prefix, null, fallback, fallback)]; } const maxLength = Math.max(safeIds.length, safeNames.length); const entities = []; for (let index = 0; index < maxLength; index += 1) { const id = safeIds[index] ?? null; - const name = safeNames[index] || (id == null ? fallback : `Группа ${id}`); + if (allowedIds.size && (id == null || !allowedIds.has(String(id)))) { + continue; + } + const name = namesById.get(String(id)) || safeNames[index] || (id == null ? fallback : `Группа ${id}`); entities.push(singleEntity(prefix, id, name, fallback)); } return entities; @@ -540,6 +904,19 @@ function combinedWeekCaption(block) { .join('; '); } +function mobileDayCaption(datesByParity) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const date = datesByParity?.[parity]; + return `${label}: ${date ? formatDate(date) : 'даты нет'}`; + }) + .join(' · '); +} + +function periodCaption(range) { + return `${formatDate(range.startDate)} - ${formatDate(range.endDate)}`; +} + function lessonSlotKey(lesson) { const value = lesson.timeSlotOrder ?? lesson.timeSlotId ?? `${lesson.startTime || ''}-${lesson.endTime || ''}`; return value ? String(value) : 'unknown'; @@ -625,12 +1002,12 @@ function lessonCountLabel(count) { return `${count} занятий`; } -function tableCountLabel(count) { +function sectionCountLabel(count) { const tail = count % 100; - if (tail >= 11 && tail <= 14) return `${count} таблиц`; - if (count % 10 === 1) return `${count} таблица`; - if ([2, 3, 4].includes(count % 10)) return `${count} таблицы`; - return `${count} таблиц`; + if (tail >= 11 && tail <= 14) return `${count} расписаний`; + if (count % 10 === 1) return `${count} расписание`; + if ([2, 3, 4].includes(count % 10)) return `${count} расписания`; + return `${count} расписаний`; } function compareSlots(a, b) { diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index 4cec711..a409229 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -1,79 +1,122 @@ -
-
-

Фильтры расписания

- +
+
+
+
+

Расписания

+ Период не выбран +
+ +
+ +
+
+ + +
+ +
+ + +
+ + + + + + + +
+ + +
+ +
+ Переход +
+ + + +
+
+
+ +
+ Дополнительные фильтры +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
+
+
+

Найденные расписания

+ 0 занятий +
+
+ + Нет выбранного расписания + +
+
- -
- -
-
-

Таблицы расписания

- 0 занятий +
+
+
+

Расписание

+ Выберите параметры и нажмите «Показать» +
+ +
+
+
Загрузка...
+
-
-
Загрузка...
-
-
+
${renderSlotContent(states)}${renderUnifiedSlot(states[0].state)}${halves}