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:
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal file
940
frontend/react/admin/tabs/TeacherAbsencesTab.jsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user