diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7b9c1d7..2e2d667 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -146,13 +146,14 @@ 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`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. Действия группы выводятся в `group-actions` с фиксированными колонками, чтобы активные и архивные строки не смещали кнопки. Кнопка восстановления архивной группы использует стиль `btn-restore-group`: размеры и поведение как у архивной кнопки, но с жёлтым warning-акцентом. +- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной компактной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Блоки загрузки дисциплин и преподавателей стоят в равных по высоте колонках; загрузка дисциплин использует фиксированную колонку кода и равномерные отступы до названия, а у карточки преподавателей задан стабильный зазор между заголовком и нагрузкой. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `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`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Типы занятий в селекте слотов сортируются в том же порядке, что и блоки часов: лекция, лабораторная, практика. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. - Вкладка `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}`. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. @@ -230,6 +231,8 @@ export const api = { - `TEACHER` → `/teacher/` - `STUDENT` → `/student/` +При неуспешной авторизации `script.js` оставляет введённый логин, очищает поле пароля и возвращает фокус в него. + ### Проверка авторизации На каждой странице проверяется наличие токена и роли: @@ -309,6 +312,10 @@ export function isAuthenticatedAsAdmin() { 4. **`modals.css`** — Модальные окна 5. **`departments-data.css`** — Стили создания кафедры/специальности +Мультиселекты с чекбоксами используют общий паттерн `.checkbox-item` + `.checkmark` + `.checkbox-label`; вкладка `classrooms` применяет его для выбора оборудования в создании и редактировании аудитории. + +Дашборд использует классы `dashboard-container`, `dashboard-metrics-grid`, `dashboard-middle` и `dashboard-side-stack`: средний ряд растягивается по высоте самого высокого столбца, а правые компактные карточки выравниваются одинаковыми строками. + ### Темизация CSS-переменные позволяют поддерживать светлую/тёмную тему: @@ -338,6 +345,8 @@ CSS-переменные позволяют поддерживать светл - **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением - **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода +- **Секционные подписи sidebar** (`Расписание`, `Ресурсы`, `Система`) оформляются в `layout.css`, а их контраст для светлой и тёмной темы задаётся переменными `--sidebar-section-title` и `--sidebar-section-divider` в `main.css`. + --- ## OpenTelemetry (`otel.js`) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c731669..afa2fa1 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -48,6 +48,189 @@ letter-spacing: 0.04em; } +/* ===== Dashboard ===== */ +.dashboard-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.dashboard-metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 1.25rem; +} + +.dashboard-metric-card { + --metric-color: var(--accent); + --metric-bg: rgba(99, 102, 241, 0.1); + display: flex; + align-items: center; + gap: 1rem; + min-height: 112px; + padding: 1.25rem; + border-left: 4px solid var(--metric-color); +} + +.dashboard-metric-card--students { + --metric-color: #10b981; + --metric-bg: rgba(16, 185, 129, 0.1); +} + +.dashboard-metric-card--teachers { + --metric-color: #f59e0b; + --metric-bg: rgba(245, 158, 11, 0.1); +} + +.dashboard-metric-card--classrooms { + --metric-color: #3b82f6; + --metric-bg: rgba(59, 130, 246, 0.1); +} + +.dashboard-metric-card .metric-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 48px; + width: 48px; + height: 48px; + color: var(--metric-color); + background: var(--metric-bg); + border-radius: 12px; +} + +.dashboard-middle { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); + gap: 1.25rem; + align-items: stretch; +} + +.dashboard-monitor-card, +.dashboard-compact-card { + display: flex; + flex-direction: column; +} + +.dashboard-monitor-card { + min-height: 100%; +} + +.dashboard-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.dashboard-card-header h2 { + margin: 0; +} + +.dashboard-time-slot { + flex: 0 0 auto; + padding: 0.35rem 0.75rem; + border-radius: 999px; + color: var(--accent-hover); + background: var(--bg-hover); + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; +} + +.dashboard-monitor-table { + flex: 1; +} + +.dashboard-side-stack { + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 1.25rem; + min-height: 100%; +} + +.dashboard-compact-card { + min-height: 0; + padding: 1.25rem; + gap: 0.75rem; +} + +.dashboard-card-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.dashboard-badge-list { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.dashboard-actions { + display: grid; + grid-template-columns: 1fr; + gap: 0.65rem; + margin-top: auto; +} + +.dashboard-action-link { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 40px; + padding: 0.6rem 0.85rem; + text-align: center; + text-decoration: none; + font-size: 0.9rem; +} + +.dashboard-action-link.btn-secondary { + color: var(--accent-hover); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + box-shadow: none; +} + +.dashboard-action-link.btn-secondary:hover { + color: var(--text-primary); + background: var(--bg-hover); + border-color: var(--accent); +} + +@media (max-width: 1200px) { + .dashboard-metrics-grid { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } + + .dashboard-middle { + grid-template-columns: 1fr; + } + + .dashboard-side-stack { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + } +} + +@media (max-width: 700px) { + .dashboard-metrics-grid, + .dashboard-side-stack { + grid-template-columns: 1fr; + } + + .dashboard-card-header { + align-items: flex-start; + flex-direction: column; + } +} + /* ===== Form Structure ===== */ .form-row { display: flex; @@ -167,14 +350,34 @@ gap: 1rem; } +.department-workspace-grid { + align-items: stretch; +} + +.department-subject-import-card, +.department-teachers-card { + display: flex; + flex-direction: column; + align-self: stretch; + min-height: 100%; +} + +.department-teachers-card .card-header-row { + margin-bottom: 0.85rem; +} + +.department-teachers-card .metric-grid { + margin-top: 0; +} + .metric-grid { display: grid; - gap: 0.75rem; + gap: 0.6rem; } .metric-card, .department-comment-item { - padding: 0.85rem; + padding: 0.8rem 0.85rem; background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); @@ -183,7 +386,7 @@ .metric-card strong, .department-comment-item strong { display: block; - margin-bottom: 0.35rem; + margin-bottom: 0.28rem; color: var(--text-primary); } @@ -299,8 +502,9 @@ .subject-import-row { display: grid; - grid-template-columns: minmax(120px, 1fr) minmax(200px, 3fr) auto; - gap: 0.75rem; + grid-template-columns: 160px minmax(260px, 1fr) auto; + column-gap: 0.65rem; + row-gap: 0.75rem; align-items: end; padding: 0.75rem; background: var(--bg-input); @@ -323,16 +527,97 @@ min-width: 260px; } -.schedule-slot-summary, -.schedule-semester-line { +.schedule-slot-summary { margin-bottom: 0.35rem; } -.schedule-semester-line { - display: flex; +.academic-year-semesters { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 0.35rem; + align-items: stretch; +} + +.academic-years-table tbody td { + padding-top: 0.6rem; + padding-bottom: 0.6rem; + vertical-align: middle; +} + +.academic-years-table thead th:first-child, +.academic-years-table thead th:nth-child(2), +.academic-years-table thead th:last-child { + text-align: center; +} + +.academic-year-center-cell { + text-align: center; + font-weight: 700; +} + +.academic-year-semesters-cell { + min-width: 520px; + vertical-align: middle; +} + +.academic-year-actions-cell { + text-align: center; + vertical-align: middle; +} + +.academic-year-actions { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 0.5rem; + justify-content: center; + gap: 0.4rem; + min-height: 100%; + white-space: nowrap; +} + +.academic-year-semester-item { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.65rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.subject-import-code-field, +.subject-import-name-field { + min-width: 0; +} + +.subject-import-row .form-group label { + margin-bottom: 0.45rem; +} + +@media (max-width: 900px) { + .academic-year-semesters { + grid-template-columns: 1fr; + } + + .academic-year-semesters-cell { + min-width: 360px; + } +} + +.academic-year-semester-meta { + display: grid; + gap: 0.2rem; +} + +.academic-year-semester-type { + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 700; +} + +.academic-year-semester-period { + color: var(--text-secondary); + font-size: 0.82rem; } .schedule-inline-table { @@ -1807,12 +2092,35 @@ input[type="number"] { box-shadow: 0 2px 10px var(--accent-glow); } -.btn-delete { +.group-actions-cell { + text-align: right; + white-space: nowrap; +} + +.group-actions { + display: grid; + grid-template-columns: 88px 104px 104px 116px; + gap: 0.35rem; + justify-content: end; + align-items: center; +} + +.group-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 28px; + padding: 0.35rem 0.65rem; + font-size: 0.82rem; + line-height: 1; + white-space: nowrap; +} + +.btn-delete, +.btn-restore-group { padding: 0.35rem 0.7rem; - background: rgba(248, 113, 113, 0.1); - border: 1px solid rgba(248, 113, 113, 0.2); border-radius: var(--radius-sm); - color: var(--error); font-family: inherit; font-size: 0.8rem; cursor: pointer; @@ -1821,11 +2129,28 @@ input[type="number"] { overflow: hidden; } +.btn-delete { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.2); + color: var(--error); +} + .btn-delete:hover { background: rgba(248, 113, 113, 0.2); transform: scale(1.05); } +.btn-restore-group { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.24); + color: var(--warning); +} + +.btn-restore-group:hover { + background: rgba(245, 158, 11, 0.2); + transform: scale(1.05); +} + .btn-icon-toggle { background: transparent; border: 1px solid var(--bg-card-border); @@ -1864,6 +2189,12 @@ input[type="number"] { transform: translateY(-1px); } +.group-actions .group-action-btn { + min-height: 28px; + padding: 0.35rem 0.65rem; + font-size: 0.82rem; +} + /* ===== Alerts ===== */ .form-alert { display: none; @@ -2284,4 +2615,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/css/layout.css b/frontend/admin/css/layout.css index 3265a1a..f2dabf7 100755 --- a/frontend/admin/css/layout.css +++ b/frontend/admin/css/layout.css @@ -56,6 +56,21 @@ padding: 0.75rem; } +.sidebar-section-divider { + height: 1px; + background: var(--sidebar-section-divider); + margin: 0.5rem 1.5rem; +} + +.sidebar-section-title { + padding: 0.5rem 1.25rem 0.25rem; + color: var(--sidebar-section-title); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + .nav-item { display: flex; align-items: center; @@ -306,6 +321,14 @@ padding: 0.75rem 0; overflow: visible; } + + .sidebar.collapsed .sidebar-section-title { + display: none; + } + + .sidebar.collapsed .sidebar-section-divider { + margin: 0.5rem; + } .sidebar.collapsed .btn-settings { justify-content: center; diff --git a/frontend/admin/css/main.css b/frontend/admin/css/main.css index a03d06e..6971959 100755 --- a/frontend/admin/css/main.css +++ b/frontend/admin/css/main.css @@ -16,6 +16,8 @@ --bg-input: rgba(255, 255, 255, 0.04); --bg-input-focus: rgba(255, 255, 255, 0.08); --bg-hover: rgba(255, 255, 255, 0.06); + --sidebar-section-title: rgba(248, 250, 252, 0.45); + --sidebar-section-divider: rgba(229, 231, 235, 0.1); /* Typography */ --text-primary: #f8fafc; @@ -48,6 +50,8 @@ --bg-input: rgba(0, 0, 0, 0.03); --bg-input-focus: rgba(0, 0, 0, 0.06); --bg-hover: rgba(0, 0, 0, 0.05); + --sidebar-section-title: rgba(51, 65, 85, 0.78); + --sidebar-section-divider: rgba(148, 163, 184, 0.32); --text-primary: #0f172a; --text-secondary: #475569; --text-placeholder: #94a3b8; diff --git a/frontend/admin/index.html b/frontend/admin/index.html index aebbf5d..32c9e05 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -16,19 +16,6 @@ - @@ -81,7 +68,7 @@ - + @@ -121,7 +108,7 @@ - + - + `
- ${escapeHtml(year.title)} - ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} - ${renderSemesterList(year.semesters || [])} - - - + ${escapeHtml(year.title)} + ${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))} + ${renderSemesterList(year.semesters || [])} + +
+ + +
`).join(''); @@ -230,12 +232,19 @@ export async function initAcademicCalendar() { function renderSemesterList(items) { if (!items.length) return 'Нет семестров'; - return items.map(semester => ` -
- ${escapeHtml(semesterLabel(semester))} - -
- `).join(''); + return `
${items.map(semester => { + const title = semesterTypeTitle(semester); + const period = formatDisplayDateRange(semester.startDate, semester.endDate); + return ` +
+
+ ${escapeHtml(title)} + ${escapeHtml(period)} +
+ +
+ `; + }).join('')}
`; } async function saveAcademicYear(event) { @@ -282,7 +291,7 @@ export async function initAcademicCalendar() { if (editSemesterButton) { const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); if (!semester) return; - semesterFormTitle.textContent = 'Редактирование семестра'; + semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`; semesterIdInput.value = semester.id; semesterYearSelect.value = semester.academicYearId || ''; semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); @@ -1164,7 +1173,13 @@ export async function initAcademicCalendar() { function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); - return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`; + } + + function semesterTypeTitle(semester) { + const type = normalizeSemesterType(semester.semesterType); + const label = SEMESTER_LABELS[type] || type || 'семестр'; + return label.charAt(0).toUpperCase() + label.slice(1); } function normalizeSemesterType(type) { @@ -1195,6 +1210,16 @@ export async function initAcademicCalendar() { return `${day}.${month}.${year}`; } + function formatDisplayDate(value) { + const [year, month, day] = String(value || '').split('-'); + if (!year || !month || !day) return String(value || ''); + return `${day}-${month}-${year}`; + } + + function formatDisplayDateRange(startDate, endDate) { + return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`; + } + function dateRangeCaption(rows) { if (!rows.length) return ''; const dates = rows.map(row => row.date).sort(); diff --git a/frontend/admin/js/views/classrooms.js b/frontend/admin/js/views/classrooms.js index aadbf07..93f933f 100755 --- a/frontend/admin/js/views/classrooms.js +++ b/frontend/admin/js/views/classrooms.js @@ -13,6 +13,7 @@ async function fetchEquipments() { function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) { const container = document.getElementById(containerId); if (!container) return; + const selected = new Set(selectedIds.map(id => String(id))); if (!equipments || !equipments.length) { container.innerHTML = '

Оборудование отсутствует

'; @@ -21,9 +22,10 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select } container.innerHTML = equipments.map(eq => ` -