Files
magistr/frontend/teacher/app.js
2026-08-11 01:55:02 +03:00

811 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => `
<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>
`).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 = '<div class="preference-empty">Не удалось загрузить параметры семестра</div>';
}
}
async function loadPreferences() {
if (!preferenceSemester.value) return;
preferenceGrid.innerHTML = '<div class="preference-empty">Загрузка календаря...</div>';
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 = `<div class="preference-empty error">${escapeHtml(error.message)}</div>`;
}
}
function renderPreferenceGrid() {
const slots = preferenceMeta.timeSlots || [];
if (!slots.length) {
preferenceGrid.innerHTML = '<div class="preference-empty">Базовая сетка времени не настроена</div>';
return;
}
const active = preferences.filter(item => ['PENDING', 'APPROVED'].includes(item.status) && item.dayOfWeek);
preferenceGrid.innerHTML = [
'<div class="preference-grid-corner">Пара</div>',
...['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => `<div class="preference-grid-day">${day}</div>`),
...slots.flatMap(slot => [
`<div class="preference-grid-time"><strong>${slot.orderNumber}</strong><span>${trimTime(slot.startTime)}–${trimTime(slot.endTime)}</span></div>`,
...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 `<button type="button" class="preference-cell ${typeClass}${pendingClass}"
data-day="${index + 1}" data-slot="${slot.id}" data-preference-id="${item?.id || ''}"
data-preference-status="${item?.status || ''}" title="${escapeHtml(title)}">
<span>${item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
</button>`;
})
])
].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 => `
<article>
<span class="preference-date-mark ${item.status === 'APPROVED' ? 'approved' : ''}"></span>
<div><strong>${escapeHtml(formatIsoDate(item.preferenceDate))}</strong><small>${escapeHtml(item.comment || 'Весь день')}</small></div>
<span class="preference-status">${escapeHtml(preferenceStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `<button type="button" class="btn btn-sm btn-danger-subtle" data-cancel-preference="${item.id}">Отменить</button>` : ''}
</article>
`).join('') : '<div class="preference-empty">Даты полной недоступности не добавлены</div>';
}
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 = '<div class="request-empty">Загрузка заявок...</div>';
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('')
: '<div class="request-empty">Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».</div>';
} catch (error) {
requestList.innerHTML = `<div class="request-empty error">${escapeHtml(error.message)}</div>`;
}
}
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 => `
<li><time>${formatInstant(entry.createdAt)}</time><strong>${preferenceStatusLabel(entry.status)}</strong><span>${escapeHtml(entry.comment || 'Без комментария')}</span></li>
`).join('');
return `<article class="request-ledger-item">
<div class="request-ledger-stripe ${String(item.status || '').toLowerCase()}"></div>
<div class="request-ledger-main">
<span>${escapeHtml(formatIsoDate(item.lessonDate))} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)}</span>
<h3>${escapeHtml(item.subjectName)}</h3>
<p>${escapeHtml((item.groupNames || []).join(', '))} · ${escapeHtml(item.sourceClassroomName)}</p>
<div class="request-change-line"><b>${escapeHtml(changeTypeLabel(item.requestType))}</b><i>→</i><strong>${escapeHtml(target)}</strong></div>
<p class="request-reason">${escapeHtml(item.reason)}</p>
${item.reviewComment ? `<p class="request-review-comment">Решение: ${escapeHtml(item.reviewComment)}</p>` : ''}
<details><summary>История решения · ${(item.history || []).length}</summary><ol>${history}</ol></details>
</div>
<div class="request-ledger-side">
<span class="request-status ${String(item.status || '').toLowerCase()}">${escapeHtml(preferenceStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `<button type="button" class="btn btn-sm btn-danger-subtle" data-cancel-request="${item.id}">Отозвать</button>` : ''}
</div>
</article>`;
}
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 => `
<option value="${date}" ${date === activeCandidates.checkedDate ? 'selected' : ''}>${formatIsoDate(date)}</option>
`).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 => `<option value="${item.id}">${escapeHtml(item.label)}</option>`).join('')
: `<option value="">${emptyLabel}</option>`;
}
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', `<div class="request-empty error">${escapeHtml(error.message)}</div>`);
}
}
function setRequestMessage(message, isError) {
requestMessage.textContent = message || '';
requestMessage.classList.toggle('error', Boolean(isError));
}
async function loadAbsences() {
absenceList.innerHTML = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
try {
const items = await fetchJson('/api/teacher-absences');
absenceList.innerHTML = items.length ? items.map(item => `
<article class="teacher-absence-item">
<span class="teacher-absence-marker ${absenceStatusClass(item.status)}"></span>
<div>
<strong>${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))}</strong>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="teacher-absence-badge ${absenceStatusClass(item.status)}">${escapeHtml(absenceStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `<button class="btn btn-sm btn-danger-subtle" type="button" data-cancel-absence="${item.id}">Отменить</button>` : ''}
</article>
`).join('') : '<div class="teacher-absence-empty">Заявок об отсутствии пока нет</div>';
} catch (error) {
absenceList.innerHTML = `<div class="teacher-absence-empty error">${escapeHtml(error.message)}</div>`;
}
}
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 = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
...slots.flatMap(slot => [
`<div class="time-cell">${escapeHtml(slot.label)}</div>`,
...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 `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list">
${dayLessons.length ? dayLessons.map(renderLesson).join('') : '<div class="empty">Занятий нет</div>'}
</div>
</article>
`;
}).join('');
}
function renderEmptyWeek(message) {
if (window.matchMedia('(max-width: 980px)').matches) {
grid.innerHTML = daysOfWeek().map(date => `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list"><div class="empty">${escapeHtml(message)}</div></div>
</article>
`).join('');
return;
}
grid.innerHTML = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
`<div class="time-cell">Неделя</div>`,
...daysOfWeek().map(() => `<div class="day-cell"><div class="empty">${escapeHtml(message)}</div></div>`)
].join('');
}
function renderHead(date) {
return `
<div class="day-head">
<strong>${escapeHtml(dayName(date))}</strong>
<span>${escapeHtml(formatDate(date))}</span>
</div>
`;
}
function renderCell(lesson) {
return `<div class="day-cell">${lesson ? renderLesson(lesson) : '<div class="empty">Нет занятия</div>'}</div>`;
}
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 `
<div class="lesson">
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
${lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId ? `
<button class="lesson-request-button" type="button" data-request-lesson="${escapeHtml(lessonKey(lesson))}">
Запросить изменение
</button>
` : '<span class="lesson-change-note">Занятие уже изменено учебным отделом</span>'}
</div>
`;
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();