1546 lines
72 KiB
JavaScript
1546 lines
72 KiB
JavaScript
import { api } from '../api.js';
|
||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||
|
||
const DAY_OPTIONS = [
|
||
{ value: 1, label: 'Понедельник', shortLabel: 'Пн' },
|
||
{ value: 2, label: 'Вторник', shortLabel: 'Вт' },
|
||
{ value: 3, label: 'Среда', shortLabel: 'Ср' },
|
||
{ value: 4, label: 'Четверг', shortLabel: 'Чт' },
|
||
{ value: 5, label: 'Пятница', shortLabel: 'Пт' },
|
||
{ value: 6, label: 'Суббота', shortLabel: 'Сб' },
|
||
{ value: 7, label: 'Воскресенье', shortLabel: 'Вс' }
|
||
];
|
||
|
||
const SEMESTER_LABELS = {
|
||
autumn: 'осенний',
|
||
spring: 'весенний'
|
||
};
|
||
|
||
export async function initAcademicCalendar() {
|
||
const academicYearForm = document.getElementById('academic-year-form');
|
||
const academicYearFormTitle = document.getElementById('academic-year-form-title');
|
||
const academicYearIdInput = document.getElementById('academic-year-id');
|
||
const academicYearTitleInput = document.getElementById('academic-year-title');
|
||
const academicYearStartInput = document.getElementById('academic-year-start');
|
||
const academicYearEndInput = document.getElementById('academic-year-end');
|
||
const academicYearResetButton = document.getElementById('academic-year-reset');
|
||
const academicYearsTbody = document.getElementById('academic-years-tbody');
|
||
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
|
||
|
||
const semesterForm = document.getElementById('semester-form');
|
||
const semesterFormTitle = document.getElementById('semester-form-title');
|
||
const semesterIdInput = document.getElementById('semester-id');
|
||
const semesterYearSelect = document.getElementById('semester-year');
|
||
const semesterTypeSelect = document.getElementById('semester-type');
|
||
const semesterStartInput = document.getElementById('semester-start');
|
||
const semesterEndInput = document.getElementById('semester-end');
|
||
const semesterResetButton = document.getElementById('semester-reset');
|
||
|
||
const calendarForm = document.getElementById('academic-calendar-form');
|
||
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
|
||
const calendarIdInput = document.getElementById('academic-calendar-id');
|
||
const calendarTitleInput = document.getElementById('academic-calendar-title');
|
||
const calendarYearSelect = document.getElementById('academic-calendar-year');
|
||
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
|
||
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
|
||
const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form');
|
||
const calendarCourseCountInput = document.getElementById('academic-calendar-course-count');
|
||
const calendarResetButton = document.getElementById('academic-calendar-reset');
|
||
const calendarsRefreshButton = document.getElementById('academic-calendars-refresh');
|
||
const calendarsTbody = document.getElementById('academic-calendars-tbody');
|
||
|
||
const editorCalendarSelect = document.getElementById('calendar-editor-calendar');
|
||
const editorLoadButton = document.getElementById('calendar-editor-load');
|
||
const editorSaveButton = document.getElementById('calendar-editor-save');
|
||
const fillCourseSelect = document.getElementById('calendar-fill-course');
|
||
const fillStartInput = document.getElementById('calendar-fill-start');
|
||
const fillEndInput = document.getElementById('calendar-fill-end');
|
||
const fillActivitySelect = document.getElementById('calendar-fill-activity');
|
||
const fillApplyButton = document.getElementById('calendar-fill-apply');
|
||
const calendarGrid = document.getElementById('calendar-grid');
|
||
const calendarTotals = document.getElementById('calendar-totals');
|
||
const calendarDayDialog = document.getElementById('calendar-day-dialog');
|
||
const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close');
|
||
const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date');
|
||
const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course');
|
||
const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week');
|
||
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');
|
||
const calendarTabButtons = Array.from(document.querySelectorAll('[data-calendar-tab]'));
|
||
const calendarTabPanels = Array.from(document.querySelectorAll('[data-calendar-tab-panel]'));
|
||
const subjectCalendarSelect = document.getElementById('calendar-subject-calendar');
|
||
const subjectSemesterSelect = document.getElementById('calendar-subject-semester');
|
||
const subjectSelect = document.getElementById('calendar-subject-id');
|
||
const subjectAddButton = document.getElementById('calendar-subject-add');
|
||
const subjectSaveButton = document.getElementById('calendar-subject-save');
|
||
const subjectRefreshButton = document.getElementById('calendar-subject-refresh');
|
||
const subjectSummary = document.getElementById('calendar-subject-summary');
|
||
const subjectCount = document.getElementById('calendar-subject-count');
|
||
|
||
let academicYears = [];
|
||
let semesters = [];
|
||
let specialties = [];
|
||
let profilesBySpecialty = new Map();
|
||
let studyForms = [];
|
||
let activityTypes = [];
|
||
let calendars = [];
|
||
let subjects = [];
|
||
let calendarSubjectRows = [];
|
||
let timeSlotScopes = [];
|
||
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();
|
||
|
||
try {
|
||
await Promise.all([
|
||
loadBaseLists(),
|
||
loadYears(),
|
||
loadSubjects(),
|
||
loadStudyForms(),
|
||
loadActivityTypes(),
|
||
loadTimeSlotScopes(),
|
||
loadTimeSlotDateAssignments()
|
||
]);
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error');
|
||
}
|
||
|
||
function bindEvents() {
|
||
calendarTabButtons.forEach(button => {
|
||
button.addEventListener('click', () => activateCalendarTab(button.dataset.calendarTab));
|
||
});
|
||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||
semesterResetButton?.addEventListener('click', resetSemesterForm);
|
||
calendarResetButton?.addEventListener('click', resetCalendarForm);
|
||
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
|
||
editorLoadButton?.addEventListener('click', loadCalendarGrid);
|
||
editorSaveButton?.addEventListener('click', saveCalendarGrid);
|
||
fillApplyButton?.addEventListener('click', applyCalendarFill);
|
||
subjectCalendarSelect?.addEventListener('change', loadCalendarSubjects);
|
||
subjectAddButton?.addEventListener('click', addCalendarSubject);
|
||
subjectSaveButton?.addEventListener('click', saveCalendarSubjects);
|
||
subjectRefreshButton?.addEventListener('click', loadCalendarSubjects);
|
||
subjectSummary?.addEventListener('click', handleSubjectSummaryClick);
|
||
|
||
calendarGrid?.addEventListener('change', (event) => {
|
||
if (event.target.matches('.calendar-day-activity')) {
|
||
updateCalendarDayVisual(event.target.closest('.calendar-day'));
|
||
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');
|
||
if (!day) return;
|
||
event.preventDefault();
|
||
openCalendarDayDialog(day);
|
||
});
|
||
calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog);
|
||
calendarDayDialog?.addEventListener('click', (event) => {
|
||
if (event.target === calendarDayDialog) closeCalendarDayDialog();
|
||
});
|
||
calendarDayDialogActivitySelect?.addEventListener('change', () => {
|
||
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', closeCalendarDayDialog);
|
||
calendarDayDialogTimeClearButton?.addEventListener('click', clearTimeSlotDateAssignment);
|
||
|
||
academicYearForm?.addEventListener('submit', saveAcademicYear);
|
||
semesterForm?.addEventListener('submit', saveSemester);
|
||
calendarForm?.addEventListener('submit', saveCalendar);
|
||
|
||
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
|
||
calendarsTbody?.addEventListener('click', handleCalendarTableClick);
|
||
}
|
||
|
||
async function loadBaseLists() {
|
||
specialties = await api.get('/api/specialties');
|
||
await loadProfiles();
|
||
populateSpecialtySelects();
|
||
}
|
||
|
||
async function loadProfiles() {
|
||
const pairs = await Promise.all(specialties.map(async (speciality) => {
|
||
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
|
||
return [String(speciality.id), profiles];
|
||
}));
|
||
profilesBySpecialty = new Map(pairs);
|
||
}
|
||
|
||
async function loadYears() {
|
||
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||
try {
|
||
academicYears = await api.get('/api/admin/calendar/years');
|
||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||
renderAcademicYears();
|
||
populateYearSelects();
|
||
} catch (error) {
|
||
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function renderAcademicYears() {
|
||
if (!academicYears.length) {
|
||
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
|
||
return;
|
||
}
|
||
academicYearsTbody.innerHTML = academicYears.map(year => `
|
||
<tr>
|
||
<td class="academic-year-center-cell academic-year-title-cell">${escapeHtml(year.title)}</td>
|
||
<td class="academic-year-center-cell academic-year-period-cell">${escapeHtml(formatDisplayDateRange(year.startDate, year.endDate))}</td>
|
||
<td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
|
||
<td class="academic-year-actions-cell">
|
||
<div class="academic-year-actions">
|
||
<button class="btn btn-sm btn-secondary btn-edit-year" data-id="${year.id}">Изменить</button>
|
||
<button class="btn btn-sm btn-danger btn-delete-year" data-id="${year.id}">Удалить</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderSemesterList(items) {
|
||
if (!items.length) return '<span class="muted">Нет семестров</span>';
|
||
return `<div class="academic-year-semesters">${items.map(semester => {
|
||
const title = semesterTypeTitle(semester);
|
||
const period = formatDisplayDateRange(semester.startDate, semester.endDate);
|
||
return `
|
||
<div class="academic-year-semester-item">
|
||
<div class="academic-year-semester-meta">
|
||
<span class="academic-year-semester-type">${escapeHtml(title)}</span>
|
||
<span class="academic-year-semester-period">${escapeHtml(period)}</span>
|
||
</div>
|
||
<button class="btn btn-sm btn-secondary btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
|
||
</div>
|
||
`;
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
async function saveAcademicYear(event) {
|
||
event.preventDefault();
|
||
hideAlert('academic-year-alert');
|
||
const payload = {
|
||
title: academicYearTitleInput.value.trim(),
|
||
startDate: academicYearStartInput.value,
|
||
endDate: academicYearEndInput.value
|
||
};
|
||
try {
|
||
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
|
||
const id = academicYearIdInput.value;
|
||
await (id
|
||
? api.put('/api/admin/calendar/years/' + id, payload)
|
||
: api.post('/api/admin/calendar/years', payload));
|
||
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
|
||
resetAcademicYearForm();
|
||
await loadYears();
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleAcademicYearTableClick(event) {
|
||
const editYearButton = event.target.closest('.btn-edit-year');
|
||
const deleteYearButton = event.target.closest('.btn-delete-year');
|
||
const editSemesterButton = event.target.closest('.btn-edit-semester');
|
||
|
||
if (editYearButton) {
|
||
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
|
||
if (!year) return;
|
||
academicYearFormTitle.textContent = 'Редактирование учебного года';
|
||
academicYearIdInput.value = year.id;
|
||
academicYearTitleInput.value = year.title || '';
|
||
academicYearStartInput.value = year.startDate || '';
|
||
academicYearEndInput.value = year.endDate || '';
|
||
hideAlert('academic-year-alert');
|
||
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
|
||
if (editSemesterButton) {
|
||
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
|
||
if (!semester) return;
|
||
semesterFormTitle.textContent = `Редактирование семестра: ${semesterTypeTitle(semester)}`;
|
||
semesterIdInput.value = semester.id;
|
||
semesterYearSelect.value = semester.academicYearId || '';
|
||
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
|
||
semesterStartInput.value = semester.startDate || '';
|
||
semesterEndInput.value = semester.endDate || '';
|
||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||
hideAlert('semester-alert');
|
||
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
|
||
if (deleteYearButton) {
|
||
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
|
||
try {
|
||
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
|
||
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
|
||
await loadYears();
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
function resetAcademicYearForm() {
|
||
academicYearForm.reset();
|
||
academicYearFormTitle.textContent = 'Учебный год';
|
||
academicYearIdInput.value = '';
|
||
hideAlert('academic-year-alert');
|
||
}
|
||
|
||
async function saveSemester(event) {
|
||
event.preventDefault();
|
||
hideAlert('semester-alert');
|
||
const yearId = semesterYearSelect.value;
|
||
const payload = {
|
||
semesterType: semesterTypeSelect.value,
|
||
startDate: semesterStartInput.value,
|
||
endDate: semesterEndInput.value
|
||
};
|
||
try {
|
||
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
|
||
throw new Error('Заполните учебный год, тип и даты семестра');
|
||
}
|
||
const id = semesterIdInput.value;
|
||
await (id
|
||
? api.put('/api/admin/calendar/semesters/' + id, payload)
|
||
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
|
||
showAlert('semester-alert', 'Семестр сохранён', 'success');
|
||
resetSemesterForm();
|
||
await loadYears();
|
||
} catch (error) {
|
||
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
|
||
}
|
||
}
|
||
|
||
function resetSemesterForm() {
|
||
semesterForm.reset();
|
||
semesterFormTitle.textContent = 'Семестр';
|
||
semesterIdInput.value = '';
|
||
syncSelects(semesterYearSelect, semesterTypeSelect);
|
||
hideAlert('semester-alert');
|
||
}
|
||
|
||
async function loadStudyForms() {
|
||
studyForms = await api.get('/api/education-forms');
|
||
populateStudyFormSelect();
|
||
}
|
||
|
||
async function loadSubjects() {
|
||
subjects = await api.get('/api/subjects');
|
||
populateSubjectSelect();
|
||
}
|
||
|
||
async function loadActivityTypes() {
|
||
activityTypes = await api.get('/api/admin/calendar/activity-types');
|
||
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 {
|
||
calendars = await api.get('/api/admin/academic-calendars');
|
||
renderCalendars();
|
||
populateEditorCalendarSelect();
|
||
populateSubjectCalendarSelect();
|
||
} catch (error) {
|
||
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function renderCalendars() {
|
||
if (!calendars.length) {
|
||
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
|
||
return;
|
||
}
|
||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||
<tr>
|
||
<td>${escapeHtml(calendar.title)}</td>
|
||
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
|
||
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
|
||
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
|
||
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
|
||
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
|
||
<button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
|
||
<button class="btn btn-sm btn-secondary btn-open-calendar-subjects" data-id="${calendar.id}">Дисциплины</button>
|
||
<button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
async function saveCalendar(event) {
|
||
event.preventDefault();
|
||
hideAlert('academic-calendar-alert');
|
||
const payload = {
|
||
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
|
||
title: calendarTitleInput.value.trim(),
|
||
academicYearId: Number(calendarYearSelect.value),
|
||
specialtyId: Number(calendarSpecialtySelect.value),
|
||
specialtyProfileId: Number(calendarProfileSelect.value),
|
||
studyFormId: Number(calendarStudyFormSelect.value),
|
||
courseCount: Number(calendarCourseCountInput.value)
|
||
};
|
||
try {
|
||
if (!payload.title || !payload.academicYearId || !payload.specialtyId
|
||
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
|
||
throw new Error('Заполните все поля календарного графика');
|
||
}
|
||
const id = calendarIdInput.value;
|
||
const saved = id
|
||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||
: await api.post('/api/admin/academic-calendars', payload);
|
||
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
|
||
resetCalendarForm();
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleCalendarTableClick(event) {
|
||
const editButton = event.target.closest('.btn-edit-calendar');
|
||
const gridButton = event.target.closest('.btn-open-calendar-grid');
|
||
const subjectsButton = event.target.closest('.btn-open-calendar-subjects');
|
||
const deleteButton = event.target.closest('.btn-delete-calendar');
|
||
|
||
if (editButton) {
|
||
activateCalendarTab('manage');
|
||
const calendar = calendars.find(item => item.id == editButton.dataset.id);
|
||
if (!calendar) return;
|
||
calendarFormTitle.textContent = 'Редактирование календарного графика';
|
||
calendarIdInput.value = calendar.id;
|
||
calendarTitleInput.value = calendar.title || '';
|
||
calendarYearSelect.value = calendar.academicYearId || '';
|
||
calendarSpecialtySelect.value = calendar.specialtyId || '';
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
|
||
calendarStudyFormSelect.value = calendar.studyFormId || '';
|
||
calendarCourseCountInput.value = calendar.courseCount || 4;
|
||
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
|
||
hideAlert('academic-calendar-alert');
|
||
calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
|
||
if (gridButton) {
|
||
activateCalendarTab('grid');
|
||
editorCalendarSelect.value = gridButton.dataset.id;
|
||
refreshSelectVisuals(editorCalendarSelect);
|
||
await loadCalendarGrid();
|
||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
|
||
if (subjectsButton) {
|
||
activateCalendarTab('subjects');
|
||
subjectCalendarSelect.value = subjectsButton.dataset.id;
|
||
refreshSelectVisuals(subjectCalendarSelect);
|
||
await loadCalendarSubjects();
|
||
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
|
||
if (deleteButton) {
|
||
if (!confirm('Удалить календарный график?')) return;
|
||
try {
|
||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||
await loadCalendars();
|
||
} catch (error) {
|
||
showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
function resetCalendarForm() {
|
||
calendarForm.reset();
|
||
calendarFormTitle.textContent = 'Календарный учебный график';
|
||
calendarIdInput.value = '';
|
||
calendarCourseCountInput.value = 4;
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
|
||
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
|
||
hideAlert('academic-calendar-alert');
|
||
}
|
||
|
||
function activateCalendarTab(tabName) {
|
||
const target = tabName || 'manage';
|
||
calendarTabButtons.forEach(button => {
|
||
const active = button.dataset.calendarTab === target;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
calendarTabPanels.forEach(panel => {
|
||
const active = panel.dataset.calendarTabPanel === target;
|
||
panel.classList.toggle('active', active);
|
||
panel.hidden = !active;
|
||
});
|
||
}
|
||
|
||
async function loadCalendarSubjects() {
|
||
hideAlert('calendar-subject-alert');
|
||
const calendar = selectedSubjectCalendar();
|
||
populateSubjectSemesterSelect();
|
||
calendarSubjectRows = [];
|
||
if (!calendar) {
|
||
renderCalendarSubjects();
|
||
return;
|
||
}
|
||
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||
try {
|
||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||
renderCalendarSubjects();
|
||
} catch (error) {
|
||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
||
}
|
||
}
|
||
|
||
function addCalendarSubject() {
|
||
hideAlert('calendar-subject-alert');
|
||
const calendar = selectedSubjectCalendar();
|
||
const semesterNumber = Number(subjectSemesterSelect.value);
|
||
const subjectId = Number(subjectSelect.value);
|
||
if (!calendar || !semesterNumber || !subjectId) {
|
||
showAlert('calendar-subject-alert', 'Выберите график, семестр и дисциплину', 'error');
|
||
return;
|
||
}
|
||
if (calendarSubjectRows.some(row =>
|
||
Number(row.semesterNumber) === semesterNumber && Number(row.subjectId) === subjectId
|
||
)) {
|
||
showAlert('calendar-subject-alert', 'Дисциплина уже добавлена в этот семестр', 'error');
|
||
return;
|
||
}
|
||
const subject = subjects.find(item => Number(item.id) === subjectId);
|
||
if (!subject) {
|
||
showAlert('calendar-subject-alert', 'Дисциплина не найдена в справочнике', 'error');
|
||
return;
|
||
}
|
||
calendarSubjectRows = sortCalendarSubjectRows([
|
||
...calendarSubjectRows,
|
||
{
|
||
id: null,
|
||
calendarId: calendar.id,
|
||
semesterNumber,
|
||
subjectId,
|
||
subjectName: subject.name,
|
||
subjectCode: subject.code,
|
||
departmentId: subject.departmentId
|
||
}
|
||
]);
|
||
renderCalendarSubjects();
|
||
showAlert('calendar-subject-alert', 'Дисциплина добавлена. Сохраните привязки.', 'success');
|
||
}
|
||
|
||
async function saveCalendarSubjects() {
|
||
hideAlert('calendar-subject-alert');
|
||
const calendar = selectedSubjectCalendar();
|
||
if (!calendar) {
|
||
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
||
return;
|
||
}
|
||
const payload = calendarSubjectRows.map(row => ({
|
||
semesterNumber: Number(row.semesterNumber),
|
||
subjectId: Number(row.subjectId)
|
||
}));
|
||
try {
|
||
const saved = await api.put(`/api/admin/academic-calendars/${calendar.id}/subjects`, payload);
|
||
calendarSubjectRows = sortCalendarSubjectRows(saved || []);
|
||
renderCalendarSubjects();
|
||
showAlert('calendar-subject-alert', 'Привязки дисциплин сохранены', 'success');
|
||
} catch (error) {
|
||
showAlert('calendar-subject-alert', error.message || 'Ошибка сохранения дисциплин графика', 'error');
|
||
}
|
||
}
|
||
|
||
function handleSubjectSummaryClick(event) {
|
||
const removeButton = event.target.closest('.calendar-subject-remove');
|
||
if (!removeButton) return;
|
||
const semesterNumber = Number(removeButton.dataset.semester);
|
||
const subjectId = Number(removeButton.dataset.subject);
|
||
calendarSubjectRows = calendarSubjectRows.filter(row =>
|
||
Number(row.semesterNumber) !== semesterNumber || Number(row.subjectId) !== subjectId
|
||
);
|
||
renderCalendarSubjects();
|
||
showAlert('calendar-subject-alert', 'Дисциплина убрана. Сохраните привязки.', 'success');
|
||
}
|
||
|
||
function renderCalendarSubjects() {
|
||
const calendar = selectedSubjectCalendar();
|
||
if (!calendar) {
|
||
subjectCount.textContent = '';
|
||
subjectSummary.innerHTML = '<div class="loading-row">Выберите календарный график</div>';
|
||
return;
|
||
}
|
||
|
||
const semesterCount = calendarSemesterCount(calendar);
|
||
subjectCount.textContent = `${calendarSubjectRows.length} дисциплин`;
|
||
subjectSummary.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
|
||
const semesterNumber = index + 1;
|
||
const semesterRows = calendarSubjectRows.filter(row => Number(row.semesterNumber) === semesterNumber);
|
||
return `
|
||
<section class="calendar-subject-semester">
|
||
<div class="calendar-subject-semester-head">
|
||
<strong>${escapeHtml(semesterOptionLabel(semesterNumber))}</strong>
|
||
<span>${semesterRows.length} дисциплин</span>
|
||
</div>
|
||
${semesterRows.length
|
||
? `<div class="calendar-subject-chips">${semesterRows.map(renderSubjectChip).join('')}</div>`
|
||
: '<div class="calendar-subject-empty">Нет дисциплин</div>'}
|
||
</section>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderSubjectChip(row) {
|
||
return `
|
||
<span class="calendar-subject-chip">
|
||
<span>${escapeHtml(subjectRowLabel(row))}</span>
|
||
<button type="button"
|
||
class="calendar-subject-remove"
|
||
data-semester="${escapeHtml(String(row.semesterNumber))}"
|
||
data-subject="${escapeHtml(String(row.subjectId))}"
|
||
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
async function loadCalendarGrid() {
|
||
hideAlert('calendar-editor-alert');
|
||
const calendar = selectedEditorCalendar();
|
||
if (!calendar) {
|
||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||
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);
|
||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||
} catch (error) {
|
||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||
}
|
||
}
|
||
|
||
async function saveCalendarGrid() {
|
||
hideAlert('calendar-editor-alert');
|
||
const calendar = selectedEditorCalendar();
|
||
if (!calendar) {
|
||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||
return;
|
||
}
|
||
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
||
calendarId: calendar.id,
|
||
courseNumber: Number(day.dataset.course),
|
||
date: day.dataset.date,
|
||
weekNumber: Number(day.dataset.week),
|
||
dayOfWeek: Number(day.dataset.day),
|
||
activityTypeId: Number(day.querySelector('.calendar-day-activity').value)
|
||
}));
|
||
if (!rows.length) {
|
||
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
|
||
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
|
||
await loadCalendarGrid();
|
||
} catch (error) {
|
||
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
|
||
}
|
||
}
|
||
|
||
function applyCalendarFill() {
|
||
hideAlert('calendar-editor-alert');
|
||
const activityId = fillActivitySelect.value;
|
||
const start = fillStartInput.value;
|
||
const end = fillEndInput.value;
|
||
if (!activityId || !start || !end) {
|
||
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
|
||
return;
|
||
}
|
||
if (end < start) {
|
||
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
|
||
return;
|
||
}
|
||
const course = fillCourseSelect.value;
|
||
let changed = 0;
|
||
calendarGrid.querySelectorAll('.calendar-day').forEach(day => {
|
||
const sameCourse = !course || day.dataset.course === course;
|
||
const inRange = day.dataset.date >= start && day.dataset.date <= end;
|
||
if (sameCourse && inRange) {
|
||
day.querySelector('.calendar-day-activity').value = activityId;
|
||
updateCalendarDayVisual(day);
|
||
changed += 1;
|
||
}
|
||
});
|
||
renderCalendarTotals();
|
||
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
|
||
}
|
||
|
||
function renderCalendarGrid(rows) {
|
||
activeCalendarDay = null;
|
||
selectedCalendarDayKey = null;
|
||
selectedCalendarDays = [];
|
||
const grouped = groupBy(rows, row => row.courseNumber);
|
||
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
|
||
<details class="calendar-course-block">
|
||
<summary class="calendar-course-header">
|
||
<span class="calendar-course-title">
|
||
<span class="calendar-course-chevron" aria-hidden="true"></span>
|
||
<span>${course} курс</span>
|
||
</span>
|
||
<span class="calendar-course-meta">${courseRows.length} дней</span>
|
||
</summary>
|
||
<div class="calendar-semesters-grid">
|
||
${renderCourseSemesters(Number(course), courseRows)}
|
||
</div>
|
||
</details>
|
||
`).join('');
|
||
populateFillCourseSelect();
|
||
renderCalendarTotals();
|
||
}
|
||
|
||
function renderCourseSemesters(course, courseRows) {
|
||
const calendar = selectedEditorCalendar();
|
||
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
|
||
const maxWeekColumns = Math.max(
|
||
...semestersForCourse.map(block => uniqueWeekNumbers(block.rows).length),
|
||
1
|
||
);
|
||
return semestersForCourse.map(block => `
|
||
<section class="calendar-semester-block">
|
||
<div class="calendar-semester-header">
|
||
<h4>${block.semesterNumber} семестр</h4>
|
||
<span>${escapeHtml(block.caption)}</span>
|
||
</div>
|
||
${renderSemesterTable(block.rows, maxWeekColumns)}
|
||
</section>
|
||
`).join('');
|
||
}
|
||
|
||
function renderSemesterTable(rows, targetWeekColumns = 0) {
|
||
if (!rows.length) {
|
||
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
|
||
}
|
||
|
||
const rowsByWeek = groupBy(rows, row => row.weekNumber);
|
||
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
|
||
const weekSlots = [...weeks];
|
||
while (weekSlots.length < targetWeekColumns) {
|
||
weekSlots.push(null);
|
||
}
|
||
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
|
||
return `
|
||
<div class="calendar-semester-table-wrap">
|
||
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
|
||
<colgroup>
|
||
<col class="calendar-day-head-col">
|
||
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th class="calendar-day-head">День</th>
|
||
${weekSlots.map(week => week === null
|
||
? '<th class="calendar-week-placeholder" aria-hidden="true"></th>'
|
||
: `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${DAY_OPTIONS.map(day => `
|
||
<tr>
|
||
<th class="calendar-day-head">${escapeHtml(day.shortLabel)}</th>
|
||
${weekSlots.map(week => {
|
||
if (week === null) {
|
||
return '<td class="calendar-empty-day calendar-week-placeholder" aria-hidden="true"></td>';
|
||
}
|
||
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
|
||
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
|
||
}).join('')}
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function uniqueWeekNumbers(rows) {
|
||
return Array.from(new Set(rows.map(row => row.weekNumber)));
|
||
}
|
||
|
||
function renderCalendarDay(row) {
|
||
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
|
||
const dayName = shortDayLabel(row.dayOfWeek);
|
||
const activity = activityById(selected) || activityTypes[0] || {};
|
||
const code = activity.code || row.activityCode || 'Т';
|
||
const color = activity.colorCode || '#64748b';
|
||
return `
|
||
<td class="calendar-day"
|
||
data-course="${row.courseNumber}"
|
||
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}`)}"
|
||
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>
|
||
`;
|
||
}
|
||
|
||
function openCalendarDayDialog(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),
|
||
commonCalendarDayActivityValue(selectedDays)
|
||
);
|
||
if (isSingle) {
|
||
populateTimeScopeDialogSelect(activeCalendarDay.dataset.date);
|
||
} else {
|
||
populateMultiSelectionTimeScopeDialogSelect();
|
||
}
|
||
isSyncingCalendarDayDialog = true;
|
||
syncSelects(calendarDayDialogActivitySelect);
|
||
syncSelects(calendarDayDialogTimeScopeSelect);
|
||
isSyncingCalendarDayDialog = false;
|
||
updateCalendarDayDialogSummaryForSelection(selectedDays);
|
||
renderCalendarTotals();
|
||
calendarDayDialog.classList.add('open');
|
||
}
|
||
|
||
function closeCalendarDayDialog() {
|
||
activeCalendarDay = null;
|
||
calendarDayDialog.classList.remove('open');
|
||
}
|
||
|
||
function updateCalendarDayDialogSummary(day) {
|
||
const activity = activityById(day.querySelector('.calendar-day-activity').value);
|
||
if (!activity) {
|
||
calendarDayDialogActivitySummary.innerHTML = '';
|
||
return;
|
||
}
|
||
calendarDayDialogActivitySummary.innerHTML = `
|
||
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
|
||
<div>
|
||
<strong>${escapeHtml(activity.name)}</strong>
|
||
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function updateCalendarDayDialogSummaryForSelection(days) {
|
||
if (days.length <= 1) {
|
||
updateCalendarDayDialogSummary(days[0]);
|
||
return;
|
||
}
|
||
|
||
const activity = activityById(commonCalendarDayActivityValue(days));
|
||
if (!activity) {
|
||
calendarDayDialogActivitySummary.innerHTML = `
|
||
<span class="calendar-day-dialog-code" style="--activity-color:#64748b">...</span>
|
||
<div>
|
||
<strong>Выбрано ячеек: ${days.length}</strong>
|
||
<p>Выберите код активности, чтобы применить его ко всему выделению</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
calendarDayDialogActivitySummary.innerHTML = `
|
||
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
|
||
<div>
|
||
<strong>${escapeHtml(activity.name)} · ${days.length} ячеек</strong>
|
||
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 || '';
|
||
calendarDayDialogTimeScopeSelect.disabled = false;
|
||
calendarDayDialogTimeSaveButton.disabled = false;
|
||
calendarDayDialogTimeClearButton.disabled = !assignment;
|
||
}
|
||
|
||
function populateMultiSelectionTimeScopeDialogSelect() {
|
||
calendarDayDialogTimeScopeSelect.innerHTML = '<option value="">Для выделения доступен только код дня</option>';
|
||
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 => {
|
||
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');
|
||
const activity = activityById(select.value);
|
||
const code = activity?.code || '-';
|
||
const color = activity?.colorCode || '#64748b';
|
||
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}`);
|
||
}
|
||
|
||
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 `
|
||
<div class="calendar-day-tooltip-head">
|
||
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
|
||
<div>
|
||
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
|
||
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
|
||
</div>
|
||
</div>
|
||
<div class="calendar-day-tooltip-body">
|
||
<div>
|
||
<span>Курс</span>
|
||
<strong>${escapeHtml(day.dataset.course)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Активность</span>
|
||
<strong>${escapeHtml(activity?.name || 'Не задана')}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Время</span>
|
||
<strong>${escapeHtml(timeLabel)}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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) {
|
||
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'));
|
||
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) {
|
||
return `${day.dataset.course}:${day.dataset.date}`;
|
||
}
|
||
|
||
function buildDefaultGrid(calendar) {
|
||
const rows = [];
|
||
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
|
||
for (let course = 1; course <= calendar.courseCount; course += 1) {
|
||
dates.forEach((date, index) => {
|
||
rows.push({
|
||
id: null,
|
||
calendarId: calendar.id,
|
||
courseNumber: course,
|
||
date: date.iso,
|
||
weekNumber: Math.floor(index / 7) + 1,
|
||
dayOfWeek: date.dayOfWeek,
|
||
activityTypeId: theoryActivityId(),
|
||
activityCode: 'Т'
|
||
});
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function semesterBlocksForCourse(calendar, course, courseRows) {
|
||
const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||
const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId));
|
||
const yearSemesters = [...(year?.semesters || [])]
|
||
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
|
||
|
||
if (!yearSemesters.length) {
|
||
return [{
|
||
semesterNumber: `${course * 2 - 1}-${course * 2}`,
|
||
caption: dateRangeCaption(sortedRows),
|
||
rows: sortedRows
|
||
}];
|
||
}
|
||
|
||
return yearSemesters.map((semester, index) => {
|
||
const nextSemester = yearSemesters[index + 1];
|
||
const startDate = index === 0
|
||
? minIso(calendar.academicYearStartDate, semester.startDate)
|
||
: semester.startDate;
|
||
const endDate = nextSemester
|
||
? previousIsoDate(nextSemester.startDate)
|
||
: maxIso(calendar.academicYearEndDate, semester.endDate);
|
||
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
|
||
return {
|
||
semesterNumber: course * 2 - 1 + index,
|
||
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
|
||
rows
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderCalendarTotals() {
|
||
const counts = new Map();
|
||
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
|
||
const activity = activityById(select.value);
|
||
const key = activity ? activity.code : '-';
|
||
counts.set(key, (counts.get(key) || 0) + 1);
|
||
});
|
||
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'))
|
||
.map(([code, count]) => {
|
||
const activity = activityTypes.find(item => item.code === code);
|
||
const selectedClass = selectedCode === code ? ' is-selected' : '';
|
||
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
|
||
return `<span class="calendar-total-chip${selectedClass}" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}${suffix}</span>`;
|
||
}).join('');
|
||
}
|
||
|
||
function populateYearSelects() {
|
||
const yearOptions = '<option value="">Выберите учебный год</option>' +
|
||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||
semesterYearSelect.innerHTML = yearOptions;
|
||
calendarYearSelect.innerHTML = yearOptions;
|
||
syncSelects(semesterYearSelect, calendarYearSelect);
|
||
}
|
||
|
||
function populateSpecialtySelects() {
|
||
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
|
||
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
|
||
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
|
||
syncSelects(calendarSpecialtySelect);
|
||
}
|
||
|
||
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
|
||
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
|
||
select.innerHTML = profiles.length
|
||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||
).join('')
|
||
: '<option value="">Нет профилей</option>';
|
||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||
select.value = selectedProfileId;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateStudyFormSelect() {
|
||
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
|
||
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||
syncSelects(calendarStudyFormSelect);
|
||
}
|
||
|
||
function populateSubjectSelect() {
|
||
if (!subjectSelect) return;
|
||
const current = subjectSelect.value;
|
||
const sortedSubjects = [...subjects].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
|
||
subjectSelect.innerHTML = sortedSubjects.length
|
||
? '<option value="">Выберите дисциплину</option>' + sortedSubjects.map(subject =>
|
||
`<option value="${subject.id}">${escapeHtml(subjectLabel(subject))}</option>`
|
||
).join('')
|
||
: '<option value="">Нет дисциплин</option>';
|
||
if (current && sortedSubjects.some(subject => String(subject.id) === String(current))) {
|
||
subjectSelect.value = current;
|
||
}
|
||
syncSelects(subjectSelect);
|
||
}
|
||
|
||
function populateActivitySelects() {
|
||
const activityOptions = '<option value="">Выберите код</option>' +
|
||
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
|
||
fillActivitySelect.innerHTML = activityOptions;
|
||
syncSelects(fillActivitySelect);
|
||
}
|
||
|
||
function populateEditorCalendarSelect() {
|
||
const current = editorCalendarSelect.value;
|
||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
|
||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||
editorCalendarSelect.value = current;
|
||
}
|
||
syncSelects(editorCalendarSelect);
|
||
}
|
||
|
||
function populateSubjectCalendarSelect() {
|
||
if (!subjectCalendarSelect) return;
|
||
const current = subjectCalendarSelect.value;
|
||
subjectCalendarSelect.innerHTML = calendars.length
|
||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
|
||
).join('')
|
||
: '<option value="">Нет графиков</option>';
|
||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||
subjectCalendarSelect.value = current;
|
||
}
|
||
populateSubjectSemesterSelect();
|
||
syncSelects(subjectCalendarSelect);
|
||
}
|
||
|
||
function populateSubjectSemesterSelect() {
|
||
if (!subjectSemesterSelect) return;
|
||
const calendar = selectedSubjectCalendar();
|
||
const current = subjectSemesterSelect.value;
|
||
if (!calendar) {
|
||
subjectSemesterSelect.innerHTML = '<option value="">Выберите график</option>';
|
||
syncSelects(subjectSemesterSelect);
|
||
return;
|
||
}
|
||
const semesterCount = calendarSemesterCount(calendar);
|
||
subjectSemesterSelect.innerHTML = Array.from({ length: semesterCount }, (_, index) => {
|
||
const semesterNumber = index + 1;
|
||
return `<option value="${semesterNumber}">${escapeHtml(semesterOptionLabel(semesterNumber))}</option>`;
|
||
}).join('');
|
||
if (current && Number(current) >= 1 && Number(current) <= semesterCount) {
|
||
subjectSemesterSelect.value = current;
|
||
}
|
||
syncSelects(subjectSemesterSelect);
|
||
}
|
||
|
||
function populateFillCourseSelect() {
|
||
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
|
||
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
|
||
Array.from(courses).sort((a, b) => Number(a) - Number(b))
|
||
.map(course => `<option value="${course}">${course} курс</option>`).join('');
|
||
syncSelects(fillCourseSelect);
|
||
}
|
||
|
||
function options(items, selectedValue) {
|
||
const selected = selectedValue == null ? '' : String(selectedValue);
|
||
return '<option value="">Выберите</option>' + items.map(item => {
|
||
const value = String(item.value);
|
||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toActivityOption(type) {
|
||
return { value: type.id, label: `${type.code} - ${type.name}` };
|
||
}
|
||
|
||
function selectedEditorCalendar() {
|
||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
||
}
|
||
|
||
function selectedSubjectCalendar() {
|
||
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
|
||
}
|
||
|
||
function calendarSemesterCount(calendar) {
|
||
return Math.max(1, Number(calendar?.courseCount || 0) * 2);
|
||
}
|
||
|
||
function semesterOptionLabel(semesterNumber) {
|
||
const course = Math.ceil(Number(semesterNumber) / 2);
|
||
const type = Number(semesterNumber) % 2 === 1 ? 'осенний' : 'весенний';
|
||
return `${semesterNumber} семестр · ${course} курс, ${type}`;
|
||
}
|
||
|
||
function sortCalendarSubjectRows(rows) {
|
||
return [...rows].sort((a, b) => {
|
||
const semesterDiff = Number(a.semesterNumber) - Number(b.semesterNumber);
|
||
if (semesterDiff !== 0) return semesterDiff;
|
||
return subjectRowLabel(a).localeCompare(subjectRowLabel(b), 'ru');
|
||
});
|
||
}
|
||
|
||
function subjectLabel(subject) {
|
||
const code = subject.code ? `${subject.code} · ` : '';
|
||
return `${code}${subject.name || 'Без названия'}`;
|
||
}
|
||
|
||
function subjectRowLabel(row) {
|
||
const code = row.subjectCode ? `${row.subjectCode} · ` : '';
|
||
return `${code}${row.subjectName || row.subjectId || 'Без названия'}`;
|
||
}
|
||
|
||
function theoryActivityId() {
|
||
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
|
||
}
|
||
|
||
function activityIdByCode(code) {
|
||
return activityTypes.find(type => type.code === code)?.id || null;
|
||
}
|
||
|
||
function activityById(id) {
|
||
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} (${formatDisplayDateRange(semester.startDate, semester.endDate)})`;
|
||
}
|
||
|
||
function semesterTypeTitle(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
const label = SEMESTER_LABELS[type] || type || 'семестр';
|
||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||
}
|
||
|
||
function normalizeSemesterType(type) {
|
||
return String(type || '').toLowerCase();
|
||
}
|
||
|
||
function specialityLabel(speciality) {
|
||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||
const name = speciality.specialityName || speciality.name || '';
|
||
return name ? `${code} - ${name}` : String(code);
|
||
}
|
||
|
||
function dayLabel(value) {
|
||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
|
||
}
|
||
|
||
function shortDayLabel(value) {
|
||
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.shortLabel || '-';
|
||
}
|
||
|
||
function formatShortDate(value) {
|
||
const [year, month, day] = String(value).split('-');
|
||
return `${day}.${month}`;
|
||
}
|
||
|
||
function formatLongDate(value) {
|
||
const [year, month, day] = String(value).split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
function formatDisplayDate(value) {
|
||
const [year, month, day] = String(value || '').split('-');
|
||
if (!year || !month || !day) return String(value || '');
|
||
return `${day}-${month}-${year}`;
|
||
}
|
||
|
||
function formatDisplayDateRange(startDate, endDate) {
|
||
return `${formatDisplayDate(startDate)} — ${formatDisplayDate(endDate)}`;
|
||
}
|
||
|
||
function dateRangeCaption(rows) {
|
||
if (!rows.length) return '';
|
||
const dates = rows.map(row => row.date).sort();
|
||
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
|
||
}
|
||
|
||
function minIso(a, b) {
|
||
return String(a) <= String(b) ? a : b;
|
||
}
|
||
|
||
function maxIso(a, b) {
|
||
return String(a) >= String(b) ? a : b;
|
||
}
|
||
|
||
function previousIsoDate(value) {
|
||
const date = new Date(`${value}T00:00:00Z`);
|
||
date.setUTCDate(date.getUTCDate() - 1);
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function enumerateDates(startIso, endIso) {
|
||
const result = [];
|
||
const current = new Date(`${startIso}T00:00:00Z`);
|
||
const end = new Date(`${endIso}T00:00:00Z`);
|
||
while (current <= end) {
|
||
const iso = current.toISOString().slice(0, 10);
|
||
const jsDay = current.getUTCDay();
|
||
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
|
||
current.setUTCDate(current.getUTCDate() + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function groupBy(items, keyFn) {
|
||
const grouped = new Map();
|
||
items.forEach(item => {
|
||
const key = keyFn(item);
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(item);
|
||
});
|
||
return grouped;
|
||
}
|
||
|
||
function syncSelects(...selects) {
|
||
selects.filter(Boolean).forEach(select => {
|
||
refreshSelectVisuals(select);
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
}
|
||
|
||
function refreshSelectVisuals(...selects) {
|
||
selects.filter(Boolean).forEach(select => {
|
||
if (select.customSelectInstance) {
|
||
select.customSelectInstance.rebuildMenu();
|
||
}
|
||
});
|
||
}
|
||
}
|