From 44cfe8ab6ec0bf56876ff52425cb306a659341c2 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sat, 2 May 2026 01:34:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API.md | 2 +- docs/BUSINESS_LOGIC.md | 2 +- docs/FRONTEND.md | 4 +- frontend/admin/css/components.css | 31 +++++ frontend/admin/js/dropdown.js | 32 ++++- frontend/admin/js/views/academic-calendar.js | 115 ++++++++++++++- frontend/admin/settings/css/main.css | 91 +++++++++++- .../admin/settings/js/views/time-slots.js | 131 ++++++------------ frontend/admin/settings/views/time-slots.html | 123 ++++++++-------- frontend/admin/views/academic-calendar.html | 13 ++ 10 files changed, 390 insertions(+), 154 deletions(-) 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 = 'Загрузка...'; try { @@ -432,6 +454,7 @@ export async function initAcademicCalendar() { return; } try { + await loadTimeSlotDateAssignments(); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const gridRows = rows.length ? rows : buildDefaultGrid(calendar); renderCalendarGrid(gridRows); @@ -573,12 +596,15 @@ export async function initAcademicCalendar() { data-date="${row.date}" data-week="${row.weekNumber}" data-day="${row.dayOfWeek}" + data-time-assignment-id="${timeAssignmentForDate(row.date)?.id || ''}" + data-time-scope-id="${timeAssignmentForDate(row.date)?.scopeId || ''}" role="button" tabindex="0" aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}" title="${escapeHtml(`${formatLongDate(row.date)}, ${dayName}, ${row.weekNumber} неделя, ${code} - ${activity.name || ''}`)}" style="--activity-color:${escapeHtml(color)}"> ${escapeHtml(code)} + ${timeAssignmentForDate(row.date) ? 'вр.' : ''} `; @@ -594,7 +620,9 @@ export async function initAcademicCalendar() { activityTypes.map(toActivityOption), day.querySelector('.calendar-day-activity').value ); + populateTimeScopeDialogSelect(day.dataset.date); syncSelects(calendarDayDialogActivitySelect); + syncSelects(calendarDayDialogTimeScopeSelect); updateCalendarDayDialogSummary(day); calendarDayDialog.classList.add('open'); } @@ -619,6 +647,77 @@ export async function initAcademicCalendar() { `; } + async function saveTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const scopeId = calendarDayDialogTimeScopeSelect.value; + if (!scopeId) { + await clearTimeSlotDateAssignment(); + return; + } + try { + await api.post('/api/admin/time-slots/date-assignments', { + date: activeCalendarDay.dataset.date, + scopeId: Number(scopeId) + }); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка применена к дате', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка применения временной сетки', 'error'); + } + } + + async function clearTimeSlotDateAssignment() { + if (!activeCalendarDay) return; + hideAlert('calendar-day-dialog-time-alert'); + const assignment = timeAssignmentForDate(activeCalendarDay.dataset.date); + if (!assignment) { + calendarDayDialogTimeScopeSelect.value = ''; + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Для даты нет ручной сетки', 'success'); + return; + } + try { + await api.delete('/api/admin/time-slots/date-assignments/' + assignment.id); + await loadTimeSlotDateAssignments(); + updateTimeAssignmentVisuals(activeCalendarDay.dataset.date); + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + syncSelects(calendarDayDialogTimeScopeSelect); + showAlert('calendar-day-dialog-time-alert', 'Ручная сетка убрана', 'success'); + } catch (error) { + showAlert('calendar-day-dialog-time-alert', error.message || 'Ошибка удаления ручной сетки', 'error'); + } + } + + function populateTimeScopeDialogSelect(date) { + const assignment = timeAssignmentForDate(date); + const manualScopes = timeSlotScopes.filter(scope => scope.applyMode === 'MANUAL'); + const automaticLabel = automaticTimeScopeLabel(date); + calendarDayDialogTimeScopeSelect.innerHTML = `` + + manualScopes.map(scope => ``).join(''); + calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; + calendarDayDialogTimeSaveButton.disabled = manualScopes.length === 0 && !assignment; + calendarDayDialogTimeClearButton.disabled = !assignment; + } + + function updateTimeAssignmentVisuals(date) { + const assignment = timeAssignmentForDate(date); + calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { + day.dataset.timeAssignmentId = assignment?.id || ''; + day.dataset.timeScopeId = assignment?.scopeId || ''; + const marker = day.querySelector('.calendar-day-time-marker'); + if (assignment && !marker) { + day.insertAdjacentHTML('beforeend', 'вр.'); + } + if (!assignment && marker) { + marker.remove(); + } + }); + } + function updateCalendarDayVisual(day) { if (!day) return; const select = day.querySelector('.calendar-day-activity'); @@ -784,6 +883,20 @@ export async function initAcademicCalendar() { return activityTypes.find(type => String(type.id) === String(id)); } + function timeAssignmentForDate(date) { + return timeSlotDateAssignments.find(item => item.date === date); + } + + function automaticTimeScopeLabel(date) { + const jsDate = new Date(`${date}T00:00:00`); + const jsDay = jsDate.getDay(); + const isoDay = jsDay === 0 ? 7 : jsDay; + const weekdayScope = timeSlotScopes.find(scope => scope.applyMode === 'WEEKDAY' && Number(scope.dayOfWeek) === isoDay); + const defaultScope = timeSlotScopes.find(scope => scope.applyMode === 'DEFAULT'); + const scopeName = weekdayScope?.name || defaultScope?.name || 'базовая сетка'; + return `По правилу даты (${scopeName})`; + } + function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; diff --git a/frontend/admin/settings/css/main.css b/frontend/admin/settings/css/main.css index 71e9ef8..8406f80 100644 --- a/frontend/admin/settings/css/main.css +++ b/frontend/admin/settings/css/main.css @@ -223,6 +223,10 @@ body { grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; } +.settings-form-single { + grid-template-columns: minmax(240px, 1fr); +} + .settings-form-assignment { grid-template-columns: minmax(150px, 0.8fr) minmax(190px, 1fr) auto; } @@ -257,6 +261,10 @@ body { box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16); } +.native-select-control { + cursor: pointer; +} + .btn-primary, .btn-secondary, .btn-delete { @@ -352,6 +360,13 @@ body { line-height: 1.45; } +.scope-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.6rem; +} + .table-wrap { overflow-x: auto; } @@ -428,6 +443,11 @@ body { width: 100%; } +.select-layer-active { + position: relative; + z-index: 300; +} + .custom-select-trigger { width: 100%; min-height: 42px; @@ -487,7 +507,7 @@ body { visibility: hidden; transform: translateY(-4px); transition: opacity var(--transition), transform var(--transition), visibility var(--transition); - z-index: 50; + z-index: 200; } [data-theme="light"] .custom-select-menu { @@ -519,6 +539,71 @@ body { opacity: 0.45; } +.settings-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.64); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.settings-modal-overlay.open { + display: flex; +} + +.settings-modal-card { + width: min(100%, 460px); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); + background: var(--bg-primary); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + padding: 1.25rem; +} + +.settings-modal-subtitle { + margin-top: 0.35rem; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.45; +} + +.settings-modal-close { + width: 36px; + height: 36px; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + transition: color var(--transition), border-color var(--transition), background var(--transition); +} + +.settings-modal-close:hover, +.settings-modal-close:focus { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + color: var(--error); +} + +.settings-modal-form { + display: grid; + gap: 1rem; + margin-top: 1.2rem; +} + +.settings-modal-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + @media (max-width: 1180px) { .settings-grid, .settings-form { @@ -528,6 +613,10 @@ body { .settings-form .btn-primary { width: fit-content; } + + .scope-header-actions { + justify-content: flex-start; + } } @media (max-width: 640px) { diff --git a/frontend/admin/settings/js/views/time-slots.js b/frontend/admin/settings/js/views/time-slots.js index f3dca27..84932e3 100644 --- a/frontend/admin/settings/js/views/time-slots.js +++ b/frontend/admin/settings/js/views/time-slots.js @@ -20,15 +20,14 @@ const DAY_SHORT_LABELS = { export async function initTimeSlotsSettings() { const scopeSelect = document.getElementById('time-slot-scope-select'); const scopeSummary = document.getElementById('time-slot-scope-summary'); + const scopeOpenCreateButton = document.getElementById('time-slot-scope-open-create'); + const scopeModal = document.getElementById('time-slot-scope-modal'); + const scopeModalCloseButton = document.getElementById('time-slot-scope-modal-close'); + const scopeModalCancelButton = document.getElementById('time-slot-scope-modal-cancel'); + const scopeForm = document.getElementById('time-slot-scope-form'); const scopeNameInput = document.getElementById('time-slot-scope-name'); - const scopeAddButton = document.getElementById('time-slot-scope-add'); const scopeDeleteButton = document.getElementById('time-slot-scope-delete'); - const assignmentForm = document.getElementById('time-slot-assignment-form'); - const assignmentDateInput = document.getElementById('time-slot-assignment-date'); - const assignmentScopeSelect = document.getElementById('time-slot-assignment-scope'); - const assignmentsTbody = document.getElementById('time-slot-assignments-tbody'); - const form = document.getElementById('time-slot-form'); const title = document.getElementById('time-slot-form-title'); const idInput = document.getElementById('time-slot-id'); @@ -42,37 +41,40 @@ export async function initTimeSlotsSettings() { let scopes = []; let slots = []; - let assignments = []; let activeScopeId = null; scopeSelect?.addEventListener('change', () => { activeScopeId = Number(scopeSelect.value); resetForm(); - render(); + renderActiveScope(); }); - scopeAddButton?.addEventListener('click', createScope); + scopeOpenCreateButton?.addEventListener('click', openScopeModal); + scopeModalCloseButton?.addEventListener('click', closeScopeModal); + scopeModalCancelButton?.addEventListener('click', closeScopeModal); + scopeModal?.addEventListener('click', (event) => { + if (event.target === scopeModal) closeScopeModal(); + }); + scopeModal?.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeScopeModal(); + }); + scopeForm?.addEventListener('submit', createScope); scopeDeleteButton?.addEventListener('click', deleteActiveScope); - assignmentForm?.addEventListener('submit', saveAssignment); form?.addEventListener('submit', saveSlot); resetButton?.addEventListener('click', resetForm); refreshButton?.addEventListener('click', loadAll); startInput?.addEventListener('change', fillDurationIfEmpty); endInput?.addEventListener('change', fillDurationIfEmpty); tbody?.addEventListener('click', handleSlotTableClick); - assignmentsTbody?.addEventListener('click', handleAssignmentTableClick); await loadAll(); async function loadAll() { tbody.innerHTML = 'Загрузка...'; - assignmentsTbody.innerHTML = 'Загрузка...'; hideAlert('time-slot-alert'); - hideAlert('time-slot-assignment-alert'); try { - [scopes, slots, assignments] = await Promise.all([ + [scopes, slots] = await Promise.all([ api.get('/api/admin/time-slots/scopes'), - api.get('/api/admin/time-slots'), - api.get('/api/admin/time-slots/date-assignments') + api.get('/api/admin/time-slots') ]); if (!activeScopeId || !scopes.some(scope => scope.id === activeScopeId)) { activeScopeId = defaultScope()?.id || scopes[0]?.id || null; @@ -80,28 +82,30 @@ export async function initTimeSlotsSettings() { render(); } catch (error) { tbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - assignmentsTbody.innerHTML = 'Нет данных'; } } function render() { renderScopeSelects(); + renderActiveScope(); + } + + function renderActiveScope() { + updateScopeActions(); renderScopeSummary(); renderSlots(); - renderAssignments(); } function renderScopeSelects() { scopeSelect.innerHTML = scopes.map(scope => ` - + `).join(''); + scopeSelect.value = activeScopeId ? String(activeScopeId) : ''; - const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL'); - assignmentScopeSelect.innerHTML = manualScopes.length - ? manualScopes.map(scope => ``).join('') - : ''; - assignmentScopeSelect.disabled = manualScopes.length === 0; + updateScopeActions(); + } + function updateScopeActions() { const activeScope = selectedScope(); scopeDeleteButton.disabled = !activeScope || activeScope.systemScope; } @@ -146,37 +150,35 @@ export async function initTimeSlotsSettings() { `).join(''); } - function renderAssignments() { - if (!assignments.length) { - assignmentsTbody.innerHTML = 'Ручных назначений нет'; - return; - } - - assignmentsTbody.innerHTML = assignments.map(assignment => ` - - ${escapeHtml(formatDate(assignment.date))} - ${escapeHtml(assignment.scopeName || '-')} - - - - - `).join(''); + function openScopeModal() { + scopeForm.reset(); + hideAlert('time-slot-scope-alert'); + scopeModal.classList.add('open'); + scopeModal.setAttribute('aria-hidden', 'false'); + window.setTimeout(() => scopeNameInput?.focus(), 0); } - async function createScope() { + function closeScopeModal() { + scopeModal.classList.remove('open'); + scopeModal.setAttribute('aria-hidden', 'true'); + hideAlert('time-slot-scope-alert'); + } + + async function createScope(event) { + event.preventDefault(); const name = scopeNameInput.value.trim(); if (!name) { - showAlert('time-slot-alert', 'Введите название ручной сетки', 'error'); + showAlert('time-slot-scope-alert', 'Введите название ручной сетки', 'error'); return; } try { const saved = await api.post('/api/admin/time-slots/scopes', { name }); - scopeNameInput.value = ''; activeScopeId = saved.id; + closeScopeModal(); showAlert('time-slot-alert', 'Сетка времени создана', 'success'); await loadAll(); } catch (error) { - showAlert('time-slot-alert', error.message || 'Ошибка создания сетки времени', 'error'); + showAlert('time-slot-scope-alert', error.message || 'Ошибка создания сетки времени', 'error'); } } @@ -195,43 +197,6 @@ export async function initTimeSlotsSettings() { } } - async function saveAssignment(event) { - event.preventDefault(); - hideAlert('time-slot-assignment-alert'); - - const payload = { - date: assignmentDateInput.value, - scopeId: assignmentScopeSelect.value ? Number(assignmentScopeSelect.value) : null - }; - - if (!payload.date || !payload.scopeId) { - showAlert('time-slot-assignment-alert', 'Выберите дату и ручную сетку', 'error'); - return; - } - - try { - await api.post('/api/admin/time-slots/date-assignments', payload); - assignmentForm.reset(); - showAlert('time-slot-assignment-alert', 'Сетка применена к дате', 'success'); - await loadAll(); - } catch (error) { - showAlert('time-slot-assignment-alert', error.message || 'Ошибка назначения сетки', 'error'); - } - } - - async function handleAssignmentTableClick(event) { - const deleteButton = event.target.closest('.btn-delete-assignment'); - if (!deleteButton) return; - if (!confirm('Убрать ручное применение сетки на эту дату?')) return; - try { - await api.delete('/api/admin/time-slots/date-assignments/' + deleteButton.dataset.id); - showAlert('time-slot-assignment-alert', 'Ручное назначение удалено', 'success'); - await loadAll(); - } catch (error) { - showAlert('time-slot-assignment-alert', error.message || 'Ошибка удаления назначения', 'error'); - } - } - async function saveSlot(event) { event.preventDefault(); hideAlert('time-slot-alert'); @@ -350,9 +315,3 @@ function dayShort(dayOfWeek) { function trimTime(value) { return value ? String(value).slice(0, 5) : ''; } - -function formatDate(value) { - if (!value) return '-'; - const [year, month, day] = String(value).split('-'); - return `${day}.${month}.${year}`; -} diff --git a/frontend/admin/settings/views/time-slots.html b/frontend/admin/settings/views/time-slots.html index 0d9fd7f..a6800a1 100644 --- a/frontend/admin/settings/views/time-slots.html +++ b/frontend/admin/settings/views/time-slots.html @@ -11,38 +11,36 @@

Сетка времени

- +
+ + +
-
+
- -
-
-

Ручное применение

-
- - - - -
+
+

Как применяется

+
+ Базовая + Используется по умолчанию для всех дат. +
+
+ Сб + Субботняя сетка применяется автоматически по субботам. +
+
+ Ручная + Ручные сетки назначаются в ячейке редактора календарного графика. +
@@ -80,45 +78,50 @@
-
-
-
- - - - - - - - - - - - - - - -
ПараНачалоОкончаниеМинутДействия
Загрузка...
-
-
+
+
+ + + + + + + + + + + + + + + +
ПараНачалоОкончаниеМинутДействия
Загрузка...
+
+
-
-
- - - - - - - - - - - - - -
ДатаСетка
Загрузка...
+
+ +
+ + +
+ + +
+ + +
+
diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index b84b862..daf0a10 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -221,5 +221,18 @@ +
+
+ + +
+
+ + +
+ +