3 Задача

This commit is contained in:
dipatrik10
2026-08-11 01:55:02 +03:00
parent 5276729a69
commit 519864b962
42 changed files with 4083 additions and 21 deletions

View File

@@ -22,6 +22,29 @@ import {
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) => {
@@ -58,6 +81,29 @@ import {
});
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;
@@ -65,6 +111,351 @@ import {
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>';
@@ -152,6 +543,7 @@ import {
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) {
@@ -249,10 +641,19 @@ import {
<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 => {
@@ -335,6 +736,59 @@ import {
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);