import {
clearAuthState,
fetchWithAuth,
logoutSession,
restoreSession
} from '../auth-session.js';
(async () => {
const session = await restoreSession();
if (!session || session.role !== 'TEACHER') {
window.location.replace('/');
return;
}
const teacherId = session.userId;
const grid = document.getElementById('schedule-grid');
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
const absenceForm = document.getElementById('teacher-absence-form');
const absenceList = document.getElementById('teacher-absence-list');
const absenceStatus = document.getElementById('teacher-absence-status');
const absenceStart = document.getElementById('teacher-absence-start');
const absenceEnd = document.getElementById('teacher-absence-end');
const absenceReason = document.getElementById('teacher-absence-reason');
const preferenceSemester = document.getElementById('preference-semester');
const preferenceGrid = document.getElementById('preference-grid');
const preferenceMessage = document.getElementById('preference-message');
const preferenceDateForm = document.getElementById('preference-date-form');
const preferenceDate = document.getElementById('preference-date');
const preferenceDateComment = document.getElementById('preference-date-comment');
const preferenceDateList = document.getElementById('preference-date-list');
const preferenceConsecutive = document.getElementById('preference-consecutive');
const preferenceNoGaps = document.getElementById('preference-no-gaps');
const requestList = document.getElementById('change-request-list');
const requestOverlay = document.getElementById('request-dialog-overlay');
const requestForm = document.getElementById('change-request-form');
const requestType = document.getElementById('change-request-type');
const requestDate = document.getElementById('change-request-date');
const requestTime = document.getElementById('change-request-time');
const requestClassroom = document.getElementById('change-request-classroom');
const requestReason = document.getElementById('change-request-reason');
const requestMessage = document.getElementById('request-dialog-message');
let preferenceMeta = { semesters: [], timeSlots: [] };
let preferences = [];
let scheduleLessons = new Map();
let activeRequestLesson = null;
let activeCandidates = null;
let weekStart = startOfWeek(new Date());
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
await logoutSession();
window.location.replace('/');
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
document.getElementById('absence-form-toggle').addEventListener('click', event => {
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
event.currentTarget.textContent = expanded ? 'Новая заявка' : 'Скрыть форму';
absenceForm.hidden = expanded;
});
absenceForm.addEventListener('submit', submitAbsence);
absenceList.addEventListener('click', cancelAbsence);
document.querySelectorAll('[data-workspace-tab]').forEach(button => {
button.addEventListener('click', () => switchWorkspace(button.dataset.workspaceTab));
});
preferenceSemester.addEventListener('change', loadPreferences);
preferenceGrid.addEventListener('click', handlePreferenceCell);
preferenceDateForm.addEventListener('submit', submitUnavailableDate);
preferenceDateList.addEventListener('click', cancelPreference);
preferenceConsecutive.addEventListener('change', () => toggleCompactPreference('CONSECUTIVE', preferenceConsecutive));
preferenceNoGaps.addEventListener('change', () => toggleCompactPreference('NO_GAPS', preferenceNoGaps));
grid.addEventListener('click', event => {
const button = event.target.closest('[data-request-lesson]');
if (button) openRequestDialog(button.dataset.requestLesson);
});
document.getElementById('change-request-refresh').addEventListener('click', loadChangeRequests);
requestList.addEventListener('click', cancelChangeRequest);
document.getElementById('request-dialog-close').addEventListener('click', closeRequestDialog);
document.getElementById('request-dialog-cancel').addEventListener('click', closeRequestDialog);
requestOverlay.addEventListener('click', event => {
if (event.target === requestOverlay) closeRequestDialog();
});
requestType.addEventListener('change', updateRequestFields);
requestDate.addEventListener('change', reloadRequestCandidates);
requestForm.addEventListener('submit', submitChangeRequest);
const todayIso = toIsoDate(new Date());
absenceStart.value = todayIso;
absenceEnd.value = todayIso;
loadSchedule();
loadAbsences();
loadPreferenceMeta();
loadChangeRequests();
function switchWorkspace(name) {
document.querySelectorAll('[data-workspace-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.workspaceTab === name);
});
document.querySelectorAll('[data-workspace-view]').forEach(view => {
const active = view.dataset.workspaceView === name;
view.hidden = !active;
view.classList.toggle('active', active);
});
}
async function loadPreferenceMeta() {
try {
preferenceMeta = await fetchJson('/api/teacher-preferences/meta');
const semesters = preferenceMeta.semesters || [];
preferenceSemester.innerHTML = semesters.map(semester => `
`).join('');
const today = toIsoDate(new Date());
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
if (current) preferenceSemester.value = String(current.id);
await loadPreferences();
} catch (error) {
showPreferenceMessage(error.message, true);
preferenceGrid.innerHTML = '
Не удалось загрузить параметры семестра
';
}
}
async function loadPreferences() {
if (!preferenceSemester.value) return;
preferenceGrid.innerHTML = 'Загрузка календаря...
';
try {
preferences = await fetchJson(`/api/teacher-preferences?semesterId=${encodeURIComponent(preferenceSemester.value)}`);
const semester = selectedSemester();
preferenceDate.min = semester?.startDate || '';
preferenceDate.max = semester?.endDate || '';
if (!preferenceDate.value || preferenceDate.value < preferenceDate.min || preferenceDate.value > preferenceDate.max) {
preferenceDate.value = semester?.startDate || '';
}
renderPreferenceGrid();
renderPreferenceDates();
syncCompactToggles();
showPreferenceMessage('', false);
} catch (error) {
preferenceGrid.innerHTML = `${escapeHtml(error.message)}
`;
}
}
function renderPreferenceGrid() {
const slots = preferenceMeta.timeSlots || [];
if (!slots.length) {
preferenceGrid.innerHTML = 'Базовая сетка времени не настроена
';
return;
}
const active = preferences.filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
preferenceGrid.innerHTML = [
'Пара
',
...['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => `${day}
`),
...slots.flatMap(slot => [
`${slot.orderNumber}${trimTime(slot.startTime)}–${trimTime(slot.endTime)}
`,
...Array.from({ length: 7 }, (_, index) => {
const item = active.find(candidate => candidate.dayOfWeek === index + 1 && candidate.timeSlotId === slot.id);
const typeClass = preferenceTypeClass(item?.preferenceType);
const pendingClass = item?.status === 'PENDING' ? ' is-pending' : '';
const title = item
? `${preferenceTypeLabel(item.preferenceType)} · ${preferenceStatusLabel(item.status)}`
: 'Добавить отметку';
return ``;
})
])
].join('');
}
async function handlePreferenceCell(event) {
const button = event.target.closest('.preference-cell');
if (!button) return;
if (button.dataset.preferenceId) {
if (button.dataset.preferenceStatus === 'APPROVED') {
showPreferenceMessage('Согласованную отметку может снять кафедра или учебный отдел', true);
return;
}
await deletePreference(button.dataset.preferenceId);
return;
}
button.disabled = true;
try {
await fetchJson('/api/teacher-preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
semesterId: Number(preferenceSemester.value),
preferenceType: document.getElementById('preference-brush').value,
dayOfWeek: Number(button.dataset.day),
timeSlotId: Number(button.dataset.slot)
})
});
showPreferenceMessage('Отметка отправлена на согласование', false);
await loadPreferences();
} catch (error) {
showPreferenceMessage(error.message, true);
button.disabled = false;
}
}
async function submitUnavailableDate(event) {
event.preventDefault();
if (!preferenceDate.value) return;
try {
await fetchJson('/api/teacher-preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
semesterId: Number(preferenceSemester.value),
preferenceType: 'HARD_UNAVAILABLE',
preferenceDate: preferenceDate.value,
comment: preferenceDateComment.value.trim() || null
})
});
preferenceDateComment.value = '';
showPreferenceMessage('Дата недоступности отправлена на согласование', false);
await loadPreferences();
} catch (error) {
showPreferenceMessage(error.message, true);
}
}
function renderPreferenceDates() {
const dates = preferences.filter(item => item.preferenceDate && item.status !== 'CANCELLED');
preferenceDateList.innerHTML = dates.length ? dates.map(item => `
${escapeHtml(formatIsoDate(item.preferenceDate))}${escapeHtml(item.comment || 'Весь день')}
${escapeHtml(preferenceStatusLabel(item.status))}
${item.status === 'PENDING' ? `` : ''}
`).join('') : 'Даты полной недоступности не добавлены
';
}
async function cancelPreference(event) {
const button = event.target.closest('[data-cancel-preference]');
if (button) await deletePreference(button.dataset.cancelPreference);
}
async function deletePreference(id) {
try {
await fetchJson(`/api/teacher-preferences/${encodeURIComponent(id)}`, { method: 'DELETE' });
showPreferenceMessage('Ожидающее пожелание отменено', false);
await loadPreferences();
} catch (error) {
showPreferenceMessage(error.message, true);
}
}
function syncCompactToggles() {
syncCompactToggle(preferenceConsecutive, 'CONSECUTIVE');
syncCompactToggle(preferenceNoGaps, 'NO_GAPS');
}
function syncCompactToggle(input, type) {
const item = preferences.find(candidate => candidate.preferenceType === type && ['PENDING', 'APPROVED'].includes(candidate.status));
input.checked = Boolean(item);
input.dataset.preferenceId = item?.id || '';
input.disabled = item?.status === 'APPROVED';
input.closest('.preference-toggle-card')?.classList.toggle('approved', item?.status === 'APPROVED');
}
async function toggleCompactPreference(type, input) {
try {
if (!input.checked && input.dataset.preferenceId) {
await fetchJson(`/api/teacher-preferences/${encodeURIComponent(input.dataset.preferenceId)}`, { method: 'DELETE' });
} else if (input.checked) {
await fetchJson('/api/teacher-preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ semesterId: Number(preferenceSemester.value), preferenceType: type })
});
}
await loadPreferences();
} catch (error) {
showPreferenceMessage(error.message, true);
await loadPreferences();
}
}
function showPreferenceMessage(message, isError) {
preferenceMessage.textContent = message || '';
preferenceMessage.classList.toggle('error', Boolean(isError));
preferenceMessage.classList.toggle('success', Boolean(message) && !isError);
}
async function loadChangeRequests() {
requestList.innerHTML = 'Загрузка заявок...
';
try {
const items = await fetchJson('/api/teacher-change-requests');
document.getElementById('change-request-count').textContent = String(items.filter(item => item.status === 'PENDING').length);
requestList.innerHTML = items.length ? items.map(renderChangeRequest).join('')
: 'Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».
';
} catch (error) {
requestList.innerHTML = `${escapeHtml(error.message)}
`;
}
}
function renderChangeRequest(item) {
const target = item.requestType === 'MOVE'
? `${formatIsoDate(item.targetLessonDate || item.lessonDate)}, ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
: item.requestType === 'CHANGE_CLASSROOM'
? item.requestedClassroomName
: 'Отмена занятия';
const history = (item.history || []).map(entry => `
${preferenceStatusLabel(entry.status)}${escapeHtml(entry.comment || 'Без комментария')}
`).join('');
return `
${escapeHtml(formatIsoDate(item.lessonDate))} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)}
${escapeHtml(item.subjectName)}
${escapeHtml((item.groupNames || []).join(', '))} · ${escapeHtml(item.sourceClassroomName)}
${escapeHtml(changeTypeLabel(item.requestType))}→${escapeHtml(target)}
${escapeHtml(item.reason)}
${item.reviewComment ? `` : ''}
История решения · ${(item.history || []).length}
${history}
${escapeHtml(preferenceStatusLabel(item.status))}
${item.status === 'PENDING' ? `` : ''}
`;
}
async function openRequestDialog(key) {
activeRequestLesson = scheduleLessons.get(key);
if (!activeRequestLesson) return;
requestOverlay.hidden = false;
document.body.classList.add('dialog-open');
document.getElementById('request-dialog-lesson').textContent = `${activeRequestLesson.subjectName} · ${formatIsoDate(activeRequestLesson.date)} · ${trimTime(activeRequestLesson.startTime)}–${trimTime(activeRequestLesson.endTime)}`;
requestReason.value = '';
requestType.value = 'MOVE';
setRequestMessage('Проверяем допустимые варианты...', false);
try {
activeCandidates = await fetchJson(`/api/teacher-change-requests/candidates?baseRuleSlotId=${encodeURIComponent(activeRequestLesson.scheduleRuleSlotId)}&lessonDate=${encodeURIComponent(activeRequestLesson.date)}`);
renderCandidateDates();
renderCandidateOptions();
updateRequestFields();
setRequestMessage('Доступны только варианты, прошедшие проверку конфликтов', false);
} catch (error) {
setRequestMessage(error.message, true);
}
}
function closeRequestDialog() {
requestOverlay.hidden = true;
document.body.classList.remove('dialog-open');
activeRequestLesson = null;
activeCandidates = null;
}
function renderCandidateDates() {
requestDate.innerHTML = (activeCandidates?.availableDates || []).map(date => `
`).join('');
}
function renderCandidateOptions() {
requestTime.innerHTML = candidateOptions(activeCandidates?.timeSlots, 'Нет свободных временных слотов');
requestClassroom.innerHTML = candidateOptions(activeCandidates?.classrooms, 'Нет свободных аудиторий');
}
function candidateOptions(items, emptyLabel) {
const available = (items || []).filter(item => item.available);
return available.length
? available.map(item => ``).join('')
: ``;
}
async function reloadRequestCandidates() {
if (!activeRequestLesson || !requestDate.value) return;
setRequestMessage('Проверяем выбранную дату...', false);
try {
activeCandidates = await fetchJson(`/api/teacher-change-requests/candidates?baseRuleSlotId=${encodeURIComponent(activeRequestLesson.scheduleRuleSlotId)}&lessonDate=${encodeURIComponent(activeRequestLesson.date)}&targetDate=${encodeURIComponent(requestDate.value)}`);
renderCandidateOptions();
setRequestMessage('Предварительная проверка завершена', false);
} catch (error) {
setRequestMessage(error.message, true);
}
}
function updateRequestFields() {
document.querySelector('[data-request-field="date"]').hidden = requestType.value !== 'MOVE';
document.querySelector('[data-request-field="time"]').hidden = requestType.value !== 'MOVE';
document.querySelector('[data-request-field="classroom"]').hidden = requestType.value !== 'CHANGE_CLASSROOM';
}
async function submitChangeRequest(event) {
event.preventDefault();
if (!activeRequestLesson) return;
if (!requestReason.value.trim()) {
setRequestMessage('Укажите обоснование заявки', true);
return;
}
const type = requestType.value;
const payload = {
baseRuleSlotId: activeRequestLesson.scheduleRuleSlotId,
lessonDate: activeRequestLesson.date,
requestType: type,
targetLessonDate: type === 'MOVE' ? requestDate.value : null,
requestedTimeSlotId: type === 'MOVE' ? Number(requestTime.value) || null : null,
requestedClassroomId: type === 'CHANGE_CLASSROOM' ? Number(requestClassroom.value) || null : null,
reason: requestReason.value.trim()
};
try {
await fetchJson('/api/teacher-change-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
closeRequestDialog();
await loadChangeRequests();
switchWorkspace('requests');
} catch (error) {
setRequestMessage(error.message, true);
}
}
async function cancelChangeRequest(event) {
const button = event.target.closest('[data-cancel-request]');
if (!button) return;
try {
await fetchJson(`/api/teacher-change-requests/${encodeURIComponent(button.dataset.cancelRequest)}`, { method: 'DELETE' });
await loadChangeRequests();
} catch (error) {
requestList.insertAdjacentHTML('afterbegin', `${escapeHtml(error.message)}
`);
}
}
function setRequestMessage(message, isError) {
requestMessage.textContent = message || '';
requestMessage.classList.toggle('error', Boolean(isError));
}
async function loadAbsences() {
absenceList.innerHTML = 'Загрузка заявок...
';
try {
const items = await fetchJson('/api/teacher-absences');
absenceList.innerHTML = items.length ? items.map(item => `
${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))}
${escapeHtml(item.reason || 'Причина не указана')}
${escapeHtml(absenceStatusLabel(item.status))}
${item.status === 'PENDING' ? `` : ''}
`).join('') : 'Заявок об отсутствии пока нет
';
} catch (error) {
absenceList.innerHTML = `${escapeHtml(error.message)}
`;
}
}
async function submitAbsence(event) {
event.preventDefault();
showAbsenceStatus('', false);
if (!absenceStart.value || !absenceEnd.value || !absenceReason.value.trim()) {
showAbsenceStatus('Заполните период и причину отсутствия', true);
return;
}
if (absenceEnd.value < absenceStart.value) {
showAbsenceStatus('Дата окончания не может быть раньше даты начала', true);
return;
}
try {
await fetchJson('/api/teacher-absences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate: absenceStart.value,
endDate: absenceEnd.value,
reason: absenceReason.value.trim()
})
});
absenceReason.value = '';
showAbsenceStatus('Заявка отправлена кафедре', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
}
}
async function cancelAbsence(event) {
const button = event.target.closest('[data-cancel-absence]');
if (!button) return;
button.disabled = true;
try {
await fetchJson(`/api/teacher-absences/${encodeURIComponent(button.dataset.cancelAbsence)}`, {
method: 'DELETE'
});
showAbsenceStatus('Заявка отменена', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
button.disabled = false;
}
}
function showAbsenceStatus(message, isError) {
absenceStatus.textContent = message || '';
absenceStatus.classList.toggle('error', Boolean(isError));
absenceStatus.classList.toggle('success', Boolean(message) && !isError);
}
async function loadSchedule() {
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
if (!teacherId) {
renderEmptyWeek('Не удалось определить преподавателя');
showStatus('Выполните вход заново, чтобы получить идентификатор пользователя', true);
return;
}
renderEmptyWeek('Загрузка...');
try {
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
scheduleLessons = new Map(lessons.map(lesson => [lessonKey(lesson), lesson]));
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
if (window.matchMedia('(max-width: 980px)').matches) {
renderMobileWeek(lessons);
return;
}
const slots = collectSlots(lessons);
const lessonsByKey = new Map();
lessons.forEach(lesson => {
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
});
grid.innerHTML = [
'',
...daysOfWeek().map(date => renderHead(date)),
...slots.flatMap(slot => [
`${escapeHtml(slot.label)}
`,
...daysOfWeek().map(date => renderCell(lessonsByKey.get(`${toIsoDate(date)}:${slot.order}`)))
])
].join('');
}
function renderMobileWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return `
${renderHead(date)}
${dayLessons.length ? dayLessons.map(renderLesson).join('') : '
Занятий нет
'}
`;
}).join('');
}
function renderEmptyWeek(message) {
if (window.matchMedia('(max-width: 980px)').matches) {
grid.innerHTML = daysOfWeek().map(date => `
${renderHead(date)}
`).join('');
return;
}
grid.innerHTML = [
'',
...daysOfWeek().map(date => renderHead(date)),
`Неделя
`,
...daysOfWeek().map(() => ``)
].join('');
}
function renderHead(date) {
return `
${escapeHtml(dayName(date))}
${escapeHtml(formatDate(date))}
`;
}
function renderCell(lesson) {
return `${lesson ? renderLesson(lesson) : '
Нет занятия
'}
`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
${escapeHtml(lesson.subjectName)}
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}
${escapeHtml(lesson.lessonTypeName)}
${subgroups ? `${escapeHtml(subgroups)}` : ''}
${escapeHtml(lesson.classroomName)}
${groups ? `${escapeHtml(groups)}` : ''}
${lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId ? `
` : '
Занятие уже изменено учебным отделом'}
`;
}
function lessonKey(lesson) {
return `${lesson.scheduleRuleSlotId}:${lesson.date}`;
}
function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
slots.set(lesson.timeSlotOrder, {
order: lesson.timeSlotOrder,
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
});
});
if (!slots.size) {
slots.set(1, { order: 1, label: 'Расписание' });
}
return [...slots.values()].sort((a, b) => a.order - b.order);
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url, options = {}) {
const response = await fetchWithAuth(url, options);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
window.location.replace('/');
throw new Error('Требуется повторный вход в систему');
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
function showStatus(message, isError) {
statusLine.textContent = message;
statusLine.classList.toggle('error', isError);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatIsoDate(value) {
const [year, month, day] = String(value || '').split('-');
return year && month && day ? `${day}.${month}.${year}` : '—';
}
function absenceStatusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
RESOLVED: 'Замены выполнены',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function absenceStatusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function selectedSemester() {
return (preferenceMeta.semesters || []).find(item => String(item.id) === String(preferenceSemester.value));
}
function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatIsoDate(semester.startDate)}–${formatIsoDate(semester.endDate)}`;
}
function preferenceTypeLabel(type) {
return {
HARD_UNAVAILABLE: 'Строго недоступен',
SOFT_PREFERRED: 'Предпочтительно',
SOFT_UNWANTED: 'Нежелательно',
CONSECUTIVE: 'Пары подряд',
NO_GAPS: 'Без окон'
}[type] || type || '—';
}
function preferenceTypeClass(type) {
return {
HARD_UNAVAILABLE: 'is-hard',
SOFT_PREFERRED: 'is-preferred',
SOFT_UNWANTED: 'is-unwanted'
}[type] || '';
}
function preferenceTypeSymbol(type) {
return { HARD_UNAVAILABLE: '×', SOFT_PREFERRED: '✓', SOFT_UNWANTED: '!' }[type] || '•';
}
function preferenceStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function changeTypeLabel(type) {
return {
MOVE: 'Перенос',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена'
}[type] || type || '—';
}
function formatInstant(value) {
if (!value) return '—';
return new Date(value).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
})();