Files
magistr/frontend/react/admin/tabs/TeacherAbsencesTab.jsx
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

941 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { getSession } from '../../../admin/js/api.js';
import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx';
import { FormAlert } from '../../shared/ui/FormAlert.jsx';
import {
absenceSemesterLabel,
absenceStats,
changeReviewTarget,
changeTypeLabel,
createTeacherAbsence,
decisionCountLabel,
decisionOptions,
decisionStatusLabel,
deleteTeacherAbsence,
fetchAbsenceTeacherOptions,
fetchTeacherAbsenceWizard,
fetchTeacherAbsences,
fetchTeacherChangeRequests,
fetchTeacherPreferenceMeta,
fetchTeacherPreferences,
formatAbsenceDate,
formatLocalDate,
dayOfMonth,
genericStatusLabel,
monthShort,
parseDecisionOption,
preferenceDescription,
resolutionLabel,
resolveTeacherAbsence,
reviewTeacherAbsence,
reviewTeacherChangeRequest,
reviewTeacherPreference,
statusClass,
statusLabel,
trimTime
} from '../teacher-absences-service.js';
function teacherOptionLabel(teacher) {
return teacher.fullName || teacher.username || `ID ${teacher.id}`;
}
export function TeacherAbsencesTab() {
const session = getSession();
const role = session?.role;
const [teacherSelection, setTeacherSelection] = useState({ id: '', label: '' });
const [startDate, setStartDate] = useState(() => formatLocalDate(new Date()));
const [endDate, setEndDate] = useState(() => formatLocalDate(new Date()));
const [reason, setReason] = useState('');
const [formAlert, setFormAlert] = useState(null);
const [absences, setAbsences] = useState([]);
const [absencesPhase, setAbsencesPhase] = useState('loading');
const [absencesError, setAbsencesError] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [registryBusy, setRegistryBusy] = useState(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardData, setWizardData] = useState(null);
const [wizardLoading, setWizardLoading] = useState(false);
const [wizardAlert, setWizardAlert] = useState(null);
const [wizardApplying, setWizardApplying] = useState(false);
const [decisions, setDecisions] = useState({});
const [preferenceSemesters, setPreferenceSemesters] = useState([]);
const [preferenceSemesterId, setPreferenceSemesterId] = useState('');
const [preferenceStatusFilter, setPreferenceStatusFilter] = useState('PENDING');
const [preferenceItems, setPreferenceItems] = useState([]);
const [preferencePhase, setPreferencePhase] = useState('idle');
const [preferenceError, setPreferenceError] = useState('');
const [preferenceComments, setPreferenceComments] = useState({});
const [preferenceBusy, setPreferenceBusy] = useState(null);
const [changeStatusFilter, setChangeStatusFilter] = useState('PENDING');
const [changeItems, setChangeItems] = useState([]);
const [changePhase, setChangePhase] = useState('idle');
const [changeError, setChangeError] = useState('');
const [changeComments, setChangeComments] = useState({});
const [changeBusy, setChangeBusy] = useState(null);
const [alert, setAlert] = useState(null);
const latestRef = useRef(null);
latestRef.current = { statusFilter, preferenceSemesterId, preferenceStatusFilter, changeStatusFilter };
const stats = absenceStats(absences);
const canApplyChanges = role === 'ADMIN' || role === 'EDUCATION_OFFICE';
useEffect(() => {
void loadAbsences();
void loadPreferenceMeta();
void loadChangeReviews();
}, []);
// Мастер замены блокирует прокрутку страницы (в legacy — класс на body)
useEffect(() => {
if (!wizardOpen) return;
document.body.classList.add('absence-wizard-open');
return () => document.body.classList.remove('absence-wizard-open');
}, [wizardOpen]);
// Escape закрывает системное окно сообщения (в legacy — диалог из dialog.js)
useEffect(() => {
if (!alert) return;
function handleKeydown(event) {
if (event.key === 'Escape') setAlert(null);
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [alert]);
async function loadAbsences(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.statusFilter;
setAbsencesPhase('loading');
try {
const items = await fetchTeacherAbsences(nextStatus);
setAbsences(items);
setAbsencesPhase('ready');
} catch (error) {
setAbsencesError(error.message || 'Не удалось загрузить отсутствия');
setAbsencesPhase('error');
}
}
async function handleSubmit(event) {
event.preventDefault();
setFormAlert(null);
if (!teacherSelection.id || !startDate || !endDate || !reason.trim()) {
setFormAlert({ type: 'error', message: 'Заполните преподавателя, период и причину' });
return;
}
if (endDate < startDate) {
setFormAlert({ type: 'error', message: 'Дата окончания не может быть раньше даты начала' });
return;
}
try {
await createTeacherAbsence({
teacherId: Number(teacherSelection.id),
startDate,
endDate,
reason: reason.trim()
});
setFormAlert({ type: 'success', message: 'Отсутствие зарегистрировано' });
setReason('');
await loadAbsences();
} catch (error) {
setFormAlert({ type: 'error', message: error.message || 'Не удалось зарегистрировать отсутствие' });
}
}
async function handleRegistryAction(item, action) {
setRegistryBusy({ id: item.id, action });
try {
if (action === 'approve' || action === 'reject') {
await reviewTeacherAbsence(item.id, {
approved: action === 'approve',
comment: action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (action === 'cancel') {
await deleteTeacherAbsence(item.id);
await loadAbsences();
} else if (action === 'wizard') {
await openWizard(item.id);
}
} catch (error) {
setAlert({ title: 'Действие не выполнено', message: error.message || 'Не удалось выполнить действие' });
} finally {
setRegistryBusy(null);
}
}
async function openWizard(id) {
setWizardOpen(true);
setWizardLoading(true);
setWizardData(null);
setWizardAlert(null);
setDecisions({});
try {
const data = await fetchTeacherAbsenceWizard(id);
setWizardData(data);
setWizardLoading(false);
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось открыть мастер замены' });
}
}
function closeWizard() {
setWizardOpen(false);
setWizardData(null);
setWizardLoading(false);
setWizardAlert(null);
setWizardApplying(false);
setDecisions({});
}
async function applyWizard() {
if (!wizardData) return;
const nextDecisions = (wizardData.lessons || []).map((lesson, index) => {
const value = decisions[index] || '';
if (!value) return null;
return {
baseRuleSlotId: Number(lesson.baseRuleSlotId),
lessonDate: lesson.lessonDate,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!nextDecisions.length && (wizardData.lessons || []).length) {
setWizardAlert({ type: 'error', message: 'Выберите хотя бы одно решение' });
return;
}
setWizardApplying(true);
setWizardAlert(null);
try {
const data = await resolveTeacherAbsence(wizardData.absence.id, nextDecisions);
setWizardData(data);
setDecisions({});
setWizardAlert({ type: 'success', message: 'Выбранные решения применены к расписанию' });
await loadAbsences();
} catch (error) {
setWizardAlert({ type: 'error', message: error.message || 'Не удалось применить решения' });
} finally {
setWizardApplying(false);
}
}
async function loadPreferenceMeta() {
setPreferencePhase('meta-loading');
try {
const meta = await fetchTeacherPreferenceMeta();
setPreferenceSemesters(meta.semesters || []);
const today = formatLocalDate(new Date());
const current = (meta.semesters || []).find(item => item.startDate <= today && item.endDate >= today);
const nextSemesterId = current ? String(current.id) : '';
setPreferenceSemesterId(nextSemesterId);
await loadPreferenceReviews({ semesterId: nextSemesterId });
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить семестры');
setPreferencePhase('error');
}
}
async function loadPreferenceReviews(overrides = {}) {
const semesterId = overrides.semesterId ?? latestRef.current.preferenceSemesterId;
const status = overrides.status ?? latestRef.current.preferenceStatusFilter;
setPreferencePhase('loading');
try {
const items = await fetchTeacherPreferences({ semesterId, status });
setPreferenceItems(items);
setPreferencePhase('ready');
} catch (error) {
setPreferenceError(error.message || 'Не удалось загрузить пожелания');
setPreferencePhase('error');
}
}
async function handlePreferenceReview(item, approved) {
const comment = (preferenceComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setPreferenceBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherPreference(item.id, {
approved,
comment: comment || 'Согласовано ответственным сотрудником'
});
await loadPreferenceReviews();
} catch (error) {
setAlert({ title: 'Ошибка рассмотрения', message: error.message || 'Не удалось рассмотреть пожелание' });
} finally {
setPreferenceBusy(null);
}
}
async function loadChangeReviews(overrideStatus) {
const nextStatus = overrideStatus ?? latestRef.current.changeStatusFilter;
setChangePhase('loading');
try {
const items = await fetchTeacherChangeRequests(nextStatus);
setChangeItems(items);
setChangePhase('ready');
} catch (error) {
setChangeError(error.message || 'Не удалось загрузить заявки');
setChangePhase('error');
}
}
async function handleChangeReview(item, approved) {
const comment = (changeComments[item.id] || '').trim();
if (!approved && !comment) {
setAlert({ title: 'Нужен комментарий', message: 'При отклонении укажите комментарий.' });
return;
}
setChangeBusy({ id: item.id, action: approved ? 'approve' : 'reject' });
try {
await reviewTeacherChangeRequest(item.id, { approved, comment });
await Promise.all([loadChangeReviews(), loadAbsences()]);
} catch (error) {
setAlert({ title: 'Решение не применено', message: error.message || 'Не удалось применить решение' });
} finally {
setChangeBusy(null);
}
}
const registryContent = absencesPhase === 'loading'
? <div className="loading-row">Загрузка...</div>
: absencesPhase === 'error'
? <div className="loading-row">{absencesError}</div>
: (absences.length ? absences.map(item => (
<article className="absence-registry-item" key={String(item.id)} data-status={item.status}>
<div className="absence-date-rail">
<strong>{dayOfMonth(item.startDate)}</strong>
<span>{monthShort(item.startDate)}</span>
<i></i>
<strong>{dayOfMonth(item.endDate)}</strong>
<span>{monthShort(item.endDate)}</span>
</div>
<div className="absence-registry-main">
<div className="absence-registry-title">
<div>
<h3>{item.teacherName || 'Преподаватель не указан'}</h3>
<p>{item.reason || 'Причина не указана'}</p>
</div>
<span className={`absence-status ${statusClass(item.status)}`}>{statusLabel(item.status)}</span>
</div>
<div className="absence-meta-row">
<span>{formatAbsenceDate(item.startDate)} — {formatAbsenceDate(item.endDate)}</span>
<span>{decisionCountLabel(item.decisionCount || 0)}</span>
{item.reviewComment ? <span>{item.reviewComment}</span> : null}
</div>
</div>
<div className="absence-registry-buttons">
{item.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'approve'}
onClick={() => void handleRegistryAction(item, 'approve')}
>
Подтвердить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'reject'}
onClick={() => void handleRegistryAction(item, 'reject')}
>
Отклонить
</button>
</>
) : null}
{['APPROVED', 'RESOLVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-secondary"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'wizard'}
onClick={() => void handleRegistryAction(item, 'wizard')}
>
{item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
) : null}
{['PENDING', 'APPROVED'].includes(item.status) ? (
<button
type="button"
className="btn btn-sm btn-ghost"
disabled={registryBusy?.id === item.id && registryBusy?.action === 'cancel'}
onClick={() => void handleRegistryAction(item, 'cancel')}
>
Отменить
</button>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Инцидентов не найдено</strong>
<span>Измените фильтр или зарегистрируйте отсутствие.</span>
</div>
));
const preferenceContent = preferencePhase === 'idle'
? null
: preferencePhase === 'meta-loading'
? <div className="loading-row">Загрузка семестров...</div>
: preferencePhase === 'loading'
? <div className="loading-row">Загрузка пожеланий...</div>
: preferencePhase === 'error'
? <div className="loading-row">{preferenceError}</div>
: (preferenceItems.length ? preferenceItems.map(item => (
<article className="teacher-dialogue-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark preference-${item.preferenceType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{item.academicYearTitle} · {item.semesterType === 'AUTUMN' ? 'осенний семестр' : 'весенний семестр'}
</small>
<h3>{item.teacherName}</h3>
<p>{preferenceDescription(item)}</p>
{item.comment ? <blockquote>{item.comment}</blockquote> : null}
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={preferenceComments[item.id] || ''}
onChange={event => setPreferenceComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, true)}
>
Согласовать
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={preferenceBusy?.id === item.id}
onClick={() => void handlePreferenceReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Пожеланий не найдено</strong>
<span>Измените фильтры или дождитесь новых отметок преподавателей.</span>
</div>
));
const changeContent = changePhase === 'idle'
? null
: changePhase === 'loading'
? <div className="loading-row">Загрузка заявок...</div>
: changePhase === 'error'
? <div className="loading-row">{changeError}</div>
: (changeItems.length ? changeItems.map(item => (
<article className="teacher-dialogue-item change-item" key={String(item.id)}>
<span className={`teacher-dialogue-mark change-${item.requestType.toLowerCase()}`}></span>
<div className="teacher-dialogue-copy">
<small>
{formatAbsenceDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)} · {item.sourceClassroomName}
</small>
<h3>{item.teacherName} · {item.subjectName}</h3>
<p>{changeTypeLabel(item.requestType)} → <strong>{changeReviewTarget(item)}</strong></p>
<blockquote>{item.reason}</blockquote>
{item.reviewComment
? <span className="teacher-dialogue-review">Решение: {item.reviewComment}</span>
: null}
<details>
<summary>История · {(item.history || []).length}</summary>
<ol>
{(item.history || []).map((entry, index) => (
<li key={index}>
<b>{genericStatusLabel(entry.status)}</b> · {entry.comment || 'Без комментария'}
</li>
))}
</ol>
</details>
</div>
<div className="teacher-dialogue-action">
<span className={`absence-status ${statusClass(item.status)}`}>{genericStatusLabel(item.status)}</span>
{item.status === 'PENDING' && canApplyChanges ? (
<>
<input
type="text"
className="teacher-dialogue-comment"
maxLength="2000"
placeholder="Комментарий к решению"
value={changeComments[item.id] || ''}
onChange={event => setChangeComments(previous => ({
...previous,
[item.id]: event.target.value
}))}
/>
<div>
<button
className="btn btn-sm btn-primary"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, true)}
>
Применить
</button>
<button
className="btn btn-sm btn-danger-subtle"
type="button"
disabled={changeBusy?.id === item.id}
onClick={() => void handleChangeReview(item, false)}
>
Отклонить
</button>
</div>
</>
) : item.status === 'PENDING'
? <small>Применение доступно учебному отделу</small>
: null}
</div>
</article>
)) : (
<div className="absence-empty">
<strong>Заявок не найдено</strong>
<span>Очередь изменений сейчас пуста.</span>
</div>
));
return (
<>
<section className="absence-command-card">
<div className="absence-command-copy">
<span className="absence-kicker">Оперативный контур расписания</span>
<h2>От регистрации инцидента до готовой замены</h2>
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
</div>
<div className="absence-command-stats" aria-label="Сводка отсутствий">
<article><strong id="absence-stat-active">{stats.active}</strong><span>требуют внимания</span></article>
<article><strong id="absence-stat-pending">{stats.pending}</strong><span>ждут подтверждения</span></article>
<article><strong id="absence-stat-resolved">{stats.resolved}</strong><span>разрешены</span></article>
</div>
</section>
<section className="card absence-create-card">
<div className="card-header-row">
<div>
<span className="absence-section-number">01</span>
<h2>Зарегистрировать отсутствие</h2>
</div>
<span className="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
</div>
<form id="teacher-absence-form" noValidate onSubmit={handleSubmit}>
<div className="absence-form-grid">
<div className="form-group absence-teacher-field">
<label htmlFor="teacher-absence-teacher">Преподаватель</label>
<AsyncCombobox
ariaLabel="Преподаватель"
placeholder="Найдите преподавателя по ФИО"
selectedValue={teacherSelection.id}
selectedLabel={teacherSelection.label}
optionLabel={teacherOptionLabel}
optionDescription={teacher => [
teacher.jobTitle,
teacher.departmentName
].filter(Boolean).join(' · ')}
load={(query, selectedId) => fetchAbsenceTeacherOptions({
query,
selectedId,
role,
departmentId: session?.departmentId
})}
onSelect={item => setTeacherSelection(item
? { id: String(item.id), label: teacherOptionLabel(item) }
: { id: '', label: '' })}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-start">Начало</label>
<input
type="date"
id="teacher-absence-start"
required
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-absence-end">Окончание</label>
<input
type="date"
id="teacher-absence-end"
required
value={endDate}
onChange={event => setEndDate(event.target.value)}
/>
</div>
<div className="form-group absence-reason-field">
<label htmlFor="teacher-absence-reason">Причина</label>
<input
type="text"
id="teacher-absence-reason"
maxLength="2000"
placeholder="Болезнь, командировка или другая причина"
required
value={reason}
onChange={event => setReason(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-md btn-primary absence-create-submit">
Зарегистрировать
</button>
</div>
<FormAlert id="teacher-absence-form-alert" alert={formAlert} />
</form>
</section>
<section className="card absence-registry-card">
<div className="card-header-row absence-registry-head">
<div>
<span className="absence-section-number">02</span>
<h2>Реестр инцидентов</h2>
</div>
<div className="absence-registry-actions">
<select
id="teacher-absence-status-filter"
aria-label="Фильтр по статусу"
value={statusFilter}
onChange={event => {
setStatusFilter(event.target.value || '');
void loadAbsences(event.target.value || '');
}}
>
<option value="">Все статусы</option>
<option value="PENDING">На подтверждении</option>
<option value="APPROVED">Согласованы</option>
<option value="RESOLVED">Разрешены</option>
<option value="REJECTED">Отклонены</option>
<option value="CANCELLED">Отменены</option>
</select>
<button
type="button"
className="btn btn-md btn-secondary"
id="teacher-absence-refresh"
onClick={() => void loadAbsences()}
>
Обновить
</button>
</div>
</div>
<div className="absence-registry" id="teacher-absence-registry" aria-live="polite">
{registryContent}
</div>
</section>
<section className="teacher-dialogue-grid">
<article className="card teacher-dialogue-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">03</span>
<h2>Пожелания на семестр</h2>
<p>Строгие ограничения после согласования участвуют в проверке правил. Мягкие отметки остаются видимой подсказкой.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-preference-pending-count">
{preferenceItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>ожидают</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-preference-semester-filter"
aria-label="Семестр пожеланий"
value={preferenceSemesterId}
onChange={event => {
setPreferenceSemesterId(event.target.value || '');
void loadPreferenceReviews({ semesterId: event.target.value || '' });
}}
>
<option value="">Все семестры</option>
{preferenceSemesters.map(semester => (
<option key={String(semester.id)} value={String(semester.id)}>
{absenceSemesterLabel(semester)}
</option>
))}
</select>
<select
id="teacher-preference-status-filter"
aria-label="Статус пожеланий"
value={preferenceStatusFilter}
onChange={event => {
setPreferenceStatusFilter(event.target.value || '');
void loadPreferenceReviews({ status: event.target.value || '' });
}}
>
<option value="PENDING">На рассмотрении</option>
<option value="APPROVED">Согласованные</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-preference-refresh"
type="button"
onClick={() => void loadPreferenceReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-preference-review-list" aria-live="polite">
{preferenceContent}
</div>
</article>
<article className="card teacher-dialogue-card change-review-card">
<header className="teacher-dialogue-head">
<div>
<span className="absence-section-number">04</span>
<h2>Заявки на изменение</h2>
<p>Каждое одобрение проходит повторную проверку конфликтов и применяется через точечное изменение расписания.</p>
</div>
<div className="teacher-dialogue-counter">
<strong id="teacher-change-pending-count">
{changeItems.filter(item => item.status === 'PENDING').length}
</strong>
<span>в очереди</span>
</div>
</header>
<div className="teacher-dialogue-toolbar">
<select
id="teacher-change-status-filter"
aria-label="Статус заявок"
value={changeStatusFilter}
onChange={event => {
setChangeStatusFilter(event.target.value || '');
void loadChangeReviews(event.target.value || '');
}}
>
<option value="PENDING">Ожидают решения</option>
<option value="APPROVED">Одобренные</option>
<option value="REJECTED">Отклонённые</option>
<option value="">Все статусы</option>
</select>
<button
className="btn btn-md btn-secondary"
id="teacher-change-refresh"
type="button"
onClick={() => void loadChangeReviews()}
>
Обновить
</button>
</div>
<div className="teacher-dialogue-list" id="teacher-change-review-list" aria-live="polite">
{changeContent}
</div>
</article>
</section>
{wizardOpen ? (
<div
className="absence-wizard-overlay"
id="absence-wizard-overlay"
onClick={event => {
if (event.target === event.currentTarget) closeWizard();
}}
>
<section className="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
<header className="absence-wizard-header">
<div>
<span className="absence-kicker">Мастер замены</span>
<h2 id="absence-wizard-title">
{wizardData ? (wizardData.absence.teacherName || 'Мастер замены') : 'Затронутые занятия'}
</h2>
<p id="absence-wizard-summary">
{wizardData
? `${formatAbsenceDate(wizardData.absence.startDate)} — ${formatAbsenceDate(wizardData.absence.endDate)} · ${wizardData.absence.reason}`
: 'Загрузка...'}
</p>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost"
id="absence-wizard-close"
aria-label="Закрыть"
onClick={closeWizard}
>
&times;
</button>
</header>
<div className="absence-wizard-progress" aria-hidden="true">
<span className="done">Инцидент</span><i></i><span className="done">Занятия</span><i></i><span>Решения</span>
</div>
<FormAlert id="absence-wizard-alert" alert={wizardAlert} />
<div className="absence-wizard-lessons" id="absence-wizard-lessons">
{wizardLoading
? <div className="loading-row">Подбираем свободные ресурсы...</div>
: (wizardData
? (wizardData.lessons.length
? wizardData.lessons.map((lesson, index) => (
<LessonDecision
key={index}
lesson={lesson}
index={index}
locked={wizardData.absence.status !== 'APPROVED' || Boolean(lesson.decisionStatus)}
value={decisions[index] || ''}
onChange={value => setDecisions(previous => ({ ...previous, [index]: value }))}
/>
))
: (
<div className="absence-empty">
<strong>Затронутых занятий нет</strong>
<span>Инцидент можно завершить без точечных изменений.</span>
</div>
))
: null)}
</div>
<section className="absence-journal">
<div className="card-header-row">
<h3>Журнал решений</h3>
<span className="muted" id="absence-journal-count">
{wizardData ? decisionCountLabel((wizardData.journal || []).length) : '0 записей'}
</span>
</div>
<div id="absence-journal-list" className="absence-journal-list">
{wizardData ? (
(wizardData.journal || []).length ? (wizardData.journal || []).map((item, index) => (
<article key={index}>
<span className={`absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}`}></span>
<div>
<strong>{resolutionLabel(item.resolution)}</strong>
<small>{formatAbsenceDate(item.lessonDate)}</small>
</div>
<span>
{decisionStatusLabel(item.status)}{item.scheduleOverrideId ? ` · override №${item.scheduleOverrideId}` : ''}
</span>
</article>
)) : <div className="loading-row">Решений пока нет</div>
) : null}
</div>
</section>
<footer className="absence-wizard-footer">
<span className="muted">Пустые строки останутся без изменений</span>
<div>
<button type="button" className="btn btn-md btn-ghost" id="absence-wizard-cancel" onClick={closeWizard}>
Закрыть
</button>
<button
type="button"
className="btn btn-md btn-primary"
id="absence-wizard-apply"
hidden={wizardData?.absence.status !== 'APPROVED'}
disabled={wizardApplying}
onClick={() => void applyWizard()}
>
Применить выбранные
</button>
</div>
</footer>
</section>
</div>
) : null}
{alert ? (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) setAlert(null);
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{alert.title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={() => setAlert(null)}
>
&times;
</button>
</header>
<p id="project-dialog-message">{alert.message}</p>
<footer>
<button type="button" className="btn btn-md btn-primary" onClick={() => setAlert(null)}>
Понятно
</button>
</footer>
</section>
</div>
) : null}
</>
);
}
function LessonDecision({ lesson, index, locked, value, onChange }) {
const options = decisionOptions(lesson);
return (
<article className="absence-lesson">
<div className="absence-lesson-time">
<strong>{trimTime(lesson.startTime)}</strong>
<span>{trimTime(lesson.endTime)}</span>
<small>{formatAbsenceDate(lesson.lessonDate)}</small>
</div>
<div className="absence-lesson-copy">
<span className="absence-lesson-type">{lesson.lessonTypeName || 'Занятие'}</span>
<h3>{lesson.subjectName || 'Дисциплина не указана'}</h3>
<p>
{(lesson.groupNames || []).join(', ') || 'Группы не указаны'} · {lesson.classroomName || 'Без аудитории'}
</p>
{lesson.existingOverrideId
? <small className="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>
: null}
</div>
<div className="absence-lesson-decision">
<label htmlFor={`absence-decision-${index}`}>Решение</label>
<select
id={`absence-decision-${index}`}
className="absence-decision-select"
disabled={locked}
value={value}
onChange={event => onChange(event.target.value)}
>
<option value="">Оставить без изменений</option>
{options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{lesson.decisionStatus
? <span className="absence-decision-fixed">{decisionStatusLabel(lesson.decisionStatus)}</span>
: null}
</div>
</article>
);
}