From c506a905a2791a3a0aba0a12e4ed8509312d2600 Mon Sep 17 00:00:00 2001 From: dipatrik10 Date: Mon, 25 May 2026 13:40:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D1=83=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 186 ++++++++++++++----- frontend/admin/css/modals.css | 28 ++- frontend/admin/js/views/academic-calendar.js | 42 ++++- 4 files changed, 194 insertions(+), 64 deletions(-) diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 1439ce6..efb120f 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -128,7 +128,7 @@ frontend/ - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. +- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса идут вертикально друг под другом, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; выбранная ячейка и её код дополнительно подсвечиваются в общей сводке. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. ### Страница настроек (`/admin/settings/`) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 19a65e4..f30526d 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -221,14 +221,14 @@ .academic-calendar-grid { display: grid; - gap: 1rem; + gap: 0.65rem; margin-top: 1rem; } .calendar-course-block { border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); - background: var(--bg-input); + background: color-mix(in srgb, var(--bg-input) 82%, var(--bg-card)); overflow: hidden; } @@ -236,23 +236,60 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; + gap: 1rem; + min-height: 44px; + padding: 0.6rem 0.85rem; + cursor: pointer; + list-style: none; + user-select: none; + transition: background var(--transition); +} + +.calendar-course-header::-webkit-details-marker { + display: none; +} + +.calendar-course-header:hover { + background: var(--bg-hover); +} + +.calendar-course-block[open] .calendar-course-header { border-bottom: 1px solid var(--bg-card-border); } -.calendar-course-header h3 { - font-size: 1rem; +.calendar-course-title { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-size: 0.96rem; + font-weight: 800; } -.calendar-course-header span { +.calendar-course-chevron { + width: 9px; + height: 9px; + border-right: 2px solid var(--text-secondary); + border-bottom: 2px solid var(--text-secondary); + transform: rotate(-45deg); + transition: transform var(--transition), border-color var(--transition); +} + +.calendar-course-block[open] .calendar-course-chevron { + transform: rotate(45deg); + border-color: var(--accent-hover); +} + +.calendar-course-meta { color: var(--text-secondary); font-size: 0.78rem; + font-weight: 700; } .calendar-semesters-grid { display: grid; - gap: 0.75rem; - padding: 0.75rem; + grid-template-columns: 1fr; + gap: 0.65rem; + padding: 0.65rem; } .calendar-semester-block { @@ -267,8 +304,8 @@ align-items: center; justify-content: space-between; gap: 1rem; - min-height: 36px; - padding: 0.45rem 0.65rem; + min-height: 32px; + padding: 0.38rem 0.55rem; border-bottom: 1px solid var(--bg-card-border); } @@ -293,7 +330,7 @@ min-width: 100%; border-collapse: collapse; table-layout: fixed; - font-size: 0.68rem; + font-size: 0.62rem; } .calendar-semester-table th, @@ -305,9 +342,9 @@ position: sticky; top: 0; z-index: 1; - height: 22px; - min-width: 26px; - padding: 0 0.2rem; + height: 18px; + min-width: 21px; + padding: 0 0.12rem; background: var(--bg-input); color: var(--text-secondary); font-weight: 700; @@ -318,8 +355,8 @@ position: sticky; left: 0; z-index: 2; - width: 36px; - min-width: 36px; + width: 30px; + min-width: 30px; background: var(--bg-input); color: var(--text-secondary); font-weight: 700; @@ -328,48 +365,69 @@ .calendar-day { position: relative; - width: 26px; - min-width: 26px; - height: 24px; - min-height: 24px; - padding: 0; - background: color-mix(in srgb, var(--activity-color, var(--accent)) 9%, var(--bg-card)); + 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%); + border-color: color-mix(in srgb, var(--activity-color, var(--accent)) 28%, var(--bg-card-border)); cursor: pointer; outline: none; - box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--activity-color, var(--accent)) 30%, transparent); text-align: center; - transition: background var(--transition), box-shadow var(--transition), transform var(--transition); + transition: background var(--transition), box-shadow var(--transition), transform var(--transition), filter var(--transition); } .calendar-day:hover, .calendar-day:focus-visible { - background: color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)); - box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)), 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 36%, transparent); - transform: scale(1.08); + 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; 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; + z-index: 4; +} + .calendar-day-code { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; + border-radius: 3px; color: var(--text-primary); - font-size: 0.68rem; + font-size: 0.6rem; line-height: 1; font-weight: 800; + text-shadow: 0 1px 0 color-mix(in srgb, var(--bg-primary) 70%, transparent); } .calendar-day-time-marker { position: absolute; - right: 1px; - bottom: 1px; - padding: 0 2px; + right: 0; + bottom: 0; + padding: 0 1px; border-radius: 3px; background: var(--accent); color: #fff; - font-size: 0.45rem; + font-size: 0.4rem; font-weight: 800; line-height: 1.15; } @@ -379,9 +437,9 @@ } .calendar-empty-day { - width: 26px; - min-width: 26px; - height: 24px; + width: 21px; + min-width: 21px; + height: 20px; background: color-mix(in srgb, var(--bg-input) 65%, transparent); } @@ -400,6 +458,7 @@ .calendar-total-chip { display: inline-flex; align-items: center; + gap: 0.35rem; min-height: 28px; padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); @@ -410,19 +469,39 @@ font-weight: 600; } +.calendar-total-chip.is-selected { + background: color-mix(in srgb, var(--chip-color) 30%, var(--bg-card)); + border-color: color-mix(in srgb, var(--chip-color) 72%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--chip-color) 22%, transparent); +} + +.calendar-total-selected { + padding: 0.08rem 0.32rem; + border-radius: 999px; + background: color-mix(in srgb, var(--chip-color) 18%, var(--bg-primary)); + color: var(--text-secondary); + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + .calendar-day-dialog-card { - max-width: 560px; + width: min(500px, 100%); + max-height: none; + padding: 1.35rem; + overflow: visible; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-card)); } .calendar-day-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin: 0.75rem 0; + gap: 0.55rem; + margin: 0.65rem 0; } .calendar-day-dialog-grid div { - padding: 0.75rem; + padding: 0.58rem 0.65rem; border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); background: var(--bg-input); @@ -436,24 +515,24 @@ } .calendar-day-dialog-grid strong { - font-size: 0.95rem; + font-size: 0.9rem; } .calendar-day-dialog-activity { display: flex; - gap: 0.8rem; + gap: 0.7rem; align-items: center; - margin: 0.8rem 0 1rem; - padding: 0.75rem; + margin: 0.7rem 0 0.8rem; + padding: 0.65rem; border-radius: var(--radius-sm); background: var(--bg-input); border: 1px solid var(--bg-card-border); } .calendar-day-dialog-code { - width: 46px; - min-width: 46px; - height: 46px; + width: 40px; + min-width: 40px; + height: 40px; display: inline-flex; align-items: center; justify-content: center; @@ -470,8 +549,8 @@ } .calendar-day-dialog-time-grid { - margin-top: 1rem; - padding-top: 1rem; + margin-top: 0.8rem; + padding-top: 0.8rem; border-top: 1px solid var(--bg-card-border); } @@ -480,6 +559,12 @@ flex-wrap: wrap; gap: 0.6rem; margin-top: 0.8rem; + margin-bottom: 0; +} + +#calendar-day-dialog-time-alert:not(.error):not(.success) { + margin: 0; + padding: 0; } .profiles-context { @@ -530,6 +615,11 @@ .calendar-day-dialog-grid { grid-template-columns: 1fr; } + + .calendar-day-dialog-card { + max-height: calc(100vh - 2rem); + overflow-y: auto; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/css/modals.css b/frontend/admin/css/modals.css index 6235575..2ba19c1 100644 --- a/frontend/admin/css/modals.css +++ b/frontend/admin/css/modals.css @@ -2,10 +2,7 @@ .modal-overlay { display: none; position: fixed; - top: 0; - left: 0; - right: 0; - /* bottom: 0; */ + inset: 0; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); @@ -13,6 +10,8 @@ align-items: center; justify-content: center; + padding: 1rem; + overflow-y: auto; opacity: 0; transition: opacity var(--transition); @@ -29,10 +28,11 @@ border-radius: var(--radius-md); padding: 2rem; - width: 100%; - top: 0; - max-width: 100%; + width: min(560px, 100%); + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); margin: 0 auto; + overflow-y: auto; position: relative; transform: scale(0.95); @@ -40,10 +40,24 @@ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); } +.modal-content.card:hover { + transform: scale(0.95); + border-color: var(--bg-card-border); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} + +.modal-content.card:hover::before { + opacity: 0; +} + .modal-overlay.open .modal-content { transform: scale(1); } +.modal-overlay.open .modal-content.card:hover { + transform: scale(1); +} + .modal-close { position: absolute; top: 1rem; diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 9efd62f..4ba5213 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -81,6 +81,7 @@ export async function initAcademicCalendar() { let timeSlotScopes = []; let timeSlotDateAssignments = []; let activeCalendarDay = null; + let selectedCalendarDayKey = null; bindEvents(); @@ -521,17 +522,22 @@ export async function initAcademicCalendar() { } function renderCalendarGrid(rows) { + activeCalendarDay = null; + selectedCalendarDayKey = null; const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` -
-
-

${course} курс

- ${courseRows.length} дней -
+ calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows], index) => ` +
+ + + + ${course} курс + + ${courseRows.length} дней +
${renderCourseSemesters(Number(course), courseRows)}
-
+ `).join(''); populateFillCourseSelect(); renderCalendarTotals(); @@ -612,6 +618,7 @@ 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} неделя`; @@ -624,6 +631,7 @@ export async function initAcademicCalendar() { syncSelects(calendarDayDialogActivitySelect); syncSelects(calendarDayDialogTimeScopeSelect); updateCalendarDayDialogSummary(day); + renderCalendarTotals(); calendarDayDialog.classList.add('open'); } @@ -730,6 +738,17 @@ export async function initAcademicCalendar() { day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); } + function selectCalendarDay(day) { + if (!day) return; + selectedCalendarDayKey = calendarDayKey(day); + calendarGrid.querySelectorAll('.calendar-day.is-selected').forEach(item => item.classList.remove('is-selected')); + day.classList.add('is-selected'); + } + + function calendarDayKey(day) { + return `${day.dataset.course}:${day.dataset.date}`; + } + function buildDefaultGrid(calendar) { const rows = []; const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); @@ -788,11 +807,18 @@ 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 selectedCode = selectedActivity?.code || null; calendarTotals.innerHTML = Array.from(counts.entries()) .sort(([a], [b]) => a.localeCompare(b, 'ru')) .map(([code, count]) => { const activity = activityTypes.find(item => item.code === code); - return `${escapeHtml(code)}: ${count}`; + const selectedClass = selectedCode === code ? ' is-selected' : ''; + const suffix = selectedCode === code ? 'выбрано' : ''; + return `${escapeHtml(code)}: ${count}${suffix}`; }).join(''); }