612 lines
26 KiB
JavaScript
612 lines
26 KiB
JavaScript
import { api } from '../api.js';
|
||
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
|
||
|
||
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
|
||
const ACTION_LABELS = {
|
||
MOVE: 'Перенос',
|
||
REPLACE: 'Замена',
|
||
CANCEL: 'Отмена'
|
||
};
|
||
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||
const MONTH_LABELS = [
|
||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||
];
|
||
|
||
export function canEditScheduleOverrides(role) {
|
||
return EDIT_ROLES.has(role);
|
||
}
|
||
|
||
export function overrideActionForChanges(changes) {
|
||
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
|
||
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
|
||
return null;
|
||
}
|
||
|
||
export function weekDates(value) {
|
||
const selected = parseIsoDate(value);
|
||
const day = selected.getDay() || 7;
|
||
const monday = addDays(selected, 1 - day);
|
||
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
|
||
}
|
||
|
||
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
|
||
const safeSlots = Array.isArray(slots) ? slots : [];
|
||
const byId = preferredId == null
|
||
? null
|
||
: safeSlots.find(slot => String(slot.id) === String(preferredId));
|
||
if (byId) return byId;
|
||
const normalizedStart = trimTime(startTime);
|
||
const normalizedEnd = trimTime(endTime);
|
||
if (!normalizedStart || !normalizedEnd) return null;
|
||
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
|
||
&& trimTime(slot.endTime) === normalizedEnd) || null;
|
||
}
|
||
|
||
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
|
||
if (!canEditScheduleOverrides(role)) {
|
||
return {
|
||
setRegistry() {},
|
||
openLesson() {},
|
||
openRegistry() {}
|
||
};
|
||
}
|
||
|
||
const drawer = document.getElementById('schedule-override-drawer');
|
||
const form = document.getElementById('schedule-override-form');
|
||
const empty = document.getElementById('schedule-override-empty');
|
||
const registryButton = document.getElementById('schedule-view-overrides-open');
|
||
const registryCount = document.getElementById('schedule-view-overrides-count');
|
||
const registry = document.getElementById('schedule-override-registry');
|
||
const registryPeriod = document.getElementById('schedule-override-registry-period');
|
||
const calendar = document.getElementById('schedule-override-calendar');
|
||
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
|
||
const week = document.getElementById('schedule-override-week');
|
||
const timeSelect = document.getElementById('schedule-override-time');
|
||
const teacherSelect = document.getElementById('schedule-override-teacher');
|
||
const classroomSelect = document.getElementById('schedule-override-classroom');
|
||
const formatSelect = document.getElementById('schedule-override-format');
|
||
const commentInput = document.getElementById('schedule-override-comment');
|
||
const restoreButton = document.getElementById('schedule-override-restore');
|
||
const cancelButton = document.getElementById('schedule-override-cancel');
|
||
const saveButton = document.getElementById('schedule-override-save');
|
||
|
||
const state = {
|
||
overrides: [],
|
||
selectedLesson: null,
|
||
selectedOverride: null,
|
||
source: null,
|
||
availability: null,
|
||
targetDate: null,
|
||
timeSlots: [],
|
||
busy: false
|
||
};
|
||
|
||
registryButton.hidden = false;
|
||
fillReferenceSelects();
|
||
bindEvents();
|
||
renderRegistry();
|
||
|
||
return { setRegistry, openLesson, openRegistry };
|
||
|
||
function bindEvents() {
|
||
drawer.querySelectorAll('[data-override-close]').forEach(button => {
|
||
button.addEventListener('click', close);
|
||
});
|
||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
|
||
});
|
||
registryButton.addEventListener('click', openRegistry);
|
||
calendarToggle.addEventListener('click', () => {
|
||
calendar.hidden = !calendar.hidden;
|
||
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
|
||
});
|
||
week.addEventListener('click', selectDateFromButton);
|
||
calendar.addEventListener('click', selectDateFromButton);
|
||
registry.addEventListener('click', event => {
|
||
const button = event.target.closest('[data-override-registry-id]');
|
||
if (!button) return;
|
||
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
|
||
if (item) openOverride(item);
|
||
});
|
||
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
|
||
select.addEventListener('change', updateComparison);
|
||
});
|
||
form.addEventListener('submit', save);
|
||
cancelButton.addEventListener('click', cancelLesson);
|
||
restoreButton.addEventListener('click', restoreRule);
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && !drawer.hidden) close();
|
||
});
|
||
}
|
||
|
||
function setRegistry(overrides, range) {
|
||
state.overrides = Array.isArray(overrides) ? overrides : [];
|
||
registryCount.textContent = String(state.overrides.length);
|
||
const activeRange = range || getRange?.();
|
||
registryPeriod.textContent = activeRange
|
||
? `${formatDate(activeRange.startDate)} — ${formatDate(activeRange.endDate)}`
|
||
: 'Период не выбран';
|
||
renderRegistry();
|
||
}
|
||
|
||
async function openLesson(lesson) {
|
||
if (!lesson?.scheduleRuleSlotId) return;
|
||
const originalDate = lesson.originalLessonDate || lesson.date;
|
||
const existing = state.overrides.find(override =>
|
||
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
|
||
&& override.lessonDate === originalDate
|
||
) || null;
|
||
await prepareEditor(lesson, existing);
|
||
}
|
||
|
||
async function openOverride(override) {
|
||
const syntheticLesson = {
|
||
scheduleRuleSlotId: override.baseRuleSlotId,
|
||
date: override.targetLessonDate || override.lessonDate,
|
||
originalLessonDate: override.lessonDate,
|
||
scheduleOverrideId: override.id,
|
||
subjectName: override.subjectName,
|
||
lessonTypeName: override.lessonTypeName,
|
||
groupNames: override.groupNames,
|
||
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
|
||
timeSlotOrder: override.sourceTimeSlotOrder,
|
||
startTime: override.sourceStartTime,
|
||
endTime: override.sourceEndTime,
|
||
teacherId: override.newTeacherId || override.sourceTeacherId,
|
||
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
|
||
classroomId: override.newClassroomId || override.sourceClassroomId,
|
||
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
|
||
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
|
||
};
|
||
await prepareEditor(syntheticLesson, override);
|
||
}
|
||
|
||
function openRegistry() {
|
||
open();
|
||
activateTab('registry');
|
||
renderRegistry();
|
||
}
|
||
|
||
async function prepareEditor(lesson, existing) {
|
||
open();
|
||
activateTab('edit');
|
||
form.hidden = true;
|
||
empty.hidden = false;
|
||
empty.textContent = 'Загрузка доступных дат и времени...';
|
||
hideAlert('schedule-override-alert');
|
||
|
||
state.selectedLesson = lesson;
|
||
state.selectedOverride = existing;
|
||
state.source = sourceFrom(lesson, existing);
|
||
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
|
||
|
||
try {
|
||
state.availability = await api.get(
|
||
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
|
||
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
|
||
);
|
||
renderDateChoices();
|
||
await loadTimeSlots(initialTimePreference(existing));
|
||
setEditorValues(existing);
|
||
renderSource();
|
||
updateComparison();
|
||
restoreButton.hidden = !existing;
|
||
form.hidden = false;
|
||
empty.hidden = true;
|
||
} catch (error) {
|
||
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
|
||
}
|
||
}
|
||
|
||
function sourceFrom(lesson, existing) {
|
||
return {
|
||
baseRuleSlotId: lesson.scheduleRuleSlotId,
|
||
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
|
||
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
|
||
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
|
||
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
|
||
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
|
||
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
|
||
startTime: existing?.sourceStartTime || lesson.startTime,
|
||
endTime: existing?.sourceEndTime || lesson.endTime,
|
||
teacherId: existing?.sourceTeacherId || lesson.teacherId,
|
||
teacherName: existing?.sourceTeacherName || lesson.teacherName,
|
||
classroomId: existing?.sourceClassroomId || lesson.classroomId,
|
||
classroomName: existing?.sourceClassroomName || lesson.classroomName,
|
||
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
|
||
};
|
||
}
|
||
|
||
function initialTimePreference(existing) {
|
||
return {
|
||
id: existing?.newTimeSlotId || state.source.timeSlotId,
|
||
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
|
||
endTime: existing?.newTimeSlotId ? null : state.source.endTime
|
||
};
|
||
}
|
||
|
||
function fillReferenceSelects() {
|
||
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
|
||
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
|
||
).join('');
|
||
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
|
||
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function setEditorValues(existing) {
|
||
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
|
||
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
|
||
ensureSelectOption(
|
||
teacherSelect,
|
||
effectiveTeacherId,
|
||
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
|
||
);
|
||
ensureSelectOption(
|
||
classroomSelect,
|
||
effectiveClassroomId,
|
||
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
|
||
);
|
||
teacherSelect.value = String(effectiveTeacherId);
|
||
classroomSelect.value = String(effectiveClassroomId);
|
||
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
|
||
commentInput.value = existing?.comment || '';
|
||
}
|
||
|
||
async function selectDateFromButton(event) {
|
||
const button = event.target.closest('[data-override-date]');
|
||
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
|
||
state.targetDate = button.dataset.overrideDate;
|
||
renderDateChoices();
|
||
await loadTimeSlots({
|
||
id: state.source.timeSlotId,
|
||
startTime: state.source.startTime,
|
||
endTime: state.source.endTime
|
||
});
|
||
updateComparison();
|
||
}
|
||
|
||
function renderDateChoices() {
|
||
const available = new Set(state.availability?.availableDates || []);
|
||
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
|
||
calendar.innerHTML = renderSemesterCalendar(
|
||
state.availability.semesterStartDate,
|
||
state.availability.semesterEndDate,
|
||
available,
|
||
state.targetDate
|
||
);
|
||
}
|
||
|
||
function dateButton(date, label, available) {
|
||
const selected = date === state.targetDate ? ' active' : '';
|
||
const disabled = available.has(date) ? '' : ' disabled';
|
||
return `
|
||
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
|
||
<span>${escapeHtml(label)}</span>
|
||
<strong>${escapeHtml(formatDateShort(date))}</strong>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
async function loadTimeSlots(preference) {
|
||
timeSelect.disabled = true;
|
||
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||
state.timeSlots = await api.get(
|
||
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
|
||
);
|
||
const selected = pickMatchingTimeSlot(
|
||
state.timeSlots,
|
||
preference?.id,
|
||
preference?.startTime,
|
||
preference?.endTime
|
||
);
|
||
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
|
||
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
|
||
).join('');
|
||
timeSelect.value = selected ? String(selected.id) : '';
|
||
timeSelect.disabled = false;
|
||
}
|
||
|
||
function renderSource() {
|
||
document.getElementById('schedule-override-before-title').textContent =
|
||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||
document.getElementById('schedule-override-before-meta').textContent = [
|
||
formatDate(state.source.lessonDate),
|
||
intervalLabel(state.source.startTime, state.source.endTime),
|
||
state.source.teacherName,
|
||
state.source.classroomName,
|
||
state.source.lessonFormat,
|
||
state.source.groupNames.join(', ')
|
||
].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
function updateComparison() {
|
||
if (!state.source) return;
|
||
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
|
||
document.getElementById('schedule-override-after-title').textContent =
|
||
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
|
||
document.getElementById('schedule-override-after-meta').textContent = [
|
||
formatDate(state.targetDate),
|
||
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
|
||
teacherName(teacherSelect.value),
|
||
classroomName(classroomSelect.value),
|
||
formatSelect.value,
|
||
state.source.groupNames.join(', ')
|
||
].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
async function save(event) {
|
||
event.preventDefault();
|
||
if (state.busy) return;
|
||
hideAlert('schedule-override-alert');
|
||
const payload = buildPayload();
|
||
if (!payload) return;
|
||
await mutate(async () => {
|
||
if (state.selectedOverride) {
|
||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||
} else {
|
||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildPayload() {
|
||
if (!timeSelect.value) {
|
||
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
|
||
return null;
|
||
}
|
||
const dateChanged = state.targetDate !== state.source.lessonDate;
|
||
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
|
||
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
|
||
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
|
||
const formatChanged = formatSelect.value !== state.source.lessonFormat;
|
||
const action = overrideActionForChanges({
|
||
dateChanged,
|
||
timeChanged,
|
||
teacherChanged,
|
||
classroomChanged,
|
||
formatChanged
|
||
});
|
||
if (!action) {
|
||
showAlert(
|
||
'schedule-override-alert',
|
||
state.selectedOverride
|
||
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
|
||
: 'Измените дату, время, преподавателя, аудиторию или формат',
|
||
'error'
|
||
);
|
||
return null;
|
||
}
|
||
return {
|
||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||
lessonDate: state.source.lessonDate,
|
||
targetLessonDate: dateChanged ? state.targetDate : null,
|
||
action,
|
||
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
|
||
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
|
||
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
|
||
newLessonFormat: formatChanged ? formatSelect.value : null,
|
||
comment: commentInput.value.trim() || null
|
||
};
|
||
}
|
||
|
||
async function cancelLesson() {
|
||
if (state.busy || !state.source) return;
|
||
const payload = {
|
||
baseRuleSlotId: state.source.baseRuleSlotId,
|
||
lessonDate: state.source.lessonDate,
|
||
targetLessonDate: null,
|
||
action: 'CANCEL',
|
||
newTimeSlotId: null,
|
||
newTeacherId: null,
|
||
newClassroomId: null,
|
||
newLessonFormat: null,
|
||
comment: commentInput.value.trim() || null
|
||
};
|
||
await mutate(async () => {
|
||
if (state.selectedOverride) {
|
||
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
|
||
} else {
|
||
await api.post('/api/edu-office/schedule/overrides', payload);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function restoreRule() {
|
||
if (state.busy || !state.selectedOverride) return;
|
||
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
|
||
}
|
||
|
||
async function mutate(operation) {
|
||
setBusy(true);
|
||
hideAlert('schedule-override-alert');
|
||
try {
|
||
await operation();
|
||
await onChanged?.();
|
||
state.selectedLesson = null;
|
||
state.selectedOverride = null;
|
||
state.source = null;
|
||
form.hidden = true;
|
||
empty.hidden = false;
|
||
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
|
||
openRegistry();
|
||
} catch (error) {
|
||
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function setBusy(busy) {
|
||
state.busy = busy;
|
||
saveButton.disabled = busy;
|
||
cancelButton.disabled = busy;
|
||
restoreButton.disabled = busy;
|
||
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
|
||
}
|
||
|
||
function renderRegistry() {
|
||
if (!registry) return;
|
||
if (!state.overrides.length) {
|
||
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
|
||
return;
|
||
}
|
||
registry.innerHTML = state.overrides.map(override => {
|
||
const target = override.targetLessonDate
|
||
? ` → ${formatDate(override.targetLessonDate)}`
|
||
: '';
|
||
const result = override.action === 'CANCEL'
|
||
? 'Занятие отменено'
|
||
: [
|
||
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
|
||
teacherName(override.newTeacherId),
|
||
classroomName(override.newClassroomId),
|
||
override.newLessonFormat
|
||
].filter(Boolean).join(' · ');
|
||
return `
|
||
<article class="schedule-override-registry-item">
|
||
<div class="schedule-override-registry-topline">
|
||
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
|
||
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
|
||
</div>
|
||
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
|
||
<p>${escapeHtml([
|
||
override.lessonTypeName,
|
||
(override.groupNames || []).join(', '),
|
||
result || 'Параметры изменены'
|
||
].filter(Boolean).join(' · '))}</p>
|
||
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
|
||
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
|
||
</article>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function activateTab(tab) {
|
||
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
|
||
button.classList.toggle('active', button.dataset.overrideTab === tab);
|
||
});
|
||
drawer.querySelectorAll('[data-override-section]').forEach(section => {
|
||
section.hidden = section.dataset.overrideSection !== tab;
|
||
});
|
||
}
|
||
|
||
function open() {
|
||
drawer.hidden = false;
|
||
drawer.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('schedule-override-open');
|
||
}
|
||
|
||
function close() {
|
||
drawer.hidden = true;
|
||
drawer.setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('schedule-override-open');
|
||
}
|
||
|
||
function teacherName(id) {
|
||
if (id == null || id === '') return '';
|
||
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
|
||
return teacher?.fullName || teacher?.username || '';
|
||
}
|
||
|
||
function classroomName(id) {
|
||
if (id == null || id === '') return '';
|
||
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
|
||
return classroom ? classroomOptionLabel(classroom) : '';
|
||
}
|
||
}
|
||
|
||
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
|
||
if (!startDate || !endDate) return '';
|
||
const start = parseIsoDate(startDate);
|
||
const end = parseIsoDate(endDate);
|
||
const months = [];
|
||
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||
cursor <= end;
|
||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
|
||
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
|
||
}
|
||
return months.join('');
|
||
}
|
||
|
||
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
|
||
const year = month.getFullYear();
|
||
const monthIndex = month.getMonth();
|
||
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
|
||
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
|
||
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
|
||
const days = Array.from({ length: dayCount }, (_, index) => {
|
||
const date = toIsoDate(new Date(year, monthIndex, index + 1));
|
||
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
|
||
const active = date === selectedDate ? ' active' : '';
|
||
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
|
||
}).join('');
|
||
return `
|
||
<section class="schedule-override-month">
|
||
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
|
||
<div class="schedule-override-calendar-grid">
|
||
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
|
||
${blanks}${days}
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function timeSlotLabel(slot) {
|
||
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
|
||
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
|
||
}
|
||
|
||
function intervalLabel(start, end) {
|
||
return `${trimTime(start)}–${trimTime(end)}`;
|
||
}
|
||
|
||
export function classroomOptionLabel(room) {
|
||
return room?.name || '';
|
||
}
|
||
|
||
function ensureSelectOption(select, value, label) {
|
||
if (value == null || value === '') return;
|
||
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
|
||
if (exists) return;
|
||
const option = document.createElement('option');
|
||
option.value = String(value);
|
||
option.textContent = label;
|
||
select.append(option);
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return '—';
|
||
const [year, month, day] = String(value).split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
function formatDateShort(value) {
|
||
const [, month, day] = String(value).split('-');
|
||
return `${day}.${month}`;
|
||
}
|
||
|
||
function trimTime(value) {
|
||
return String(value || '').slice(0, 5);
|
||
}
|
||
|
||
function parseIsoDate(value) {
|
||
const [year, month, day] = String(value).split('-').map(Number);
|
||
return new Date(year, month - 1, day);
|
||
}
|
||
|
||
function addDays(date, days) {
|
||
const next = new Date(date);
|
||
next.setDate(next.getDate() + days);
|
||
return next;
|
||
}
|
||
|
||
function toIsoDate(date) {
|
||
return [
|
||
date.getFullYear(),
|
||
String(date.getMonth() + 1).padStart(2, '0'),
|
||
String(date.getDate()).padStart(2, '0')
|
||
].join('-');
|
||
}
|