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
This commit is contained in:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,940 @@
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>
);
}