diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7b9c1d7..f52dba3 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -148,10 +148,10 @@ frontend/ - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. -- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c731669..446af8d 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -313,6 +313,65 @@ clear: both; } +.schedule-rule-table-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + min-width: 188px; +} + +.schedule-rule-table-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 86px; + min-height: 32px; + padding: 0.38rem 0.72rem; + 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.82rem; + font-weight: 700; + line-height: 1.1; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition); +} + +.schedule-rule-table-button:hover, +.schedule-rule-table-button:focus-visible { + outline: none; + transform: translateY(-1px); +} + +.schedule-rule-table-button-edit { + border-color: color-mix(in srgb, var(--accent) 34%, var(--bg-card-border)); + background: color-mix(in srgb, var(--accent) 12%, var(--bg-input)); + color: var(--accent-hover); +} + +.schedule-rule-table-button-edit:hover, +.schedule-rule-table-button-edit:focus-visible { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 22%, var(--bg-input)); + box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-glow) 60%, transparent); +} + +.schedule-rule-table-button-delete { + border-color: color-mix(in srgb, var(--error) 34%, var(--bg-card-border)); + background: color-mix(in srgb, var(--error) 10%, var(--bg-input)); + color: var(--error); +} + +.schedule-rule-table-button-delete:hover, +.schedule-rule-table-button-delete:focus-visible { + border-color: var(--error); + background: color-mix(in srgb, var(--error) 18%, var(--bg-input)); + box-shadow: 0 4px 14px rgba(248, 113, 113, 0.14); +} + .slot-subgroup-multi .select-text { overflow: hidden; text-overflow: ellipsis; @@ -429,6 +488,17 @@ gap: 0.55rem; } +.schedule-visual-period-grid { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 0.65rem; + align-items: end; +} + +.schedule-visual-period-grid .form-group { + min-width: 0; +} + .schedule-visual-filter-head { align-items: center; } @@ -665,9 +735,10 @@ } .schedule-visual-lesson { + position: relative; display: grid; gap: 0.12rem; - padding: 0.34rem; + padding: 0.34rem 1.65rem 0.34rem 0.34rem; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-card); @@ -691,6 +762,37 @@ line-height: 1.14; } +.schedule-visual-edit-rule { + position: absolute; + top: 0.26rem; + right: 0.26rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + padding: 0; + border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border)); + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); + color: var(--accent-hover); + font: inherit; + font-size: 0.82rem; + font-weight: 800; + line-height: 1; + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition); +} + +.schedule-visual-edit-rule:hover, +.schedule-visual-edit-rule:focus-visible { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 22%, var(--bg-card)); + color: var(--text-primary); + outline: none; + transform: translateY(-1px); +} + .schedule-visual-free { display: flex; align-items: center; @@ -720,6 +822,10 @@ display: grid; grid-template-columns: 1fr; } + + .schedule-visual-period-grid { + grid-template-columns: 1fr; + } } .calendar-fill-panel { @@ -2284,4 +2390,4 @@ tbody tr:hover { padding: 0.05rem 0.35rem; white-space: nowrap; line-height: 1.2; -} \ No newline at end of file +} diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index c21c418..bbc42a4 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [ { parity: 'EVEN', label: 'Чётная' } ]; const MOBILE_QUERY = '(max-width: 760px)'; +const DEFAULT_SEMESTER_WEEKS = 22; export async function initScheduleView() { const role = localStorage.getItem('role'); @@ -621,11 +622,43 @@ export async function initScheduleView() { if (startWeek <= 0) startWeek = 1; if (endWeek < startWeek) endWeek = startWeek; - - if (startWeek === endWeek) { - return `${startWeek} нед.`; - } - return `с ${startWeek} по ${endWeek} нед.`; + + return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson)); + } + + function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) { + const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity); + const semesterStartWeek = firstApplicableSemesterWeek(parity); + if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return ''; + if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`; + return startWeek === endWeek + ? `(${startWeek} нед.)` + : `(с ${startWeek} по ${endWeek} нед.)`; + } + + function semesterWeeksForLesson(lesson) { + const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate)); + if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS; + const start = parseIsoDate(semester.startDate); + const end = parseIsoDate(semester.endDate); + const days = Math.max(1, Math.round((end - start) / 86400000) + 1); + return Math.max(1, Math.ceil(days / 7)); + } + + function dateInRange(date, startDate, endDate) { + return Boolean(date && startDate && endDate && date >= startDate && date <= endDate); + } + + function firstApplicableSemesterWeek(parity) { + return parity === 'EVEN' ? 2 : 1; + } + + function lastApplicableSemesterWeek(maxWeeks, parity) { + const totalWeeks = Number(maxWeeks || 0); + if (!totalWeeks) return 0; + if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1; + if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1; + return totalWeeks; } function renderLessonCard(lesson) { @@ -694,13 +727,14 @@ export async function initScheduleView() { } async function loadDictionaries(role, userDepartmentId) { - const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([ + const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([ api.get('/api/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), api.get('/api/departments'), api.get('/api/subjects'), - api.get('/api/lesson-types') + api.get('/api/lesson-types'), + api.get('/api/admin/calendar/years').catch(() => []) ]); const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId @@ -718,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) { groups, teachers, classrooms, - departments + departments, + semesters: (academicYears || []).flatMap(year => year.semesters || []) }; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 7fc5a4b..8cf935a 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -82,6 +82,8 @@ export async function initSchedule() { const visualBackdrop = document.getElementById('schedule-visual-backdrop'); const visualStage = document.getElementById('schedule-visual-stage'); const visualSummary = document.getElementById('schedule-visual-summary'); + const visualYearSelect = document.getElementById('schedule-visual-year'); + const visualSemesterSelect = document.getElementById('schedule-visual-semester'); const visualGroupFilter = document.getElementById('schedule-visual-group-filter'); const visualUseFormButton = document.getElementById('schedule-visual-use-form'); const visualSelectAllButton = document.getElementById('schedule-visual-select-all'); @@ -94,9 +96,14 @@ export async function initSchedule() { let lessonTypes = []; let subgroups = []; let timeSlots = []; + let academicYears = []; let semesters = []; + let selectedVisualYearId = ''; + let selectedVisualSemesterId = ''; let selectedVisualGroupIds = new Set(); let visualGroupFilterTouched = false; + let visualPeriodTouched = false; + let syncingVisualPeriod = false; bindEvents(); @@ -116,17 +123,30 @@ export async function initSchedule() { visualToggleButton?.addEventListener('click', toggleVisualDrawer); visualCloseButton?.addEventListener('click', closeVisualDrawer); visualBackdrop?.addEventListener('click', closeVisualDrawer); + visualYearSelect?.addEventListener('change', handleVisualYearChange); + visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange); visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); + visualStage?.addEventListener('click', handleVisualStageClick); visualUseFormButton?.addEventListener('click', () => { + const formSemesterId = ruleSemesterSelect.value; + if (formSemesterId) { + visualPeriodTouched = true; + setVisualPeriodBySemesterId(formSemesterId); + } visualGroupFilterTouched = false; selectedVisualGroupIds = new Set(); renderVisualSchedule(); }); visualSelectAllButton?.addEventListener('click', () => { - selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id))); + selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id))); visualGroupFilterTouched = true; renderVisualSchedule(); }); + ruleSemesterSelect?.addEventListener('change', () => { + if (visualPeriodTouched) return; + selectDefaultVisualPeriod(); + renderVisualSchedule(); + }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeVisualDrawer(); }); @@ -190,15 +210,19 @@ export async function initSchedule() { } async function loadYears() { - const academicYears = await api.get('/api/admin/calendar/years'); + academicYears = await api.get('/api/admin/calendar/years'); semesters = academicYears.flatMap(year => year.semesters || []); populateSemesterSelects(); + selectDefaultVisualPeriod(); } async function loadRules() { rulesTbody.innerHTML = '