Files
magistr/frontend/admin/js/views/teacher-absences.js
2026-08-20 00:55:27 +03:00

572 lines
31 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 { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { alertAction } from '../dialog.js';
import { AsyncCombobox } from '../async-combobox.js';
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
export function teacherAbsenceTeacherOptionsUrl({ query = '', selectedId = '', role = '', departmentId = '' } = {}) {
const params = new URLSearchParams({ query, limit: '30' });
if (role === 'DEPARTMENT' && departmentId) params.set('departmentId', departmentId);
if (selectedId) params.set('selectedId', selectedId);
return `/api/users/teachers/options?${params}`;
}
export async function initTeacherAbsences() {
const session = getSession();
const role = session?.role;
if (!MANAGER_ROLES.has(role)) return;
const form = document.getElementById('teacher-absence-form');
const teacherSelect = document.getElementById('teacher-absence-teacher');
const startInput = document.getElementById('teacher-absence-start');
const endInput = document.getElementById('teacher-absence-end');
const reasonInput = document.getElementById('teacher-absence-reason');
const statusFilter = document.getElementById('teacher-absence-status-filter');
const registry = document.getElementById('teacher-absence-registry');
const overlay = document.getElementById('absence-wizard-overlay');
const wizardLessons = document.getElementById('absence-wizard-lessons');
const preferenceSemesterFilter = document.getElementById('teacher-preference-semester-filter');
const preferenceStatusFilter = document.getElementById('teacher-preference-status-filter');
const preferenceReviewList = document.getElementById('teacher-preference-review-list');
const changeStatusFilter = document.getElementById('teacher-change-status-filter');
const changeReviewList = document.getElementById('teacher-change-review-list');
let absences = [];
let currentWizard = null;
let preferenceItems = [];
let changeItems = [];
new AsyncCombobox(teacherSelect, {
placeholder: 'Найдите преподавателя по ФИО',
optionLabel: teacher => teacher.fullName || teacher.username || `ID ${teacher.id}`,
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
load: async (query, selectedId) => {
return api.get(teacherAbsenceTeacherOptionsUrl({
query,
selectedId,
role,
departmentId: session?.departmentId
}));
}
});
setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences);
document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences);
document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard);
overlay?.addEventListener('click', event => {
if (event.target === overlay) closeWizard();
});
registry?.addEventListener('click', handleRegistryAction);
preferenceSemesterFilter?.addEventListener('change', loadPreferenceReviews);
preferenceStatusFilter?.addEventListener('change', loadPreferenceReviews);
document.getElementById('teacher-preference-refresh')?.addEventListener('click', loadPreferenceReviews);
preferenceReviewList?.addEventListener('click', handlePreferenceReview);
changeStatusFilter?.addEventListener('change', loadChangeReviews);
document.getElementById('teacher-change-refresh')?.addEventListener('click', loadChangeReviews);
changeReviewList?.addEventListener('click', handleChangeReview);
await Promise.all([loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
absences = await api.get(`/api/teacher-absences${params}`);
renderRegistry();
updateStats(absences);
} catch (error) {
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
}
}
async function createAbsence(event) {
event.preventDefault();
hideAlert('teacher-absence-form-alert');
if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) {
showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error');
return;
}
if (endInput.value < startInput.value) {
showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
try {
await api.post('/api/teacher-absences', {
teacherId: Number(teacherSelect.value),
startDate: startInput.value,
endDate: endInput.value,
reason: reasonInput.value.trim()
});
showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success');
reasonInput.value = '';
await loadAbsences();
} catch (error) {
showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error');
}
}
function renderRegistry() {
if (!absences.length) {
registry.innerHTML = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
return;
}
registry.innerHTML = absences.map(item => `
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
<div class="absence-date-rail">
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
<span>${escapeHtml(monthShort(item.startDate))}</span>
<i></i>
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
<span>${escapeHtml(monthShort(item.endDate))}</span>
</div>
<div class="absence-registry-main">
<div class="absence-registry-title">
<div>
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
</div>
<div class="absence-meta-row">
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
</div>
<div class="absence-registry-buttons">
${item.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
` : ''}
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
` : ''}
${['PENDING', 'APPROVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
` : ''}
</div>
</article>
`).join('');
}
async function handleRegistryAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const id = Number(button.dataset.id);
button.disabled = true;
try {
if (button.dataset.action === 'approve' || button.dataset.action === 'reject') {
await api.post(`/api/teacher-absences/${id}/review`, {
approved: button.dataset.action === 'approve',
comment: button.dataset.action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (button.dataset.action === 'cancel') {
await api.delete(`/api/teacher-absences/${id}`);
await loadAbsences();
} else if (button.dataset.action === 'wizard') {
await openWizard(id);
}
} catch (error) {
await alertAction({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
button.disabled = false;
}
}
async function openWizard(id) {
overlay.hidden = false;
document.body.classList.add('absence-wizard-open');
wizardLessons.innerHTML = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`);
renderWizard();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error');
}
}
function closeWizard() {
overlay.hidden = true;
document.body.classList.remove('absence-wizard-open');
currentWizard = null;
}
function renderWizard() {
const absence = currentWizard.absence;
document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены';
document.getElementById('absence-wizard-summary').textContent =
`${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`;
const applyButton = document.getElementById('absence-wizard-apply');
applyButton.hidden = absence.status !== 'APPROVED';
wizardLessons.innerHTML = currentWizard.lessons.length
? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('')
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
renderJournal(currentWizard.journal || []);
}
function renderLessonDecision(lesson, index, absenceStatus) {
const options = decisionOptions(lesson);
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
return `
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
<div class="absence-lesson-time">
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
</div>
<div class="absence-lesson-copy">
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
</div>
<div class="absence-lesson-decision">
<label for="absence-decision-${index}">Решение</label>
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
<option value="">Оставить без изменений</option>
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
</select>
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
</div>
</article>
`;
}
async function applyWizard() {
if (!currentWizard) return;
const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => {
const value = card.querySelector('.absence-decision-select')?.value;
if (!value) return null;
return {
baseRuleSlotId: Number(card.dataset.slotId),
lessonDate: card.dataset.date,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!decisions.length && currentWizard.lessons.length) {
showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error');
return;
}
const button = document.getElementById('absence-wizard-apply');
button.disabled = true;
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions });
renderWizard();
showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success');
await loadAbsences();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error');
} finally {
button.disabled = false;
}
}
async function loadPreferenceMeta() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка семестров...</div>';
try {
const meta = await api.get('/api/teacher-preferences/meta');
preferenceSemesterFilter.innerHTML = '<option value="">Все семестры</option>' + (meta.semesters || []).map(item => `
<option value="${item.id}">${escapeHtml(semesterLabel(item))}</option>
`).join('');
const today = isoDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
if (current) preferenceSemesterFilter.value = String(current.id);
await loadPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить семестры')}</div>`;
}
}
async function loadPreferenceReviews() {
preferenceReviewList.innerHTML = '<div class="loading-row">Загрузка пожеланий...</div>';
const params = new URLSearchParams();
if (preferenceSemesterFilter.value) params.set('semesterId', preferenceSemesterFilter.value);
if (preferenceStatusFilter.value) params.set('status', preferenceStatusFilter.value);
try {
preferenceItems = await api.get(`/api/teacher-preferences${params.size ? `?${params}` : ''}`);
document.getElementById('teacher-preference-pending-count').textContent = String(
preferenceItems.filter(item => item.status === 'PENDING').length
);
renderPreferenceReviews();
} catch (error) {
preferenceReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить пожелания')}</div>`;
}
}
function renderPreferenceReviews() {
preferenceReviewList.innerHTML = preferenceItems.length ? preferenceItems.map(item => `
<article class="teacher-dialogue-item">
<span class="teacher-dialogue-mark preference-${escapeHtml(item.preferenceType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${escapeHtml(item.academicYearTitle)} · ${escapeHtml(item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр')}</small>
<h3>${escapeHtml(item.teacherName)}</h3>
<p>${escapeHtml(preferenceDescription(item))}</p>
${item.comment ? `<blockquote>${escapeHtml(item.comment)}</blockquote>` : ''}
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-preference-review="approve" data-id="${item.id}">Согласовать</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-preference-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : ''}
</div>
</article>
`).join('') : '<div class="absence-empty"><strong>Пожеланий не найдено</strong><span>Измените фильтры или дождитесь новых отметок преподавателей.</span></div>';
}
async function handlePreferenceReview(event) {
const button = event.target.closest('[data-preference-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.preferenceReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
await alertAction({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
button.disabled = false;
}
}
async function loadChangeReviews() {
changeReviewList.innerHTML = '<div class="loading-row">Загрузка заявок...</div>';
const params = changeStatusFilter.value ? `?status=${encodeURIComponent(changeStatusFilter.value)}` : '';
try {
changeItems = await api.get(`/api/teacher-change-requests${params}`);
document.getElementById('teacher-change-pending-count').textContent = String(
changeItems.filter(item => item.status === 'PENDING').length
);
renderChangeReviews();
} catch (error) {
changeReviewList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить заявки')}</div>`;
}
}
function renderChangeReviews() {
const canApply = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
changeReviewList.innerHTML = changeItems.length ? changeItems.map(item => {
const target = item.requestType === 'MOVE'
? `${formatDate(item.targetLessonDate || item.lessonDate)} · ${trimTime(item.requestedStartTime)}–${trimTime(item.requestedEndTime)}`
: item.requestType === 'CHANGE_CLASSROOM' ? item.requestedClassroomName : 'Отмена занятия';
return `<article class="teacher-dialogue-item change-item">
<span class="teacher-dialogue-mark change-${escapeHtml(item.requestType.toLowerCase())}"></span>
<div class="teacher-dialogue-copy">
<small>${formatDate(item.lessonDate)} · ${trimTime(item.sourceStartTime)}–${trimTime(item.sourceEndTime)} · ${escapeHtml(item.sourceClassroomName)}</small>
<h3>${escapeHtml(item.teacherName)} · ${escapeHtml(item.subjectName)}</h3>
<p>${escapeHtml(changeTypeLabel(item.requestType))} → <strong>${escapeHtml(target)}</strong></p>
<blockquote>${escapeHtml(item.reason)}</blockquote>
${item.reviewComment ? `<span class="teacher-dialogue-review">Решение: ${escapeHtml(item.reviewComment)}</span>` : ''}
<details><summary>История · ${(item.history || []).length}</summary>
<ol>${(item.history || []).map(entry => `<li><b>${genericStatusLabel(entry.status)}</b> · ${escapeHtml(entry.comment || 'Без комментария')}</li>`).join('')}</ol>
</details>
</div>
<div class="teacher-dialogue-action">
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(genericStatusLabel(item.status))}</span>
${item.status === 'PENDING' && canApply ? `
<input type="text" class="teacher-dialogue-comment" maxlength="2000" placeholder="Комментарий к решению">
<div>
<button class="btn btn-sm btn-primary" type="button" data-change-review="approve" data-id="${item.id}">Применить</button>
<button class="btn btn-sm btn-danger-subtle" type="button" data-change-review="reject" data-id="${item.id}">Отклонить</button>
</div>
` : item.status === 'PENDING' ? '<small>Применение доступно учебному отделу</small>' : ''}
</div>
</article>`;
}).join('') : '<div class="absence-empty"><strong>Заявок не найдено</strong><span>Очередь изменений сейчас пуста.</span></div>';
}
async function handleChangeReview(event) {
const button = event.target.closest('[data-change-review][data-id]');
if (!button) return;
const card = button.closest('.teacher-dialogue-item');
const approved = button.dataset.changeReview === 'approve';
const comment = card.querySelector('.teacher-dialogue-comment')?.value.trim() || '';
if (!approved && !comment) {
await alertAction({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
button.disabled = true;
try {
await api.post(`/api/teacher-change-requests/${button.dataset.id}/review`, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
await alertAction({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
button.disabled = false;
}
}
}
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
function renderJournal(journal) {
document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length);
document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
<article>
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
</article>
`).join('') : '<div class="loading-row">Решений пока нет</div>';
}
function updateStats(items) {
document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length;
document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length;
document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length;
}
function setDefaultPeriod(startInput, endInput) {
const today = new Date();
startInput.value = isoDate(today);
endInput.value = isoDate(today);
}
function isoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function genericStatusLabel(status) {
return {
PENDING: 'На рассмотрении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function semesterLabel(semester) {
const type = semester.semesterType === 'AUTUMN' ? 'осенний' : semester.semesterType === 'SPRING' ? 'весенний' : semester.semesterType;
return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}–${formatDate(semester.endDate)}`;
}
function preferenceDescription(item) {
const type = {
HARD_UNAVAILABLE: 'Строгая недоступность',
SOFT_PREFERRED: 'Предпочтительное время',
SOFT_UNWANTED: 'Нежелательное время',
CONSECUTIVE: 'Пожелание «пары подряд»',
NO_GAPS: 'Пожелание «без окон»'
}[item.preferenceType] || item.preferenceType;
if (item.preferenceDate) return `${type}: ${formatDate(item.preferenceDate)}`;
if (item.dayName) return `${type}: ${item.dayName}, ${trimTime(item.startTime)}–${trimTime(item.endTime)}`;
return type;
}
function changeTypeLabel(type) {
return {
MOVE: 'Перенос даты или времени',
CHANGE_CLASSROOM: 'Смена аудитории',
CANCEL: 'Отмена занятия'
}[type] || type || 'Изменение';
}
function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}