diff --git a/docs/API.md b/docs/API.md
index da8d84b..2e1c6f7 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -203,7 +203,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
}
```
-Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
+Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments`, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
### Учебные годы, семестры и коды календарного графика
diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md
index 61ce347..16402f5 100644
--- a/docs/BUSINESS_LOGIC.md
+++ b/docs/BUSINESS_LOGIC.md
@@ -101,7 +101,7 @@
### Временные слоты
-Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты.
+Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате.
Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание.
diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md
index 58177b2..3822004 100644
--- a/docs/FRONTEND.md
+++ b/docs/FRONTEND.md
@@ -124,7 +124,7 @@ frontend/
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила.
-- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
+- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
@@ -136,7 +136,7 @@ frontend/
- Кнопка «Назад в панель» для возврата в `/admin/`
- Текущие вкладки:
- **Общие настройки** — заглушка (в разработке)
- - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление/удаление ручных сеток и назначение ручной сетки на конкретную дату. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам.
+ - **Временные слоты** — выбор сетки через выпадающий список, CRUD слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
---
diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css
index bc05ef2..774464e 100755
--- a/frontend/admin/css/components.css
+++ b/frontend/admin/css/components.css
@@ -316,6 +316,19 @@
font-weight: 800;
}
+.calendar-day-time-marker {
+ position: absolute;
+ right: 1px;
+ bottom: 1px;
+ padding: 0 2px;
+ border-radius: 3px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 0.45rem;
+ font-weight: 800;
+ line-height: 1.15;
+}
+
.calendar-day-activity {
display: none !important;
}
@@ -411,6 +424,19 @@
font-size: 0.85rem;
}
+.calendar-day-dialog-time-grid {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--bg-card-border);
+}
+
+.calendar-day-dialog-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin-top: 0.8rem;
+}
+
.profiles-context {
color: var(--text-secondary);
font-weight: 600;
@@ -471,6 +497,11 @@ input[type="number"] {
font-family: inherit;
}
+.select-layer-active {
+ position: relative;
+ z-index: 300;
+}
+
.custom-select-trigger {
display: flex;
align-items: center;
diff --git a/frontend/admin/js/dropdown.js b/frontend/admin/js/dropdown.js
index 4af5ab9..36ccf86 100644
--- a/frontend/admin/js/dropdown.js
+++ b/frontend/admin/js/dropdown.js
@@ -2,10 +2,17 @@
export class CustomSelect {
constructor(originalSelect) {
- if (originalSelect.classList.contains('custom-select-initialized')) return;
+ if (originalSelect.classList.contains('custom-select-initialized')) {
+ if (originalSelect.customSelectInstance) {
+ return originalSelect.customSelectInstance;
+ }
+ originalSelect.classList.remove('custom-select-initialized');
+ }
this.originalSelect = originalSelect;
this.originalSelect.classList.add('custom-select-initialized');
+ this.originalSelect.customSelectInstance = this;
+ this.layerParent = this.originalSelect.closest('.settings-card, .card');
// Hide original but keep it accessible for form submissions and JS
this.originalSelect.style.display = 'none';
@@ -163,6 +170,7 @@ export class CustomSelect {
this.closeAll(); // Close other open dropdowns
if (!isOpen) {
+ this.layerParent?.classList.add('select-layer-active');
this.wrapper.classList.add('open');
// Scroll selected item into view
const selectedItem = this.menu.querySelector('.selected');
@@ -178,10 +186,14 @@ export class CustomSelect {
document.querySelectorAll('.custom-select-wrapper.open').forEach(wrapper => {
wrapper.classList.remove('open');
});
+ document.querySelectorAll('.select-layer-active').forEach(layer => {
+ layer.classList.remove('select-layer-active');
+ });
}
close() {
this.wrapper.classList.remove('open');
+ this.layerParent?.classList.remove('select-layer-active');
}
handleItemClick(e, index) {
@@ -198,12 +210,28 @@ export class CustomSelect {
// Global initializer
export function initAllCustomDropdowns(root = document) {
- const selects = root.querySelectorAll('select:not(.custom-select-initialized)');
+ const selects = root.querySelectorAll('select:not(.custom-select-initialized):not([data-native-select="true"])');
selects.forEach(select => {
new CustomSelect(select);
});
}
+export function refreshCustomDropdowns(...selects) {
+ selects.filter(Boolean).forEach(select => {
+ if (select.dataset.nativeSelect === 'true') {
+ return;
+ }
+ if (select.customSelectInstance) {
+ select.customSelectInstance.rebuildMenu();
+ return;
+ }
+ if (select.classList.contains('custom-select-initialized')) {
+ select.classList.remove('custom-select-initialized');
+ }
+ new CustomSelect(select);
+ });
+}
+
// Observe DOM for automatically picking up new select elements
export function startDropdownAutoObserver() {
const observer = new MutationObserver((mutations) => {
diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js
index ba6e222..9efd62f 100644
--- a/frontend/admin/js/views/academic-calendar.js
+++ b/frontend/admin/js/views/academic-calendar.js
@@ -67,6 +67,9 @@ export async function initAcademicCalendar() {
const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day');
const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary');
const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity');
+ const calendarDayDialogTimeScopeSelect = document.getElementById('calendar-day-dialog-time-scope');
+ const calendarDayDialogTimeSaveButton = document.getElementById('calendar-day-dialog-time-save');
+ const calendarDayDialogTimeClearButton = document.getElementById('calendar-day-dialog-time-clear');
let academicYears = [];
let semesters = [];
@@ -75,12 +78,21 @@ export async function initAcademicCalendar() {
let studyForms = [];
let activityTypes = [];
let calendars = [];
+ let timeSlotScopes = [];
+ let timeSlotDateAssignments = [];
let activeCalendarDay = null;
bindEvents();
try {
- await Promise.all([loadBaseLists(), loadYears(), loadStudyForms(), loadActivityTypes()]);
+ await Promise.all([
+ loadBaseLists(),
+ loadYears(),
+ loadStudyForms(),
+ loadActivityTypes(),
+ loadTimeSlotScopes(),
+ loadTimeSlotDateAssignments()
+ ]);
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error');
@@ -126,6 +138,8 @@ export async function initAcademicCalendar() {
updateCalendarDayDialogSummary(activeCalendarDay);
renderCalendarTotals();
});
+ calendarDayDialogTimeSaveButton?.addEventListener('click', saveTimeSlotDateAssignment);
+ calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
@@ -309,6 +323,14 @@ export async function initAcademicCalendar() {
populateActivitySelects();
}
+ async function loadTimeSlotScopes() {
+ timeSlotScopes = await api.get('/api/admin/time-slots/scopes');
+ }
+
+ async function loadTimeSlotDateAssignments() {
+ timeSlotDateAssignments = await api.get('/api/admin/time-slots/date-assignments');
+ }
+
async function loadCalendars() {
calendarsTbody.innerHTML = '