3 Задача

This commit is contained in:
dipatrik10
2026-08-11 01:55:02 +03:00
parent 5276729a69
commit 519864b962
42 changed files with 4083 additions and 21 deletions

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -121,7 +121,7 @@
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
<span>Отсутствия и замены</span>
<span>Запросы преподавателей</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->

View File

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

View File

@@ -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) {

View File

@@ -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: 'Замена преподавателя',

View File

@@ -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">