3 Задача
This commit is contained in:
@@ -759,6 +759,37 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.schedule-preference-hint {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
gap: .4rem;
|
||||
align-items: center;
|
||||
min-height: 25px;
|
||||
margin-top: -.15rem;
|
||||
}
|
||||
|
||||
.schedule-preference-caption {
|
||||
margin-right: .25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: .69rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.schedule-preference-pill {
|
||||
padding: .24rem .5rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
font-size: .67rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.schedule-preference-pill.hard { color: #fb7185; background: color-mix(in srgb, #fb7185 9%, transparent); }
|
||||
.schedule-preference-pill.preferred { color: #34d399; background: color-mix(in srgb, #34d399 9%, transparent); }
|
||||
.schedule-preference-pill.unwanted { color: #f59e0b; background: color-mix(in srgb, #f59e0b 9%, transparent); }
|
||||
.schedule-preference-pill.dates { color: #fda4af; }
|
||||
.schedule-preference-pill.compact { color: #60a5fa; }
|
||||
|
||||
.schedule-slot-row.has-subgroup {
|
||||
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
|
||||
}
|
||||
|
||||
@@ -470,6 +470,167 @@ body.absence-wizard-open {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-card {
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.teacher-dialogue-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.teacher-dialogue-head h2 {
|
||||
margin: .25rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-head p {
|
||||
max-width: 560px;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: .8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.teacher-dialogue-counter {
|
||||
display: grid;
|
||||
min-width: 78px;
|
||||
min-height: 70px;
|
||||
place-items: center;
|
||||
padding: .55rem;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 28%, var(--bg-card-border));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, #f59e0b 8%, var(--bg-input));
|
||||
}
|
||||
|
||||
.teacher-dialogue-counter strong {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.teacher-dialogue-counter span {
|
||||
color: var(--text-secondary);
|
||||
font-size: .67rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar {
|
||||
display: flex;
|
||||
gap: .55rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.teacher-dialogue-list {
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
max-height: 680px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.teacher-dialogue-item {
|
||||
display: grid;
|
||||
grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr);
|
||||
gap: .85rem;
|
||||
padding: .85rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 13px;
|
||||
background: color-mix(in srgb, var(--bg-input) 68%, transparent);
|
||||
}
|
||||
|
||||
.teacher-dialogue-mark {
|
||||
border-radius: 99px;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.teacher-dialogue-mark.preference-hard_unavailable { background: #fb7185; }
|
||||
.teacher-dialogue-mark.preference-soft_preferred { background: #34d399; }
|
||||
.teacher-dialogue-mark.preference-soft_unwanted { background: #f59e0b; }
|
||||
.teacher-dialogue-mark.preference-consecutive,
|
||||
.teacher-dialogue-mark.preference-no_gaps { background: #60a5fa; }
|
||||
.teacher-dialogue-mark.change-move { background: #60a5fa; }
|
||||
.teacher-dialogue-mark.change-change_classroom { background: #a78bfa; }
|
||||
.teacher-dialogue-mark.change-cancel { background: #fb7185; }
|
||||
|
||||
.teacher-dialogue-copy small {
|
||||
color: var(--text-secondary);
|
||||
font-size: .68rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-copy h3 {
|
||||
margin: .22rem 0;
|
||||
font-size: .92rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-copy p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: .77rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-copy blockquote {
|
||||
margin: .6rem 0 0;
|
||||
padding: .5rem .65rem;
|
||||
border-left: 2px solid #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-review {
|
||||
display: block;
|
||||
margin-top: .55rem;
|
||||
color: #34d399;
|
||||
font-size: .72rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-copy details {
|
||||
margin-top: .55rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-copy summary { cursor: pointer; }
|
||||
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
|
||||
|
||||
.teacher-dialogue-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: .55rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-action > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.teacher-dialogue-comment {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 .65rem;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.absence-command-card,
|
||||
.absence-form-grid {
|
||||
@@ -494,6 +655,10 @@ body.absence-wizard-open {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.teacher-dialogue-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -553,4 +718,19 @@ body.absence-wizard-open {
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.teacher-dialogue-toolbar,
|
||||
.teacher-dialogue-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.teacher-dialogue-item {
|
||||
grid-template-columns: 7px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.teacher-dialogue-action {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<path d="M18 8l3 3" />
|
||||
<path d="M21 8l-3 3" />
|
||||
</svg>
|
||||
<span>Отсутствия и замены</span>
|
||||
<span>Запросы преподавателей</span>
|
||||
</a>
|
||||
|
||||
<!-- Раздел: Субъекты и ресурсы -->
|
||||
|
||||
@@ -118,7 +118,7 @@ const ROUTES = {
|
||||
}
|
||||
},
|
||||
'teacher-absences': {
|
||||
title: 'Отсутствия и мастер замены',
|
||||
title: 'Запросы преподавателей',
|
||||
file: '/admin/views/teacher-absences.html',
|
||||
init: async () => {
|
||||
const module = await import('./views/teacher-absences.js');
|
||||
|
||||
@@ -124,6 +124,7 @@ export async function initSchedule() {
|
||||
let timeSlots = [];
|
||||
let academicYears = [];
|
||||
let semesters = [];
|
||||
let approvedTeacherPreferences = [];
|
||||
let selectedVisualYearId = '';
|
||||
let selectedVisualSemesterId = '';
|
||||
let selectedVisualGroupIds = new Set();
|
||||
@@ -137,7 +138,7 @@ export async function initSchedule() {
|
||||
bindEvents();
|
||||
|
||||
try {
|
||||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
|
||||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
|
||||
renderRuleGroups([]);
|
||||
renderRuleSlots([{}]);
|
||||
await loadRules();
|
||||
@@ -185,6 +186,7 @@ export async function initSchedule() {
|
||||
renderVisualSchedule();
|
||||
});
|
||||
ruleSemesterSelect?.addEventListener('change', () => {
|
||||
updatePreferenceHints();
|
||||
if (visualPeriodTouched) return;
|
||||
selectDefaultVisualPeriod();
|
||||
renderVisualSchedule();
|
||||
@@ -252,6 +254,7 @@ export async function initSchedule() {
|
||||
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
||||
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
||||
}
|
||||
updatePreferenceHints();
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
@@ -276,6 +279,11 @@ export async function initSchedule() {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
async function loadApprovedTeacherPreferences() {
|
||||
approvedTeacherPreferences = await api.get('/api/teacher-preferences?status=APPROVED');
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
@@ -1383,8 +1391,55 @@ export async function initSchedule() {
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
<button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
|
||||
<div class="schedule-preference-hint" data-preference-hint></div>
|
||||
</div>
|
||||
`).join('');
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
function updatePreferenceHints() {
|
||||
const semesterId = Number(ruleSemesterSelect?.value);
|
||||
Array.from(ruleSlotsContainer?.querySelectorAll('.schedule-slot-row') || []).forEach(row => {
|
||||
const hint = row.querySelector('[data-preference-hint]');
|
||||
if (!hint) return;
|
||||
const teacherId = Number(row.querySelector('.slot-teacher')?.value);
|
||||
const dayOfWeek = Number(row.querySelector('.slot-day')?.value);
|
||||
const timeSlotId = Number(row.querySelector('.slot-time')?.value);
|
||||
if (!semesterId || !teacherId) {
|
||||
hint.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const teacherItems = approvedTeacherPreferences.filter(item =>
|
||||
Number(item.semesterId) === semesterId && Number(item.teacherId) === teacherId
|
||||
);
|
||||
const exact = teacherItems.filter(item =>
|
||||
Number(item.dayOfWeek) === dayOfWeek && Number(item.timeSlotId) === timeSlotId
|
||||
);
|
||||
const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length;
|
||||
const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType));
|
||||
const pills = [
|
||||
...exact.map(item => ({
|
||||
text: preferenceHintLabel(item.preferenceType),
|
||||
kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard'
|
||||
: item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted'
|
||||
})),
|
||||
...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []),
|
||||
...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' }))
|
||||
];
|
||||
hint.innerHTML = pills.length
|
||||
? `<span class="schedule-preference-caption">Пожелания преподавателя</span>${pills.map(pill => `<span class="schedule-preference-pill ${pill.kind}">${escapeHtml(pill.text)}</span>`).join('')}`
|
||||
: '<span class="schedule-preference-caption muted">Согласованных пожеланий для слота нет</span>';
|
||||
});
|
||||
}
|
||||
|
||||
function preferenceHintLabel(type) {
|
||||
return {
|
||||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||||
SOFT_PREFERRED: 'Предпочтительно',
|
||||
SOFT_UNWANTED: 'Нежелательно',
|
||||
CONSECUTIVE: 'Пары подряд',
|
||||
NO_GAPS: 'Без окон'
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
function readRuleSlots(validate) {
|
||||
|
||||
@@ -17,8 +17,15 @@ export async function initTeacherAbsences() {
|
||||
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 = [];
|
||||
|
||||
setDefaultPeriod(startInput, endInput);
|
||||
form?.addEventListener('submit', createAbsence);
|
||||
@@ -31,8 +38,15 @@ export async function initTeacherAbsences() {
|
||||
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([loadTeachers(), loadAbsences()]);
|
||||
await Promise.all([loadTeachers(), loadAbsences(), loadPreferenceMeta(), loadChangeReviews()]);
|
||||
|
||||
async function loadTeachers() {
|
||||
try {
|
||||
@@ -249,6 +263,152 @@ export async function initTeacherAbsences() {
|
||||
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) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api.post(`/api/teacher-preferences/${button.dataset.id}/review`, {
|
||||
approved,
|
||||
comment: comment || 'Согласовано ответственным сотрудником'
|
||||
});
|
||||
await loadPreferenceReviews();
|
||||
} catch (error) {
|
||||
window.alert(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) {
|
||||
window.alert('При отклонении укажите комментарий');
|
||||
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) {
|
||||
window.alert(error.message || 'Не удалось применить решение');
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decisionOptions(lesson) {
|
||||
@@ -343,6 +503,41 @@ 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: 'Замена преподавателя',
|
||||
|
||||
@@ -69,6 +69,50 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="teacher-dialogue-grid">
|
||||
<article class="card teacher-dialogue-card">
|
||||
<header class="teacher-dialogue-head">
|
||||
<div>
|
||||
<span class="absence-section-number">03</span>
|
||||
<h2>Пожелания на семестр</h2>
|
||||
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
|
||||
</div>
|
||||
<div class="teacher-dialogue-counter"><strong id="teacher-preference-pending-count">0</strong><span>ожидают</span></div>
|
||||
</header>
|
||||
<div class="teacher-dialogue-toolbar">
|
||||
<select id="teacher-preference-semester-filter" aria-label="Семестр пожеланий"></select>
|
||||
<select id="teacher-preference-status-filter" aria-label="Статус пожеланий">
|
||||
<option value="PENDING">На рассмотрении</option>
|
||||
<option value="APPROVED">Согласованные</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button class="btn btn-md btn-secondary" id="teacher-preference-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite"></div>
|
||||
</article>
|
||||
|
||||
<article class="card teacher-dialogue-card change-review-card">
|
||||
<header class="teacher-dialogue-head">
|
||||
<div>
|
||||
<span class="absence-section-number">04</span>
|
||||
<h2>Заявки на изменение</h2>
|
||||
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
|
||||
</div>
|
||||
<div class="teacher-dialogue-counter"><strong id="teacher-change-pending-count">0</strong><span>в очереди</span></div>
|
||||
</header>
|
||||
<div class="teacher-dialogue-toolbar">
|
||||
<select id="teacher-change-status-filter" aria-label="Статус заявок">
|
||||
<option value="PENDING">Ожидают решения</option>
|
||||
<option value="APPROVED">Одобренные</option>
|
||||
<option value="REJECTED">Отклонённые</option>
|
||||
<option value="">Все статусы</option>
|
||||
</select>
|
||||
<button class="btn btn-md btn-secondary" id="teacher-change-refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
|
||||
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
|
||||
<header class="absence-wizard-header">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
44
frontend/tests/teacher-preferences.test.mjs
Normal file
44
frontend/tests/teacher-preferences.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
test('кабинет преподавателя содержит календарь пожеланий и журнал заявок', async () => {
|
||||
const [index, app] = await Promise.all([
|
||||
readFile(new URL('../teacher/index.html', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../teacher/app.js', import.meta.url), 'utf8')
|
||||
]);
|
||||
|
||||
assert.match(index, /data-workspace-tab="preferences"/);
|
||||
assert.match(index, /data-workspace-tab="requests"/);
|
||||
assert.match(index, /id="preference-grid"/);
|
||||
assert.match(index, /id="change-request-form"/);
|
||||
assert.match(app, /\/api\/teacher-preferences\/meta/);
|
||||
assert.match(app, /\/api\/teacher-change-requests\/candidates/);
|
||||
assert.match(app, /MOVE:[\s\S]*CHANGE_CLASSROOM:[\s\S]*CANCEL:/);
|
||||
});
|
||||
|
||||
test('ответственные роли видят согласование, а применять изменение может учебный отдел', async () => {
|
||||
const [view, script] = await Promise.all([
|
||||
readFile(new URL('../admin/views/teacher-absences.html', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../admin/js/views/teacher-absences.js', import.meta.url), 'utf8')
|
||||
]);
|
||||
|
||||
assert.match(view, /id="teacher-preference-review-list"/);
|
||||
assert.match(view, /id="teacher-change-review-list"/);
|
||||
assert.match(script, /\/api\/teacher-preferences/);
|
||||
assert.match(script, /\/api\/teacher-change-requests/);
|
||||
assert.match(script, /role === 'ADMIN' \|\| role === 'EDUCATION_OFFICE'/);
|
||||
assert.match(script, /Применение доступно учебному отделу/);
|
||||
});
|
||||
|
||||
test('конструктор правил показывает только согласованные пожелания преподавателя', async () => {
|
||||
const [script, styles] = await Promise.all([
|
||||
readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../admin/css/components.css', import.meta.url), 'utf8')
|
||||
]);
|
||||
|
||||
assert.match(script, /\/api\/teacher-preferences\?status=APPROVED/);
|
||||
assert.match(script, /data-preference-hint/);
|
||||
assert.match(script, /Строгая недоступность/);
|
||||
assert.match(styles, /\.schedule-preference-hint/);
|
||||
});
|
||||
Reference in New Issue
Block a user