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);

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание преподавателя</title>
<title>Кабинет преподавателя</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="title">
<h1>Расписание преподавателя</h1>
<h1>Кабинет преподавателя</h1>
<span id="week-label">Загрузка периода...</span>
</div>
<div class="actions">
@@ -23,6 +23,15 @@
</div>
</header>
<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="Предыдущая неделя">
@@ -71,8 +80,133 @@
<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>
</body>
</html>

View File

@@ -185,6 +185,8 @@
--green: #047857;
--orange: #b45309;
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
--card: var(--panel);
--border: var(--panel-border);
}
[data-theme="dark"] {
@@ -419,6 +421,456 @@ body {
background: var(--panel);
}
.btn-sm {
min-height: 32px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.workspace-nav {
display: inline-flex;
gap: 4px;
margin: 0 0 18px;
padding: 5px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: var(--panel);
box-shadow: var(--shadow);
}
.workspace-tab {
min-height: 38px;
padding: 0 15px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.workspace-tab span {
display: inline-grid;
min-width: 20px;
min-height: 20px;
margin-left: 5px;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
font-size: 11px;
}
.workspace-tab.active {
background: var(--text);
color: var(--panel);
}
.workspace-view[hidden],
.request-dialog-overlay[hidden] {
display: none;
}
.workspace-view.active {
animation: workspace-in 0.28s ease both;
}
@keyframes workspace-in {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: translateY(0); }
}
.preference-hero,
.request-ledger-head {
position: relative;
display: flex;
align-items: end;
justify-content: space-between;
gap: 30px;
margin-bottom: 14px;
padding: 26px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--panel-border));
border-radius: 18px;
background:
linear-gradient(120deg, color-mix(in srgb, var(--accent) 13%, transparent), transparent 58%),
var(--panel);
box-shadow: var(--shadow);
}
.preference-hero::after {
position: absolute;
top: -90px;
right: 18%;
width: 180px;
height: 180px;
border: 1px solid color-mix(in srgb, var(--accent) 23%, transparent);
border-radius: 50%;
content: '';
}
.preference-kicker,
.request-ledger-head > div > span {
color: var(--accent-strong);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.preference-hero h2,
.request-ledger-head h2 {
max-width: 720px;
margin: 7px 0;
font-size: clamp(24px, 4vw, 42px);
line-height: 1.02;
letter-spacing: -0.035em;
}
.preference-hero p,
.request-ledger-head p {
max-width: 760px;
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.preference-semester-field,
.preference-board-head label,
.request-dialog label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.preference-semester-field {
position: relative;
z-index: 1;
min-width: 280px;
}
.preference-semester-field select,
.preference-board-head select,
.preference-date-form input,
.request-dialog select,
.request-dialog textarea {
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--panel-border);
border-radius: 9px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.preference-board,
.preference-card {
border: 1px solid var(--panel-border);
border-radius: 16px;
background: var(--panel);
box-shadow: var(--shadow);
}
.preference-board {
padding: 20px;
}
.preference-board-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.preference-board-head > div > span,
.preference-card-number {
color: var(--accent-strong);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.12em;
}
.preference-board-head h3,
.preference-card h3 {
margin: 5px 0 0;
font-size: 19px;
}
.preference-legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 10px;
}
.preference-legend span {
padding: 4px 9px;
border: 1px solid var(--panel-border);
border-radius: 999px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.preference-legend .is-preferred { border-color: #10b981; color: #047857; }
.preference-legend .is-unwanted { border-color: #f59e0b; color: #b45309; }
.preference-legend .is-hard { border-color: #f43f5e; color: #be123c; }
.preference-legend .is-pending { border-style: dashed; }
.preference-grid-wrap {
overflow-x: auto;
padding-bottom: 4px;
}
.preference-grid {
display: grid;
min-width: 780px;
grid-template-columns: 110px repeat(7, minmax(78px, 1fr));
gap: 6px;
}
.preference-grid-corner,
.preference-grid-day,
.preference-grid-time,
.preference-cell {
min-height: 48px;
border: 1px solid var(--panel-border);
border-radius: 9px;
}
.preference-grid-corner,
.preference-grid-day {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--panel-border) 22%, transparent);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.preference-grid-time {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
}
.preference-grid-time strong {
display: grid;
width: 25px;
height: 25px;
place-items: center;
border-radius: 7px;
background: var(--text);
color: var(--panel);
font-size: 12px;
}
.preference-grid-time span {
color: var(--muted);
font-size: 11px;
}
.preference-cell {
position: relative;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: transform .16s ease, border-color .16s ease, background .16s ease;
}
.preference-cell:hover { transform: translateY(-2px); border-color: var(--accent); }
.preference-cell span { font-size: 19px; font-weight: 900; }
.preference-cell.is-preferred { border-color: #10b981; background: color-mix(in srgb, #10b981 15%, transparent); color: #047857; }
.preference-cell.is-unwanted { border-color: #f59e0b; background: color-mix(in srgb, #f59e0b 15%, transparent); color: #b45309; }
.preference-cell.is-hard { border-color: #f43f5e; background: color-mix(in srgb, #f43f5e 14%, transparent); color: #be123c; }
.preference-cell.is-pending::after {
position: absolute;
inset: 3px;
border: 1px dashed currentColor;
border-radius: 6px;
content: '';
opacity: .7;
}
.preference-message,
.request-dialog-message {
min-height: 20px;
margin-top: 10px;
font-size: 13px;
}
.preference-message.success { color: #059669; }
.preference-message.error,
.request-dialog-message.error { color: #dc2626; }
.preference-lower-grid {
display: grid;
grid-template-columns: 1.35fr .65fr;
gap: 14px;
margin-top: 14px;
}
.preference-card {
padding: 20px;
}
.preference-card p {
margin: 7px 0 16px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.preference-date-form {
display: grid;
grid-template-columns: 170px 1fr auto;
gap: 8px;
}
.preference-date-list {
display: grid;
gap: 7px;
margin-top: 12px;
}
.preference-date-list article {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 9px;
align-items: center;
padding: 9px;
border: 1px solid var(--panel-border);
border-radius: 10px;
}
.preference-date-list small { display: block; margin-top: 2px; color: var(--muted); }
.preference-date-mark { width: 7px; height: 34px; border-radius: 9px; background: #f59e0b; }
.preference-date-mark.approved { background: #f43f5e; }
.preference-status { color: var(--muted); font-size: 11px; font-weight: 800; }
.preference-empty { padding: 14px; color: var(--muted); text-align: center; font-size: 13px; }
.preference-toggle-card {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: start;
margin-top: 9px;
padding: 12px;
border: 1px solid var(--panel-border);
border-radius: 11px;
cursor: pointer;
}
.preference-toggle-card.approved { border-color: #10b981; background: color-mix(in srgb, #10b981 8%, transparent); }
.preference-toggle-card input { margin-top: 3px; accent-color: var(--accent); }
.preference-toggle-card strong,
.preference-toggle-card small { display: block; }
.preference-toggle-card small { margin-top: 3px; color: var(--muted); line-height: 1.4; }
.lesson-request-button {
width: 100%;
margin-top: 9px;
padding: 6px 8px;
border: 1px solid color-mix(in srgb, var(--green) 35%, var(--panel-border));
border-radius: 7px;
background: var(--panel);
color: var(--green);
font: inherit;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.lesson-change-note { display: block; margin-top: 8px; color: var(--muted); font-size: 10px; }
.request-ledger-head h2 { font-size: clamp(24px, 3vw, 34px); }
.request-ledger { display: grid; gap: 10px; }
.request-empty { padding: 28px; border: 1px dashed var(--panel-border); border-radius: 14px; color: var(--muted); text-align: center; }
.request-ledger-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
gap: 17px;
padding: 17px;
border: 1px solid var(--panel-border);
border-radius: 15px;
background: var(--panel);
box-shadow: var(--shadow);
}
.request-ledger-stripe { border-radius: 99px; background: #94a3b8; }
.request-ledger-stripe.pending { background: #f59e0b; }
.request-ledger-stripe.approved { background: #10b981; }
.request-ledger-stripe.rejected { background: #f43f5e; }
.request-ledger-main > span { color: var(--muted); font-size: 12px; }
.request-ledger-main h3 { margin: 4px 0; }
.request-ledger-main p { margin: 3px 0; color: var(--muted); font-size: 13px; }
.request-change-line { display: flex; align-items: center; gap: 9px; margin: 12px 0; font-size: 13px; }
.request-change-line b { color: var(--accent-strong); }
.request-change-line i { color: var(--muted); font-style: normal; }
.request-ledger-main .request-reason { color: var(--text); }
.request-ledger-main .request-review-comment { color: #047857; }
.request-ledger-main details { margin-top: 10px; }
.request-ledger-main summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.request-ledger-main ol { display: grid; gap: 5px; padding-left: 20px; }
.request-ledger-main li { padding-left: 5px; font-size: 12px; }
.request-ledger-main li time,
.request-ledger-main li strong,
.request-ledger-main li span { margin-right: 8px; }
.request-ledger-main li time { color: var(--muted); }
.request-ledger-side { display: flex; flex-direction: column; align-items: end; gap: 9px; }
.request-status { padding: 5px 9px; border: 1px solid currentColor; border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; }
.request-status.pending { color: #b45309; }
.request-status.approved { color: #047857; }
.request-status.rejected { color: #be123c; }
.request-dialog-overlay {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(7, 12, 22, .68);
backdrop-filter: blur(7px);
}
.dialog-open { overflow: hidden; }
.request-dialog {
width: min(760px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
border: 1px solid var(--panel-border);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 35px 90px rgba(0, 0, 0, .35);
}
.request-dialog header {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
border-bottom: 1px solid var(--panel-border);
background: linear-gradient(115deg, color-mix(in srgb, var(--accent) 12%, transparent), transparent);
}
.request-dialog header span { color: var(--accent-strong); font-size: 11px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
.request-dialog header h2 { margin: 5px 0; }
.request-dialog header p { margin: 0; color: var(--muted); font-size: 13px; }
.request-dialog form { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; padding: 20px; }
.request-dialog [hidden] { display: none; }
.request-dialog textarea { min-height: 100px; padding-top: 10px; resize: vertical; }
.request-reason-field,
.request-dialog-message,
.request-dialog footer { grid-column: 1 / -1; }
.request-dialog footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; }
.empty {
color: var(--muted);
font-size: 13px;
@@ -471,5 +923,26 @@ body {
.week-nav .btn {
flex: 1;
}
.workspace-nav,
.preference-hero,
.preference-board-head,
.request-ledger-head {
display: grid;
width: 100%;
}
.workspace-tab { text-align: left; }
.preference-semester-field { min-width: 0; }
.preference-lower-grid,
.preference-date-form,
.request-dialog form { grid-template-columns: 1fr; }
.preference-date-form .btn,
.request-reason-field,
.request-dialog-message,
.request-dialog footer { grid-column: auto; }
.request-ledger-item { grid-template-columns: 6px minmax(0, 1fr); }
.request-ledger-side { grid-column: 2; align-items: start; }
.request-dialog footer { flex-direction: column-reverse; }
}