Добавил вкладку заявок

This commit is contained in:
dipatrik10
2026-06-02 16:07:30 +03:00
parent 6d9f6b7a42
commit 08b8607f05
11 changed files with 456 additions and 157 deletions

View File

@@ -777,11 +777,7 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
<<<<<<< HEAD | `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
=======
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

@@ -129,7 +129,8 @@ frontend/
| Tab | Описание | API | | Tab | Описание | API |
|-----|----------|-----| |-----|----------|-----|
| `users` | CRUD пользователей и очередь заявок кафедр на создание преподавателей | `/api/users`, `/api/teacher-requests` | | `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
| `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` | | `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
@@ -146,14 +147,11 @@ frontend/
### Особенности админских вкладок ### Особенности админских вкладок
<<<<<<< HEAD
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `users` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`.
=======
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
>>>>>>> 43d72c52356686ada2a54d73eccfdf491579fa0c - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.

View File

@@ -385,6 +385,22 @@
align-items: end; align-items: end;
} }
.department-workspace-control-card {
display: grid;
gap: 1rem;
}
.department-workspace-control-card .card-header-row {
margin-bottom: 0;
}
.department-workspace-filter-grid {
display: grid;
grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 0.65fr));
gap: 1rem;
align-items: end;
}
.schedule-view-metrics, .schedule-view-metrics,
.department-workspace-grid { .department-workspace-grid {
display: grid; display: grid;
@@ -397,7 +413,63 @@
} }
.department-workspace-grid-wide { .department-workspace-grid-wide {
grid-template-columns: repeat(4, minmax(220px, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
align-items: stretch;
}
.department-workspace-actions-grid {
gap: 1rem;
}
.department-subject-import-card {
grid-column: span 7;
grid-row: span 2;
}
.department-teacher-assign-card {
grid-column: span 5;
}
.department-teacher-request-card {
grid-column: span 5;
}
.department-teachers-card {
grid-column: 1 / -1;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card,
.department-workspace-table-card {
min-width: 0;
}
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card {
display: flex;
flex-direction: column;
}
.department-subject-import-card .stack-form,
.department-teacher-assign-card .stack-form,
.department-teacher-request-card .stack-form {
display: flex;
flex-direction: column;
flex: 1;
}
.department-subject-import-card .subject-import-slots-panel {
flex: 1;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn {
align-self: flex-start;
margin-top: auto;
} }
.teacher-request-actions { .teacher-request-actions {
@@ -407,6 +479,12 @@
min-width: 180px; min-width: 180px;
} }
.teacher-request-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
}
#teacher-requests-tbody input, #teacher-requests-tbody input,
#teacher-requests-tbody select { #teacher-requests-tbody select {
min-width: 150px; min-width: 150px;
@@ -427,7 +505,6 @@
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-glow);
} }
.department-subject-import-card,
.department-teachers-card { .department-teachers-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -575,7 +652,7 @@
.subject-import-row { .subject-import-row {
display: grid; display: grid;
grid-template-columns: 160px minmax(260px, 1fr) auto; grid-template-columns: minmax(120px, 0.7fr) minmax(240px, 1.3fr) auto;
column-gap: 0.65rem; column-gap: 0.65rem;
row-gap: 0.75rem; row-gap: 0.75rem;
align-items: end; align-items: end;
@@ -722,6 +799,10 @@
min-width: 0; min-width: 0;
} }
.subject-import-row .btn {
min-height: 42px;
}
.subject-import-row .form-group label { .subject-import-row .form-group label {
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
} }
@@ -731,6 +812,14 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.department-subject-import-card,
.department-teacher-assign-card,
.department-teacher-request-card,
.department-teachers-card {
grid-column: 1;
grid-row: auto;
}
.academic-year-semesters { .academic-year-semesters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -740,6 +829,40 @@
} }
} }
@media (max-width: 1280px) and (min-width: 901px) {
.department-workspace-grid-wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.department-subject-import-card,
.department-teachers-card {
grid-column: 1 / -1;
grid-row: auto;
}
.department-teacher-assign-card,
.department-teacher-request-card {
grid-column: span 1;
}
}
@media (max-width: 700px) {
.department-workspace-filter-grid {
grid-template-columns: 1fr;
}
.subject-import-row {
grid-template-columns: 1fr;
}
.department-subject-import-card .stack-form > .btn,
.department-teacher-assign-card .stack-form > .btn,
.department-teacher-request-card .stack-form > .btn,
.subject-import-row .btn {
width: 100%;
}
}
.academic-year-semester-meta { .academic-year-semester-meta {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
@@ -1869,7 +1992,7 @@
@media (max-width: 1350px) { @media (max-width: 1350px) {
.schedule-view-filter-grid, .schedule-view-filter-grid,
.schedule-view-metrics, .schedule-view-metrics,
.department-workspace-grid { .department-workspace-grid:not(.department-workspace-grid-wide) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -104,6 +104,7 @@
} }
.nav-item svg { .nav-item svg {
flex: 0 0 auto;
transition: transform var(--transition); transition: transform var(--transition);
} }
@@ -112,6 +113,38 @@
transform: scale(1.15) rotate(-5deg); transform: scale(1.15) rotate(-5deg);
} }
.nav-item span:not(.nav-badge) {
min-width: 0;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
margin-left: auto;
padding: 0 0.45rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 999px;
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
}
.nav-badge[hidden],
.topbar-request-badge[hidden] {
display: none !important;
}
[data-theme="light"] .nav-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.sidebar-footer { .sidebar-footer {
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -247,7 +280,7 @@
display: none; display: none;
} }
.sidebar.collapsed .nav-item span, .sidebar.collapsed .nav-item span:not(.nav-badge),
.sidebar.collapsed .btn-settings span { .sidebar.collapsed .btn-settings span {
position: absolute; position: absolute;
left: calc(100% + 10px); left: calc(100% + 10px);
@@ -271,13 +304,13 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }
[data-theme="light"] .sidebar.collapsed .nav-item span, [data-theme="light"] .sidebar.collapsed .nav-item span:not(.nav-badge),
[data-theme="light"] .sidebar.collapsed .btn-settings span { [data-theme="light"] .sidebar.collapsed .btn-settings span {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.sidebar.collapsed .nav-item:hover span, .sidebar.collapsed .nav-item:hover span:not(.nav-badge),
.sidebar.collapsed .btn-settings:hover span { .sidebar.collapsed .btn-settings:hover span {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
@@ -298,6 +331,16 @@
padding: 0.75rem 0; padding: 0.75rem 0;
overflow: visible; overflow: visible;
} }
.sidebar.collapsed .nav-badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
min-width: 18px;
height: 18px;
padding: 0 0.3rem;
font-size: 0.65rem;
}
.sidebar.collapsed .btn-settings { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
@@ -333,9 +376,44 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
}
.topbar-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1; flex: 1;
} }
.topbar-request-badge {
min-height: 30px;
padding: 0 0.75rem;
border: 1px solid rgba(248, 113, 113, 0.26);
border-radius: 999px;
background: rgba(248, 113, 113, 0.13);
color: #fecaca;
font: inherit;
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.topbar-request-badge:hover {
transform: translateY(-1px);
background: rgba(248, 113, 113, 0.2);
border-color: rgba(248, 113, 113, 0.42);
}
[data-theme="light"] .topbar-request-badge {
background: #fee2e2;
border-color: #fecaca;
color: #b91c1c;
}
.content { .content {
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
display: flex; display: flex;

View File

@@ -66,6 +66,17 @@
</svg> </svg>
<span>Дашборд</span> <span>Дашборд</span>
</a> </a>
<a href="#" class="nav-item" data-tab="teacher-requests">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M8 11h8" />
<path d="M8 16h5" />
</svg>
<span>Заявки</span>
<span class="nav-badge" id="teacher-requests-nav-badge" hidden>0</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace"> <a href="#" class="nav-item" data-tab="department-workspace">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -221,7 +232,10 @@
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<h1 id="page-title">Загрузка...</h1> <div class="topbar-title-group">
<h1 id="page-title">Загрузка...</h1>
<button type="button" class="topbar-request-badge" id="teacher-requests-title-badge" hidden>0 заявок</button>
</div>
</header> </header>
<!-- Dynamic Content Injected Here --> <!-- Dynamic Content Injected Here -->

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
} }
import { isAuthenticatedAsAny, logout } from './api.js'; import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -38,6 +38,14 @@ const ROUTES = {
await module.initUsers(); await module.initUsers();
} }
}, },
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: { groups: {
title: 'Управление группами', title: 'Управление группами',
file: '/admin/views/groups.html', file: '/admin/views/groups.html',
@@ -115,7 +123,7 @@ const ROUTES = {
const ROLE_NAVIGATION = { const ROLE_NAVIGATION = {
ADMIN: { ADMIN: {
defaultTab: 'dashboard', defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'] tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
}, },
EDUCATION_OFFICE: { EDUCATION_OFFICE: {
defaultTab: 'dashboard', defaultTab: 'dashboard',
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn'); const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout'); const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main'); const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER; const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs); const allowedTabs = new Set(roleNavigation.tabs);
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect(); applyRippleEffect();
closeAllDropdownsOnOutsideClick(); closeAllDropdownsOnOutsideClick();
configureRoleInterface(); configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load // Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') { if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/'; window.location.href = '/';
}); });
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation // Navigation
navItems.forEach(item => { navItems.forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
@@ -271,6 +286,42 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle; document.querySelector('.logo span').textContent = firstAllowedTitle;
} }
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', ''); const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab); switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);

View File

@@ -0,0 +1,136 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
}
}

View File

@@ -21,9 +21,6 @@ const ROLE_BADGE = {
export async function initUsers() { export async function initUsers() {
const usersTbody = document.getElementById('users-tbody'); const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form'); const createForm = document.getElementById('create-form');
const teacherRequestsTbody = document.getElementById('teacher-requests-tbody');
const teacherRequestsRefresh = document.getElementById('teacher-requests-refresh');
let departments = [];
async function loadUsers() { async function loadUsers() {
try { try {
@@ -36,53 +33,6 @@ export async function initUsers() {
} }
} }
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadTeacherRequests() {
if (!teacherRequestsTbody) return;
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
renderTeacherRequests(Array.isArray(requests) ? requests : []);
} catch (error) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
}
function renderTeacherRequests(requests) {
if (!requests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function renderUsers(users) { function renderUsers(users) {
if (!users || !users.length) { if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>'; usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
@@ -143,54 +93,6 @@ export async function initUsers() {
} }
}); });
teacherRequestsRefresh?.addEventListener('click', loadTeacherRequests);
teacherRequestsTbody?.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await Promise.all([loadTeacherRequests(), loadUsers()]);
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
return;
}
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadTeacherRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
});
usersTbody.addEventListener('click', async (e) => { usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-archive-user'); const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return; if (!deleteBtn) return;
@@ -216,6 +118,5 @@ export async function initUsers() {
} }
}); });
await loadDepartments(); await loadUsers();
await Promise.all([loadUsers(), loadTeacherRequests()]);
} }

View File

@@ -1,10 +1,10 @@
<div class="card"> <div class="card department-workspace-control-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Рабочая область кафедры</h2> <h2>Рабочая область кафедры</h2>
<button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button> <button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
</div> </div>
<div class="schedule-view-filter-grid"> <div class="department-workspace-filter-grid">
<div class="form-group" id="department-workspace-department-field"> <div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label> <label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true"> <select id="department-workspace-department" data-native-select="true">
@@ -24,8 +24,8 @@
<div class="form-alert" id="department-workspace-alert" role="alert"></div> <div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div> </div>
<div class="department-workspace-grid department-workspace-grid-wide"> <div class="department-workspace-grid department-workspace-grid-wide department-workspace-actions-grid">
<div class="card create-card"> <div class="card create-card department-subject-import-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Загрузка дисциплин</h2> <h2>Загрузка дисциплин</h2>
</div> </div>
@@ -42,7 +42,7 @@
</form> </form>
</div> </div>
<div class="card create-card"> <div class="card create-card department-teacher-assign-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Добавить преподавателя</h2> <h2>Добавить преподавателя</h2>
</div> </div>
@@ -62,7 +62,7 @@
</form> </form>
</div> </div>
<div class="card create-card"> <div class="card create-card department-teacher-request-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Заявка на преподавателя</h2> <h2>Заявка на преподавателя</h2>
</div> </div>
@@ -88,7 +88,7 @@
</form> </form>
</div> </div>
<div class="card"> <div class="card department-teachers-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Преподаватели и нагрузка</h2> <h2>Преподаватели и нагрузка</h2>
</div> </div>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card department-workspace-table-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Заявки кафедры</h2> <h2>Заявки кафедры</h2>
<span class="muted" id="department-teacher-request-count">0 заявок</span> <span class="muted" id="department-teacher-request-count">0 заявок</span>
@@ -120,7 +120,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card department-workspace-table-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Дисциплины кафедры</h2> <h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span> <span class="muted" id="department-subject-count">0 дисциплин</span>
@@ -142,7 +142,7 @@
</div> </div>
</div> </div>
<div class="card" id="department-comments-card" hidden> <div class="card department-workspace-table-card" id="department-comments-card" hidden>
<div class="card-header-row"> <div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2> <h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button> <button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>

View File

@@ -0,0 +1,28 @@
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<div class="teacher-request-toolbar">
<span class="muted" id="teacher-requests-count">0 заявок</span>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>

View File

@@ -40,32 +40,6 @@
</form> </form>
</div> </div>
<div class="card">
<div class="card-header-row">
<h2>Заявки на преподавателей</h2>
<button type="button" class="btn btn-sm btn-secondary" id="teacher-requests-refresh">Обновить</button>
</div>
<div class="form-alert" id="teacher-requests-alert" role="alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card"> <div class="card">
<h2>Все пользователи</h2> <h2>Все пользователи</h2>
<div class="table-wrap"> <div class="table-wrap">