feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
@@ -1,853 +0,0 @@
|
||||
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)}" aria-label="${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="btn btn-sm btn-secondary 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("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -9,207 +9,8 @@
|
||||
<link rel="stylesheet" href="../ui-foundation.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Кабинет преподавателя</h1>
|
||||
<span id="week-label">Загрузка периода...</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="teacher-root"></div>
|
||||
|
||||
<nav class="workspace-nav" aria-label="Разделы кабинета">
|
||||
<button class="workspace-tab active" type="button" data-workspace-tab="schedule">Расписание</button>
|
||||
<button class="workspace-tab" type="button" data-workspace-tab="preferences">Мои пожелания</button>
|
||||
<button class="workspace-tab" type="button" data-workspace-tab="requests">
|
||||
Заявки на изменение <span id="change-request-count">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="workspace-view active" data-workspace-view="schedule">
|
||||
<section class="toolbar">
|
||||
<div class="week-nav">
|
||||
<button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
|
||||
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
|
||||
</section>
|
||||
|
||||
<section class="schedule-overview" id="schedule-overview" aria-live="polite"></section>
|
||||
<div class="status" id="status-line"></div>
|
||||
<section class="teacher-absence-panel">
|
||||
<div class="teacher-absence-heading">
|
||||
<div>
|
||||
<span>Изменение планов</span>
|
||||
<h2>Сообщить об отсутствии</h2>
|
||||
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
|
||||
</div>
|
||||
<button class="btn btn-md btn-secondary" id="absence-form-toggle" type="button" aria-expanded="false">
|
||||
Новая заявка
|
||||
</button>
|
||||
</div>
|
||||
<form class="teacher-absence-form" id="teacher-absence-form" hidden>
|
||||
<label>
|
||||
<span>Начало</span>
|
||||
<input class="date-input" id="teacher-absence-start" type="date" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Окончание</span>
|
||||
<input class="date-input" id="teacher-absence-end" type="date" required>
|
||||
</label>
|
||||
<label class="teacher-absence-reason">
|
||||
<span>Причина</span>
|
||||
<input id="teacher-absence-reason" type="text" maxlength="2000" placeholder="Например, командировка" required>
|
||||
</label>
|
||||
<button class="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
|
||||
</form>
|
||||
<div class="teacher-absence-status" id="teacher-absence-status"></div>
|
||||
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
|
||||
</section>
|
||||
|
||||
<section class="workspace-view" data-workspace-view="preferences" hidden>
|
||||
<section class="preference-hero">
|
||||
<div>
|
||||
<span class="preference-kicker">Контур доступности</span>
|
||||
<h2>Соберите ритм семестра до составления расписания</h2>
|
||||
<p>Строгая недоступность блокирует назначение. Мягкие отметки и пожелания к компактности помогают учебному отделу выбрать удобный вариант.</p>
|
||||
</div>
|
||||
<label class="preference-semester-field">
|
||||
<span>Семестр</span>
|
||||
<select id="preference-semester"></select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="preference-board">
|
||||
<header class="preference-board-head">
|
||||
<div>
|
||||
<span>Недельный шаблон</span>
|
||||
<h3>Отмечайте интервалы одним касанием</h3>
|
||||
</div>
|
||||
<label>
|
||||
<span>Режим отметки</span>
|
||||
<select id="preference-brush">
|
||||
<option value="SOFT_PREFERRED">Предпочтительно</option>
|
||||
<option value="SOFT_UNWANTED">Нежелательно</option>
|
||||
<option value="HARD_UNAVAILABLE">Строго недоступен</option>
|
||||
</select>
|
||||
</label>
|
||||
</header>
|
||||
<div class="preference-legend" aria-label="Легенда">
|
||||
<span class="is-preferred">Предпочтительно</span>
|
||||
<span class="is-unwanted">Нежелательно</span>
|
||||
<span class="is-hard">Строгий запрет</span>
|
||||
<span class="is-pending">Ожидает согласования</span>
|
||||
</div>
|
||||
<div class="preference-grid-wrap">
|
||||
<div class="preference-grid" id="preference-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="preference-message" id="preference-message" role="status"></div>
|
||||
</section>
|
||||
|
||||
<div class="preference-lower-grid">
|
||||
<section class="preference-card">
|
||||
<span class="preference-card-number">01</span>
|
||||
<h3>Полная недоступность</h3>
|
||||
<p>Добавьте отдельную дату внутри семестра — все пары этого дня будут считаться строгим ограничением.</p>
|
||||
<form id="preference-date-form" class="preference-date-form">
|
||||
<input type="date" id="preference-date" required>
|
||||
<input type="text" id="preference-date-comment" maxlength="2000" placeholder="Комментарий (необязательно)">
|
||||
<button class="btn btn-md btn-primary" type="submit">Добавить дату</button>
|
||||
</form>
|
||||
<div class="preference-date-list" id="preference-date-list"></div>
|
||||
</section>
|
||||
<section class="preference-card">
|
||||
<span class="preference-card-number">02</span>
|
||||
<h3>Компактность недели</h3>
|
||||
<p>Эти мягкие пожелания не отменяют занятия автоматически, но видны при составлении правил.</p>
|
||||
<label class="preference-toggle-card">
|
||||
<input type="checkbox" id="preference-consecutive">
|
||||
<span><strong>Пары подряд</strong><small>Собирать занятия последовательными блоками</small></span>
|
||||
</label>
|
||||
<label class="preference-toggle-card">
|
||||
<input type="checkbox" id="preference-no-gaps">
|
||||
<span><strong>Без окон</strong><small>Не оставлять пустые интервалы между занятиями</small></span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-view" data-workspace-view="requests" hidden>
|
||||
<section class="request-ledger-head">
|
||||
<div>
|
||||
<span>Журнал решений</span>
|
||||
<h2>Заявки на изменение занятий</h2>
|
||||
<p>Откройте занятие в расписании, чтобы запросить перенос, другую аудиторию или отмену. Здесь сохраняются статус, комментарий и вся история решения.</p>
|
||||
</div>
|
||||
<button class="btn btn-md btn-secondary" id="change-request-refresh" type="button">Обновить</button>
|
||||
</section>
|
||||
<div class="request-ledger" id="change-request-list" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="request-dialog-overlay" id="request-dialog-overlay" hidden>
|
||||
<section class="request-dialog" role="dialog" aria-modal="true" aria-labelledby="request-dialog-title">
|
||||
<header>
|
||||
<div>
|
||||
<span>Предварительная проверка</span>
|
||||
<h2 id="request-dialog-title">Заявка на изменение</h2>
|
||||
<p id="request-dialog-lesson">Загрузка занятия...</p>
|
||||
</div>
|
||||
<button class="btn btn-icon-md btn-secondary" id="request-dialog-close" type="button" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<form id="change-request-form">
|
||||
<label>
|
||||
<span>Что изменить</span>
|
||||
<select id="change-request-type">
|
||||
<option value="MOVE">Перенести дату или время</option>
|
||||
<option value="CHANGE_CLASSROOM">Сменить аудиторию</option>
|
||||
<option value="CANCEL">Отменить занятие</option>
|
||||
</select>
|
||||
</label>
|
||||
<label data-request-field="date">
|
||||
<span>Новая дата</span>
|
||||
<select id="change-request-date"></select>
|
||||
</label>
|
||||
<label data-request-field="time">
|
||||
<span>Новая пара</span>
|
||||
<select id="change-request-time"></select>
|
||||
</label>
|
||||
<label data-request-field="classroom" hidden>
|
||||
<span>Новая аудитория</span>
|
||||
<select id="change-request-classroom"></select>
|
||||
</label>
|
||||
<label class="request-reason-field">
|
||||
<span>Обоснование</span>
|
||||
<textarea id="change-request-reason" maxlength="2000" rows="4" placeholder="Почему требуется изменение" required></textarea>
|
||||
</label>
|
||||
<div class="request-dialog-message" id="request-dialog-message" role="alert"></div>
|
||||
<footer>
|
||||
<button class="btn btn-md btn-secondary" id="request-dialog-cancel" type="button">Закрыть</button>
|
||||
<button class="btn btn-md btn-primary" type="submit">Отправить в учебный отдел</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
<script type="module" src="/react/teacher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user