исправил настройку временных слотов
This commit is contained in:
@@ -203,7 +203,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
}
|
||||
```
|
||||
|
||||
Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments` и имеют приоритет над автоматической субботней сеткой. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
|
||||
Базовая сетка применяется по умолчанию. Субботняя сетка применяется автоматически по субботам. Ручные сетки применяются только на датах из `date-assignments`, имеют приоритет над автоматической субботней сеткой и назначаются пользователем из модального окна ячейки редактора календарного графика. В правилах расписания выбираются только базовые слоты; эффективное время пары подставляется при генерации.
|
||||
|
||||
### Учебные годы, семестры и коды календарного графика
|
||||
|
||||
|
||||
@@ -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` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание.
|
||||
|
||||
|
||||
@@ -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 слотов выбранной сетки, добавление ручных сеток через модальное окно и удаление выбранной ручной сетки. Базовая сетка применяется по умолчанию, субботняя — автоматически по субботам; ручное применение выполняется в сетке календарного графика.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
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)}">
|
||||
<strong class="calendar-day-code">${escapeHtml(code)}</strong>
|
||||
${timeAssignmentForDate(row.date) ? '<span class="calendar-day-time-marker">вр.</span>' : ''}
|
||||
<select class="calendar-day-activity custom-select-initialized" aria-hidden="true" tabindex="-1">${options(activityTypes.map(toActivityOption), selected)}</select>
|
||||
</td>
|
||||
`;
|
||||
@@ -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 = `<option value="">${escapeHtml(automaticLabel)}</option>` +
|
||||
manualScopes.map(scope => `<option value="${scope.id}">${escapeHtml(scope.name)}</option>`).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', '<span class="calendar-day-time-marker">вр.</span>');
|
||||
}
|
||||
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})`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = '<tr><td colspan="5" class="loading-row">Загрузка...</td></tr>';
|
||||
assignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
|
||||
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 = `<tr><td colspan="5" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
assignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет данных</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderScopeSelects();
|
||||
renderActiveScope();
|
||||
}
|
||||
|
||||
function renderActiveScope() {
|
||||
updateScopeActions();
|
||||
renderScopeSummary();
|
||||
renderSlots();
|
||||
renderAssignments();
|
||||
}
|
||||
|
||||
function renderScopeSelects() {
|
||||
scopeSelect.innerHTML = scopes.map(scope => `
|
||||
<option value="${scope.id}" ${scope.id === activeScopeId ? 'selected' : ''}>${escapeHtml(scopeOptionLabel(scope))}</option>
|
||||
<option value="${scope.id}">${escapeHtml(scopeOptionLabel(scope))}</option>
|
||||
`).join('');
|
||||
scopeSelect.value = activeScopeId ? String(activeScopeId) : '';
|
||||
|
||||
const manualScopes = scopes.filter(scope => scope.applyMode === 'MANUAL');
|
||||
assignmentScopeSelect.innerHTML = manualScopes.length
|
||||
? manualScopes.map(scope => `<option value="${scope.id}">${escapeHtml(scope.name)}</option>`).join('')
|
||||
: '<option value="">Нет ручных сеток</option>';
|
||||
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 = '<tr><td colspan="3" class="loading-row">Ручных назначений нет</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
assignmentsTbody.innerHTML = assignments.map(assignment => `
|
||||
<tr>
|
||||
<td>${escapeHtml(formatDate(assignment.date))}</td>
|
||||
<td>${escapeHtml(assignment.scopeName || '-')}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Убрать</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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}`;
|
||||
}
|
||||
|
||||
@@ -11,38 +11,36 @@
|
||||
<article class="settings-card">
|
||||
<div class="card-header-row">
|
||||
<h3>Сетка времени</h3>
|
||||
<button type="button" class="btn-secondary" id="time-slot-scope-delete">Удалить сетку</button>
|
||||
<div class="scope-header-actions">
|
||||
<button type="button" class="btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
|
||||
<button type="button" class="btn-secondary" id="time-slot-scope-delete">Удалить сетку</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-form settings-form-compact">
|
||||
<div class="settings-form settings-form-single">
|
||||
<label class="form-field" for="time-slot-scope-select">
|
||||
<span>Область действия</span>
|
||||
<select id="time-slot-scope-select"></select>
|
||||
<select id="time-slot-scope-select" class="native-select-control" data-native-select="true"></select>
|
||||
</label>
|
||||
<label class="form-field" for="time-slot-scope-name">
|
||||
<span>Новая ручная сетка</span>
|
||||
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная">
|
||||
</label>
|
||||
<button type="button" class="btn-primary" id="time-slot-scope-add">Добавить сетку</button>
|
||||
</div>
|
||||
|
||||
<div class="scope-summary" id="time-slot-scope-summary"></div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<h3>Ручное применение</h3>
|
||||
<form id="time-slot-assignment-form" class="settings-form settings-form-assignment" novalidate>
|
||||
<label class="form-field" for="time-slot-assignment-date">
|
||||
<span>Дата</span>
|
||||
<input type="date" id="time-slot-assignment-date" required>
|
||||
</label>
|
||||
<label class="form-field" for="time-slot-assignment-scope">
|
||||
<span>Сетка</span>
|
||||
<select id="time-slot-assignment-scope"></select>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Применить</button>
|
||||
<div class="form-alert" id="time-slot-assignment-alert" role="alert"></div>
|
||||
</form>
|
||||
<article class="settings-card settings-card-compact">
|
||||
<h3>Как применяется</h3>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge scope-badge-base">Базовая</span>
|
||||
<span>Используется по умолчанию для всех дат.</span>
|
||||
</div>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge">Сб</span>
|
||||
<span>Субботняя сетка применяется автоматически по субботам.</span>
|
||||
</div>
|
||||
<div class="scope-summary">
|
||||
<span class="scope-badge">Ручная</span>
|
||||
<span>Ручные сетки назначаются в ячейке редактора календарного графика.</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -80,45 +78,50 @@
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<div class="settings-grid settings-grid-tables">
|
||||
<article class="settings-card">
|
||||
<div class="table-wrap">
|
||||
<table class="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пара</th>
|
||||
<th>Начало</th>
|
||||
<th>Окончание</th>
|
||||
<th>Минут</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="time-slots-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
<article class="settings-card">
|
||||
<div class="table-wrap">
|
||||
<table class="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пара</th>
|
||||
<th>Начало</th>
|
||||
<th>Окончание</th>
|
||||
<th>Минут</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="time-slots-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="table-wrap">
|
||||
<table class="settings-table settings-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Сетка</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="time-slot-assignments-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="settings-modal-overlay" id="time-slot-scope-modal" aria-hidden="true">
|
||||
<div class="settings-modal-card" role="dialog" aria-modal="true" aria-labelledby="time-slot-scope-modal-title">
|
||||
<div class="card-header-row">
|
||||
<div>
|
||||
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
|
||||
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
|
||||
</div>
|
||||
<button type="button" class="settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<form id="time-slot-scope-form" class="settings-modal-form" novalidate>
|
||||
<label class="form-field" for="time-slot-scope-name">
|
||||
<span>Название сетки</span>
|
||||
<input type="text" id="time-slot-scope-name" placeholder="Например: праздничная" required>
|
||||
</label>
|
||||
|
||||
<div class="settings-modal-actions">
|
||||
<button type="submit" class="btn-primary">Создать сетку</button>
|
||||
<button type="button" class="btn-secondary" id="time-slot-scope-modal-cancel">Отмена</button>
|
||||
</div>
|
||||
|
||||
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -221,5 +221,18 @@
|
||||
<option value="">Выберите код</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-time-grid">
|
||||
<div class="form-group">
|
||||
<label for="calendar-day-dialog-time-scope">Временная сетка даты</label>
|
||||
<select id="calendar-day-dialog-time-scope">
|
||||
<option value="">По правилу даты</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="calendar-day-dialog-actions">
|
||||
<button type="button" class="btn-primary" id="calendar-day-dialog-time-save">Применить</button>
|
||||
<button type="button" class="btn-edit-classroom" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
|
||||
</div>
|
||||
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user