import { clearAuthState, fetchWithAuth, logoutSession, restoreSession } from '../auth-session.js'; import { renderScheduleOverview, renderScheduleOverviewState } from '../schedule-overview.js'; import { readUrlState, writeUrlState } from '../admin/js/url-state.js'; import { trackUxEvent } from '../telemetry.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 scheduleOverview = document.getElementById('schedule-overview'); 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; const pageState = readUrlState({ teacherTab: 'schedule', teacherDate: '' }); let weekStart = startOfWeek(pageState.teacherDate ? parseIsoDate(pageState.teacherDate) : 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', goToCurrentWeek); document.getElementById('next-week').addEventListener('click', () => moveWeek(1)); datePicker.addEventListener('change', () => { if (datePicker.value) { weekStart = startOfWeek(parseIsoDate(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; switchWorkspace(['schedule', 'preferences', 'requests'].includes(pageState.teacherTab) ? pageState.teacherTab : 'schedule'); loadSchedule(); loadScheduleOverview(); 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); }); writeUrlState({ teacherTab: name }); trackUxEvent('teacher_workspace_opened', { section: name }); } function goToCurrentWeek() { weekStart = startOfWeek(new Date()); loadSchedule(); } async function loadScheduleOverview() { if (!teacherId) { renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: 'Не удалось определить преподавателя' }); return; } renderScheduleOverviewState(scheduleOverview, { kind: 'loading' }); const now = new Date(); const startDate = toIsoDate(now); const endDate = toIsoDate(addDays(now, 14)); try { const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`); const overview = renderScheduleOverview(scheduleOverview, lessons, { role: 'teacher', now, onGoToday: goToCurrentWeek }); trackUxEvent('teacher_schedule_overview_loaded', overview); } catch (error) { renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: error.message, onRetry: loadScheduleOverview }); } } 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 ? `

    Решение: ${escapeHtml(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))}`; writeUrlState({ teacherDate: startDate }); 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); trackUxEvent('teacher_schedule_loaded', { lessons: lessons.length, startDate }); } 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)}
    ${escapeHtml(message)}
    `).join(''); return; } grid.innerHTML = [ '
    ', ...daysOfWeek().map(date => renderHead(date)), `
    Неделя
    `, ...daysOfWeek().map(() => `
    ${escapeHtml(message)}
    `) ].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 parseIsoDate(value) { const [year, month, day] = String(value || '').split('-').map(Number); return year && month && day ? new Date(year, month - 1, day) : new Date(); } 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("'", '''); } })();