854 lines
40 KiB
JavaScript
854 lines
40 KiB
JavaScript
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 => `
|
||
<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))}`;
|
||
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 = [
|
||
'<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 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("'", ''');
|
||
}
|
||
})();
|