From 30972db9eec38cb7e2a97d6d4d6dd7d600022476 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Sun, 31 May 2026 21:36:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20=D0=BA=D0=BE=D1=81?= =?UTF-8?q?=D1=8F=D0=BA=D0=BE=D0=B2=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 4 +- frontend/admin/css/components.css | 110 +++++++++- frontend/admin/js/views/schedule-view.js | 51 ++++- frontend/admin/js/views/schedule.js | 257 ++++++++++++++++++++--- frontend/admin/views/schedule.html | 14 ++ 5 files changed, 399 insertions(+), 37 deletions(-) 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 = 'Загрузка...'; try { rules = await api.get('/api/admin/schedule-rules'); + if (!visualPeriodTouched) { + selectDefaultVisualPeriod(); + } renderRules(); renderVisualSchedule(); } catch (error) { @@ -222,8 +246,10 @@ export async function initSchedule() { ${renderRuleLoadSummary(rule)} ${renderRuleSlotsSummary(rule.slots)} - - +
+ + +
`).join(''); @@ -262,16 +288,32 @@ export async function initSchedule() { function renderVisualSchedule() { if (!visualStage || !visualSummary) return; + if (!selectedVisualSemesterId) { + syncVisualGroupFilter([]); + visualSummary.textContent = 'Выберите учебный год и семестр'; + setVisualState('Выберите период, чтобы увидеть правила конкретного семестра'); + return; + } if (!rules.length) { + syncVisualGroupFilter([]); visualSummary.textContent = 'Нет сохранённых правил'; setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам'); return; } - const allGroups = collectVisualGroups(); + const visualRules = visualRulesForSelectedPeriod(); + const periodLabel = visualPeriodLabel(); + if (!visualRules.length) { + syncVisualGroupFilter([]); + visualSummary.textContent = `${periodLabel} · правил нет`; + setVisualState('В выбранном семестре нет сохранённых правил расписания'); + return; + } + + const allGroups = collectVisualGroups(visualRules); syncVisualGroupFilter(allGroups); const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id))); - const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(); + const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules); if (!allGroups.length || !slots.length) { visualSummary.textContent = 'Недостаточно данных для построения'; setVisualState('У правил должны быть группы и слоты занятий'); @@ -283,15 +325,15 @@ export async function initSchedule() { return; } - const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds); + const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules); const rows = buildVisualRows(columns, slots, lessonsByCell); if (!rows.length) { - visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; - setVisualState('Для выбранных групп нет активных пар в сохранённых правилах'); + visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; + setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра'); return; } - visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`; + visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`; visualStage.innerHTML = `
@@ -348,10 +390,10 @@ export async function initSchedule() { `; } - function buildVisualLessonsByCell(allowedGroupIds) { + function buildVisualLessonsByCell(allowedGroupIds, visualRules) { const grouped = new Map(); - const activeWeeks = buildActiveWeeksByRuleSlot(); - rules.forEach(rule => { + const activeWeeks = buildActiveWeeksByRuleSlot(visualRules); + visualRules.forEach(rule => { (rule.slots || []).forEach((slot, index) => { const slotKey = visualRuleSlotKey(slot, index); (rule.groupIds || []).forEach((groupId, groupIndex) => { @@ -360,6 +402,7 @@ export async function initSchedule() { if (!weeks.length) return; const lesson = { id: `${rule.id}:${slotKey}:${groupId}`, + ruleId: rule.id, parity: slot.parity || 'BOTH', subjectName: rule.subjectName || 'Без дисциплины', teacherName: slot.teacherName || '', @@ -367,7 +410,7 @@ export async function initSchedule() { lessonTypeName: slot.lessonTypeName || '', lessonFormat: slot.lessonFormat || '', subgroupNames: slotSubgroupNames(slot), - weekText: compactWeekRanges(weeks), + weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity), groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId) }; const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot)); @@ -380,9 +423,9 @@ export async function initSchedule() { return grouped; } - function buildActiveWeeksByRuleSlot() { + function buildActiveWeeksByRuleSlot(visualRules) { const activeWeeks = new Map(); - rules.forEach(rule => { + visualRules.forEach(rule => { const maxWeeks = maxWeeksForRule(rule); (rule.groupIds || []).forEach(groupId => { LESSON_TYPE_LIMITS.forEach(config => { @@ -454,13 +497,18 @@ export async function initSchedule() { ${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` ${escapeHtml(lesson.weekText)}` : ''} ${subgroupText ? `${escapeHtml(subgroupText)}` : ''} ${meta ? `${escapeHtml(meta)}` : ''} + `; } - function collectVisualGroups() { + function collectVisualGroups(visualRules = rules) { const byId = new Map(); - rules.forEach(rule => { + visualRules.forEach(rule => { (rule.groupIds || []).forEach((id, index) => { if (!byId.has(String(id))) { byId.set(String(id), { @@ -473,9 +521,9 @@ export async function initSchedule() { return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name)); } - function collectVisualSlotsFromRules() { + function collectVisualSlotsFromRules(visualRules = rules) { const byKey = new Map(); - rules.flatMap(rule => rule.slots || []).forEach(slot => { + visualRules.flatMap(rule => rule.slots || []).forEach(slot => { const key = visualSlotKey(slot); if (!byKey.has(key)) { byKey.set(key, { @@ -511,6 +559,39 @@ export async function initSchedule() { renderVisualSchedule(); } + function handleVisualYearChange() { + if (syncingVisualPeriod) return; + selectedVisualYearId = visualYearSelect?.value || ''; + selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id + ? String(firstSemesterForYear(selectedVisualYearId).id) + : ''; + visualPeriodTouched = true; + visualGroupFilterTouched = false; + populateVisualSemesterSelect(); + renderVisualSchedule(); + } + + function handleVisualSemesterChange() { + if (syncingVisualPeriod) return; + selectedVisualSemesterId = visualSemesterSelect?.value || ''; + visualPeriodTouched = true; + visualGroupFilterTouched = false; + renderVisualSchedule(); + } + + function handleVisualStageClick(event) { + const editButton = event.target.closest('.schedule-visual-edit-rule'); + if (!editButton) return; + const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId)); + if (!rule) { + showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); + return; + } + fillRuleForm(rule); + closeVisualDrawer(); + showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success'); + } + function syncVisualGroupFilter(allGroups) { ensureVisualGroupSelection(allGroups); if (!visualGroupFilter) return; @@ -547,6 +628,92 @@ export async function initSchedule() { } } + function selectDefaultVisualPeriod() { + const formSemester = semesterById(ruleSemesterSelect?.value); + const ruleSemester = firstSemesterWithRules(); + const fallbackSemester = semesters[0]; + const semester = formSemester || ruleSemester || fallbackSemester; + setVisualPeriodBySemesterId(semester?.id || ''); + } + + function setVisualPeriodBySemesterId(semesterId) { + const semester = semesterById(semesterId); + selectedVisualSemesterId = semester ? String(semester.id) : ''; + selectedVisualYearId = semester ? String(semester.academicYearId) : ''; + populateVisualYearSelect(); + populateVisualSemesterSelect(); + } + + function populateVisualYearSelect() { + if (!visualYearSelect) return; + visualYearSelect.innerHTML = '' + + academicYears.map(year => ``).join(''); + if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) { + visualYearSelect.value = selectedVisualYearId; + } else if (academicYears.length) { + selectedVisualYearId = String(academicYears[0].id); + visualYearSelect.value = selectedVisualYearId; + } else { + selectedVisualYearId = ''; + visualYearSelect.value = ''; + } + syncVisualPeriodSelects(visualYearSelect); + } + + function populateVisualSemesterSelect() { + if (!visualSemesterSelect) return; + const yearSemesters = semestersForYear(selectedVisualYearId); + visualSemesterSelect.innerHTML = yearSemesters.length + ? '' + yearSemesters + .map(semester => ``) + .join('') + : ''; + + if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) { + visualSemesterSelect.value = selectedVisualSemesterId; + } else if (yearSemesters.length) { + selectedVisualSemesterId = String(yearSemesters[0].id); + visualSemesterSelect.value = selectedVisualSemesterId; + } else { + selectedVisualSemesterId = ''; + visualSemesterSelect.value = ''; + } + syncVisualPeriodSelects(visualSemesterSelect); + } + + function syncVisualPeriodSelects(...selects) { + syncingVisualPeriod = true; + syncSelects(...selects); + syncingVisualPeriod = false; + } + + function visualRulesForSelectedPeriod() { + if (!selectedVisualSemesterId) return []; + return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId); + } + + function firstSemesterWithRules() { + const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId))); + return semesters.find(semester => ruleSemesterIds.has(String(semester.id))); + } + + function semesterById(id) { + return semesters.find(semester => String(semester.id) === String(id)); + } + + function semestersForYear(yearId) { + return semesters.filter(semester => String(semester.academicYearId) === String(yearId)); + } + + function firstSemesterForYear(yearId) { + return semestersForYear(yearId)[0] || null; + } + + function visualPeriodLabel() { + const semester = semesterById(selectedVisualSemesterId); + return semester ? shortSemesterLabel(semester) : 'Семестр не выбран'; + } + function renderRuleSlotsSummary(slots) { if (!slots || !slots.length) return 'Нет слотов'; return slots.map(slot => { @@ -687,7 +854,7 @@ export async function initSchedule() {
-
+
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
@@ -1022,6 +1189,23 @@ export async function initSchedule() { return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; } + function orderedLessonTypes() { + return [...lessonTypes].sort(compareLessonTypes); + } + + function compareLessonTypes(first, second) { + return lessonTypeRank(first) - lessonTypeRank(second) + || naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label); + } + + function lessonTypeRank(lessonType) { + const key = lessonTypeKey(lessonType); + if (key === 'lecture') return 0; + if (key === 'laboratory') return 1; + if (key === 'practice') return 2; + return 3; + } + function lessonTypeKeyById(lessonTypeId) { const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId)); return lessonType ? lessonTypeKey(lessonType) : null; @@ -1048,6 +1232,11 @@ export async function initSchedule() { return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; } + function shortSemesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`; + } + function formatSemester(rule) { const type = normalizeSemesterType(rule.semesterType); return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; @@ -1106,16 +1295,34 @@ export async function initSchedule() { return new Date(year, month - 1, day); } - function compactWeekRanges(weeks) { + function compactWeekRanges(weeks, maxWeeks, parity) { const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b); if (!uniqueWeeks.length) return ''; - return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`; + const start = uniqueWeeks[0]; + const end = uniqueWeeks[uniqueWeeks.length - 1]; + const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity); + const semesterStartWeek = firstApplicableSemesterWeek(parity); + if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return ''; + if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`; + return weekRangeText(start, end); } function weekRangeText(start, end) { return start === end - ? `${start} нед.` - : `с ${start} по ${end} нед.`; + ? `(${start} нед.)` + : `(с ${start} по ${end} нед.)`; + } + + 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 compareSlotItems(first, second) { diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 4fc6913..56b114d 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -136,6 +136,20 @@
+
+
+ + +
+
+ + +
+
Группы