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">
|
||||
|
||||
Reference in New Issue
Block a user