From 467f78c3d0f2bafacf7c8a32d4edd24357c8f33d Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Wed, 27 May 2026 13:22:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D1=81=D0=B5=D1=82=D0=BA=D0=BE=D0=B9=20=D0=B2=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=B8=D0=BA=D0=B5=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 197 +++++++++++--- frontend/admin/js/views/academic-calendar.js | 268 +++++++++++++++++-- 3 files changed, 398 insertions(+), 69 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c615c46..a8aa0f1 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -152,7 +152,7 @@ frontend/ - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `users` поддерживает роли `EDUCATION_OFFICE`, `DEPARTMENT` и `SCHEDULE_VIEWER`; удаление пользователя работает как архивирование. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 9cbe9c4..623cfeb 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -391,13 +391,20 @@ } .calendar-semesters-grid { + --calendar-semester-block-height: 194px; display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; gap: 0.65rem; padding: 0.65rem; } .calendar-semester-block { + display: flex; + flex-direction: column; + height: var(--calendar-semester-block-height); + min-height: 100%; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-card); @@ -426,16 +433,28 @@ } .calendar-semester-table-wrap { + flex: 1; + display: flex; + min-height: 0; overflow-x: auto; + overflow-y: hidden; background: var(--bg-primary); + scrollbar-width: none; +} + +.calendar-semester-table-wrap::-webkit-scrollbar { + width: 0; + height: 0; } .calendar-semester-table { width: max-content; min-width: 100%; + height: 100%; border-collapse: collapse; table-layout: fixed; font-size: 0.62rem; + user-select: none; } .calendar-semester-table th, @@ -443,12 +462,16 @@ border: 1px solid var(--bg-card-border); } +.calendar-semester-table tbody tr { + height: 19px; +} + .calendar-semester-table thead th { position: sticky; top: 0; z-index: 1; height: 18px; - min-width: 21px; + min-width: 18px; padding: 0 0.12rem; background: var(--bg-input); color: var(--text-secondary); @@ -470,43 +493,33 @@ .calendar-day { position: relative; - width: 21px; - min-width: 21px; - height: 20px; - min-height: 20px; - padding: 1px; - background: - linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)) 0%, var(--bg-card) 100%); + width: 18px; + min-width: 18px; + height: 19px; + min-height: 19px; + padding: 0; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 12%, var(--bg-card)); border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); cursor: pointer; outline: none; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); + box-shadow: none; text-align: center; - transition: background var(--transition), box-shadow var(--transition), transform var(--transition), filter var(--transition); + transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), filter var(--transition); } .calendar-day:hover, .calendar-day:focus-visible { - background: - linear-gradient(180deg, color-mix(in srgb, var(--activity-color, var(--accent)) 32%, var(--bg-card)) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 10%, var(--bg-card)) 100%); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), - inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 58%, transparent), - 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); - transform: none; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 50%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); z-index: 3; } .calendar-day.is-selected { - background: - radial-gradient(circle at 50% 20%, color-mix(in srgb, var(--activity-color, var(--accent)) 46%, #ffffff) 0%, color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card)) 42%, var(--bg-card) 100%); - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 90%, transparent), - inset 0 -2px 0 color-mix(in srgb, var(--activity-color, var(--accent)) 72%, transparent), - 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 32%, transparent), - 0 6px 14px color-mix(in srgb, var(--activity-color, var(--accent)) 18%, transparent); - filter: saturate(1.2); - transform: none; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 34%, var(--bg-card)); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 64%, var(--bg-card-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 44%, transparent); + filter: saturate(1.08); z-index: 4; } @@ -516,7 +529,7 @@ justify-content: center; width: 100%; height: 100%; - border-radius: 3px; + border-radius: 2px; color: var(--text-primary); font-size: 0.6rem; line-height: 1; @@ -541,14 +554,89 @@ display: none !important; } +.calendar-day-tooltip { + position: fixed; + z-index: 1200; + width: min(280px, calc(100vw - 24px)); + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 26%, var(--bg-card-border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + color: var(--text-primary); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: translateY(4px) scale(0.98); + transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease; +} + +.calendar-day-tooltip.visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); +} + +.calendar-day-tooltip-head { + display: flex; + align-items: center; + gap: 0.65rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-day-tooltip-head strong, +.calendar-day-tooltip-body strong { + display: block; + color: var(--text-primary); + font-size: 0.85rem; + line-height: 1.25; +} + +.calendar-day-tooltip-head span, +.calendar-day-tooltip-body span { + display: block; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.2; +} + +.calendar-day-tooltip-code { + width: 34px; + min-width: 34px; + height: 34px; + display: inline-grid; + place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent); + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 800; + line-height: 1; + padding-top: 2px; + text-align: center; +} + +.calendar-day-tooltip-body { + display: grid; + gap: 0.45rem; + padding-top: 0.65rem; +} + .calendar-empty-day { - width: 21px; - min-width: 21px; - height: 20px; + width: 18px; + min-width: 18px; + height: 19px; background: color-mix(in srgb, var(--bg-input) 65%, transparent); } .calendar-empty-semester { + flex: 1; + display: flex; + align-items: center; padding: 0.75rem; color: var(--text-secondary); font-size: 0.82rem; @@ -558,6 +646,7 @@ display: flex; flex-wrap: wrap; gap: 0.5rem; + margin-top: 0.85rem; } .calendar-total-chip { @@ -590,19 +679,25 @@ text-transform: uppercase; } -.calendar-day-dialog-card { - width: min(500px, 100%); +#calendar-day-dialog.modal-overlay { + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(3px); +} + +#calendar-day-dialog .calendar-day-dialog-card { + width: min(470px, 100%); max-height: none; - padding: 1.35rem; + padding: 1rem 1rem 0.85rem; overflow: visible; background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); + border-radius: 18px; } .calendar-day-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.55rem; - margin: 0.65rem 0; + gap: 0.45rem; + margin: 0.45rem 0; } .calendar-day-dialog-grid div { @@ -627,8 +722,8 @@ display: flex; gap: 0.7rem; align-items: center; - margin: 0.7rem 0 0.8rem; - padding: 0.65rem; + margin: 0.55rem 0 0.65rem; + padding: 0.6rem; border-radius: var(--radius-sm); background: var(--bg-input); border: 1px solid var(--bg-card-border); @@ -654,8 +749,8 @@ } .calendar-day-dialog-time-grid { - margin-top: 0.8rem; - padding-top: 0.8rem; + margin-top: 0.6rem; + padding-top: 0.6rem; border-top: 1px solid var(--bg-card-border); } @@ -663,15 +758,21 @@ display: flex; flex-wrap: wrap; gap: 0.6rem; - margin-top: 0.8rem; + margin-top: 0.6rem; margin-bottom: 0; } -#calendar-day-dialog-time-alert:not(.error):not(.success) { +#calendar-day-dialog-time-alert { margin: 0; padding: 0; } +#calendar-day-dialog-time-alert.error, +#calendar-day-dialog-time-alert.success { + margin-top: 0.55rem; + padding: 0.5rem 0.75rem; +} + .profiles-context { color: var(--text-secondary); font-weight: 600; @@ -702,6 +803,16 @@ grid-template-columns: repeat(2, minmax(180px, 1fr)); } + .calendar-semesters-grid { + grid-template-columns: 1fr; + grid-auto-rows: auto; + } + + .calendar-semester-block { + height: auto; + min-height: 0; + } + .calendar-semester-header { align-items: flex-start; flex-direction: column; @@ -727,7 +838,7 @@ grid-template-columns: 1fr; } - .calendar-day-dialog-card { + #calendar-day-dialog .calendar-day-dialog-card { max-height: calc(100vh - 2rem); overflow-y: auto; } diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 4ba5213..c0b9ebd 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -82,6 +82,12 @@ export async function initAcademicCalendar() { let timeSlotDateAssignments = []; let activeCalendarDay = null; let selectedCalendarDayKey = null; + let selectedCalendarDays = []; + let isCalendarDragSelecting = false; + let didCalendarDragSelect = false; + let suppressCalendarDayClick = false; + let isSyncingCalendarDayDialog = false; + let calendarDayTooltip = null; bindEvents(); @@ -116,10 +122,34 @@ export async function initAcademicCalendar() { renderCalendarTotals(); } }); + calendarGrid?.addEventListener('mousedown', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) startCalendarDayDragSelection(event, day); + }); + calendarGrid?.addEventListener('mouseover', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) addCalendarDayToDragSelection(day); + }); + calendarGrid?.addEventListener('mousemove', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day, event); + }); + calendarGrid?.addEventListener('mouseleave', hideCalendarDayTooltip); + calendarGrid?.addEventListener('focusin', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) showCalendarDayTooltip(day); + }); + calendarGrid?.addEventListener('focusout', hideCalendarDayTooltip); calendarGrid?.addEventListener('click', (event) => { + if (suppressCalendarDayClick) { + suppressCalendarDayClick = false; + event.preventDefault(); + return; + } const day = event.target.closest('.calendar-day'); if (day) openCalendarDayDialog(day); }); + document.addEventListener('mouseup', finishCalendarDayDragSelection); calendarGrid?.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const day = event.target.closest('.calendar-day'); @@ -132,14 +162,17 @@ export async function initAcademicCalendar() { if (event.target === calendarDayDialog) closeCalendarDayDialog(); }); calendarDayDialogActivitySelect?.addEventListener('change', () => { - if (!activeCalendarDay) return; - const select = activeCalendarDay.querySelector('.calendar-day-activity'); - select.value = calendarDayDialogActivitySelect.value; - updateCalendarDayVisual(activeCalendarDay); - updateCalendarDayDialogSummary(activeCalendarDay); + if (isSyncingCalendarDayDialog || !calendarDayDialogActivitySelect.value) return; + const days = selectedCalendarDays.length ? selectedCalendarDays : (activeCalendarDay ? [activeCalendarDay] : []); + days.forEach(day => { + const select = day.querySelector('.calendar-day-activity'); + select.value = calendarDayDialogActivitySelect.value; + updateCalendarDayVisual(day); + }); + updateCalendarDayDialogSummaryForSelection(days); renderCalendarTotals(); }); - calendarDayDialogTimeSaveButton?.addEventListener('click', saveTimeSlotDateAssignment); + calendarDayDialogTimeSaveButton?.addEventListener('click', closeCalendarDayDialog); calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment); academicYearForm?.addEventListener('submit', saveAcademicYear); @@ -524,6 +557,7 @@ export async function initAcademicCalendar() { function renderCalendarGrid(rows) { activeCalendarDay = null; selectedCalendarDayKey = null; + selectedCalendarDays = []; const grouped = groupBy(rows, row => row.courseNumber); calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => `
@@ -607,7 +641,6 @@ export async function initAcademicCalendar() { 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) ? 'вр.' : ''} @@ -617,20 +650,48 @@ export async function initAcademicCalendar() { } function openCalendarDayDialog(day) { - activeCalendarDay = day; - selectCalendarDay(day); - calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`; - calendarDayDialogCourse.textContent = `${day.dataset.course} курс`; - calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`; - calendarDayDialogDay.textContent = dayLabel(day.dataset.day); + openCalendarDayDialogForSelection([day]); + } + + function openCalendarDayDialogForSelection(days) { + const selectedDays = uniqueCalendarDays(days); + if (!selectedDays.length) return; + + activeCalendarDay = selectedDays[0]; + selectCalendarDays(selectedDays); + + const dates = selectedDays.map(day => day.dataset.date).sort(); + const courses = new Set(selectedDays.map(day => day.dataset.course)); + const weeks = new Set(selectedDays.map(day => day.dataset.week)); + const weekDays = new Set(selectedDays.map(day => day.dataset.day)); + const isSingle = selectedDays.length === 1; + + calendarDayDialogDate.textContent = isSingle + ? `${formatLongDate(activeCalendarDay.dataset.date)} (${shortDayLabel(activeCalendarDay.dataset.day)})` + : `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + calendarDayDialogCourse.textContent = courses.size === 1 + ? `${activeCalendarDay.dataset.course} курс` + : `${courses.size} курса`; + calendarDayDialogWeek.textContent = weeks.size === 1 + ? `${activeCalendarDay.dataset.week} неделя` + : `${weeks.size} недель`; + calendarDayDialogDay.textContent = weekDays.size === 1 + ? dayLabel(activeCalendarDay.dataset.day) + : `${selectedDays.length} ячеек`; calendarDayDialogActivitySelect.innerHTML = options( activityTypes.map(toActivityOption), - day.querySelector('.calendar-day-activity').value + commonCalendarDayActivityValue(selectedDays) ); - populateTimeScopeDialogSelect(day.dataset.date); + if (isSingle) { + populateTimeScopeDialogSelect(activeCalendarDay.dataset.date); + } else { + populateMultiSelectionTimeScopeDialogSelect(); + } + isSyncingCalendarDayDialog = true; syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogTimeScopeSelect); - updateCalendarDayDialogSummary(day); + isSyncingCalendarDayDialog = false; + updateCalendarDayDialogSummaryForSelection(selectedDays); renderCalendarTotals(); calendarDayDialog.classList.add('open'); } @@ -655,6 +716,33 @@ export async function initAcademicCalendar() { `; } + function updateCalendarDayDialogSummaryForSelection(days) { + if (days.length <= 1) { + updateCalendarDayDialogSummary(days[0]); + return; + } + + const activity = activityById(commonCalendarDayActivityValue(days)); + if (!activity) { + calendarDayDialogActivitySummary.innerHTML = ` + ... +
+ Выбрано ячеек: ${days.length} +

Выберите код активности, чтобы применить его ко всему выделению

+
+ `; + return; + } + + calendarDayDialogActivitySummary.innerHTML = ` + ${escapeHtml(activity.code)} +
+ ${escapeHtml(activity.name)} · ${days.length} ячеек +

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

+
+ `; + } + async function saveTimeSlotDateAssignment() { if (!activeCalendarDay) return; hideAlert('calendar-day-dialog-time-alert'); @@ -707,10 +795,19 @@ export async function initAcademicCalendar() { calendarDayDialogTimeScopeSelect.innerHTML = `` + manualScopes.map(scope => ``).join(''); calendarDayDialogTimeScopeSelect.value = assignment?.scopeId || ''; - calendarDayDialogTimeSaveButton.disabled = manualScopes.length === 0 && !assignment; + calendarDayDialogTimeScopeSelect.disabled = false; + calendarDayDialogTimeSaveButton.disabled = false; calendarDayDialogTimeClearButton.disabled = !assignment; } + function populateMultiSelectionTimeScopeDialogSelect() { + calendarDayDialogTimeScopeSelect.innerHTML = ''; + calendarDayDialogTimeScopeSelect.value = ''; + calendarDayDialogTimeScopeSelect.disabled = true; + calendarDayDialogTimeSaveButton.disabled = false; + calendarDayDialogTimeClearButton.disabled = true; + } + function updateTimeAssignmentVisuals(date) { const assignment = timeAssignmentForDate(date); calendarGrid.querySelectorAll(`.calendar-day[data-date="${date}"]`).forEach(day => { @@ -735,14 +832,133 @@ export async function initAcademicCalendar() { day.style.setProperty('--activity-color', color); day.querySelector('.calendar-day-code').textContent = code; day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); - day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); + } + + function showCalendarDayTooltip(day, event = null) { + if (isCalendarDragSelecting) { + hideCalendarDayTooltip(); + return; + } + const tooltip = ensureCalendarDayTooltip(); + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b'); + tooltip.innerHTML = renderCalendarDayTooltip(day); + tooltip.classList.add('visible'); + positionCalendarDayTooltip(tooltip, day, event); + } + + function hideCalendarDayTooltip() { + calendarDayTooltip?.classList.remove('visible'); + } + + function ensureCalendarDayTooltip() { + if (calendarDayTooltip) return calendarDayTooltip; + calendarDayTooltip = document.createElement('div'); + calendarDayTooltip.className = 'calendar-day-tooltip'; + calendarDayTooltip.setAttribute('role', 'tooltip'); + document.body.appendChild(calendarDayTooltip); + return calendarDayTooltip; + } + + function renderCalendarDayTooltip(day) { + const activity = activityById(day.querySelector('.calendar-day-activity')?.value); + const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-'; + const color = activity?.colorCode || '#64748b'; + const timeAssignment = timeAssignmentForDate(day.dataset.date); + const timeLabel = timeAssignment + ? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка') + : automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', ''); + return ` +
+ ${escapeHtml(code)} +
+ ${escapeHtml(formatLongDate(day.dataset.date))} + ${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя +
+
+
+
+ Курс + ${escapeHtml(day.dataset.course)} +
+
+ Активность + ${escapeHtml(activity?.name || 'Не задана')} +
+
+ Время + ${escapeHtml(timeLabel)} +
+
+ `; + } + + function positionCalendarDayTooltip(tooltip, day, event) { + const offset = 14; + const rect = day.getBoundingClientRect(); + const x = event?.clientX ?? rect.left + rect.width / 2; + const y = event?.clientY ?? rect.top; + const tooltipRect = tooltip.getBoundingClientRect(); + let left = x + offset; + let top = y + offset; + if (left + tooltipRect.width > window.innerWidth - 12) { + left = x - tooltipRect.width - offset; + } + if (top + tooltipRect.height > window.innerHeight - 12) { + top = y - tooltipRect.height - offset; + } + tooltip.style.left = `${Math.max(12, left)}px`; + tooltip.style.top = `${Math.max(12, top)}px`; + } + + function startCalendarDayDragSelection(event, day) { + if (event.button !== 0) return; + hideCalendarDayTooltip(); + isCalendarDragSelecting = true; + didCalendarDragSelect = false; + suppressCalendarDayClick = false; + selectCalendarDays([day]); + event.preventDefault(); + } + + function addCalendarDayToDragSelection(day) { + if (!isCalendarDragSelecting || selectedCalendarDays.includes(day)) return; + didCalendarDragSelect = true; + selectedCalendarDays.push(day); + day.classList.add('is-selected'); + renderCalendarTotals(); + } + + function finishCalendarDayDragSelection() { + if (!isCalendarDragSelecting) return; + isCalendarDragSelecting = false; + if (didCalendarDragSelect && selectedCalendarDays.length > 1) { + suppressCalendarDayClick = true; + openCalendarDayDialogForSelection(selectedCalendarDays); + } } function selectCalendarDay(day) { - if (!day) return; - selectedCalendarDayKey = calendarDayKey(day); + selectCalendarDays(day ? [day] : []); + } + + function selectCalendarDays(days) { + const uniqueDays = uniqueCalendarDays(days); + selectedCalendarDays = uniqueDays; + selectedCalendarDayKey = uniqueDays[0] ? calendarDayKey(uniqueDays[0]) : null; calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); - day.classList.add('is-selected'); + uniqueDays.forEach(item => item.classList.add('is-selected')); + renderCalendarTotals(); + } + + function uniqueCalendarDays(days) { + return Array.from(new Set(days.filter(Boolean))); + } + + function commonCalendarDayActivityValue(days) { + const values = uniqueCalendarDays(days).map(day => day.querySelector('.calendar-day-activity')?.value || ''); + const uniqueValues = Array.from(new Set(values)); + return uniqueValues.length === 1 ? uniqueValues[0] : ''; } function calendarDayKey(day) { @@ -807,10 +1023,12 @@ export async function initAcademicCalendar() { const key = activity ? activity.code : '-'; counts.set(key, (counts.get(key) || 0) + 1); }); - const selectedDay = selectedCalendarDayKey - ? Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey) - : null; - const selectedActivity = selectedDay ? activityById(selectedDay.querySelector('.calendar-day-activity').value) : null; + const selectedDays = selectedCalendarDays.length + ? selectedCalendarDays + : (selectedCalendarDayKey + ? [Array.from(calendarGrid.querySelectorAll('.calendar-day')).find(day => calendarDayKey(day) === selectedCalendarDayKey)].filter(Boolean) + : []); + const selectedActivity = selectedDays.length ? activityById(commonCalendarDayActivityValue(selectedDays)) : null; const selectedCode = selectedActivity?.code || null; calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru'))