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,177 @@
import { useEffect, useState } from 'react';
import { formatIsoDate, trimTime } from '../shared/dates.js';
import {
buildChangeRequestPayload,
candidateOptions,
fetchChangeRequestCandidates
} from './teacher-service.js';
export function RequestDialog({ lesson, onClose, onSubmitted }) {
const [candidates, setCandidates] = useState(null);
const [candidatesError, setCandidatesError] = useState(false);
const [requestType, setRequestType] = useState('MOVE');
const [targetDate, setTargetDate] = useState('');
const [timeSlotId, setTimeSlotId] = useState('');
const [classroomId, setClassroomId] = useState('');
const [reason, setReason] = useState('');
const [message, setMessage] = useState({ text: 'Проверяем допустимые варианты...', isError: false });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
document.body.classList.add('dialog-open');
return () => {
document.body.classList.remove('dialog-open');
};
}, []);
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchChangeRequestCandidates({
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.date
});
if (!active) return;
setCandidates(loaded);
setCandidatesError(false);
setTargetDate(loaded.checkedDate || (loaded.availableDates || [])[0] || '');
setMessage({ text: 'Доступны только варианты, прошедшие проверку конфликтов', isError: false });
} catch {
if (!active) return;
setCandidatesError(true);
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
}
})();
return () => {
active = false;
};
}, [lesson]);
async function reloadCandidates(nextDate) {
if (!nextDate) return;
setMessage({ text: 'Проверяем выбранную дату...', isError: false });
try {
const loaded = await fetchChangeRequestCandidates({
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.date,
targetDate: nextDate
});
setCandidates(loaded);
setCandidatesError(false);
setTimeSlotId('');
setClassroomId('');
setMessage({ text: 'Предварительная проверка завершена', isError: false });
} catch {
setMessage({ text: 'Не удалось проверить допустимые варианты', isError: true });
}
}
async function submit(event) {
event.preventDefault();
if (!reason.trim()) {
setMessage({ text: 'Укажите обоснование заявки', isError: true });
return;
}
setSubmitting(true);
try {
await onSubmitted(buildChangeRequestPayload({
classroomId,
lesson,
reason,
requestType,
targetDate,
timeSlotId
}));
} finally {
setSubmitting(false);
}
}
const timeOptions = candidateOptions(candidates?.timeSlots, 'Нет свободных временных слотов');
const classroomOptions = candidateOptions(candidates?.classrooms, 'Нет свободных аудиторий');
return (
<div className="request-dialog-overlay" onClick={event => {
if (event.target === event.currentTarget) onClose();
}}>
<section className="request-dialog" role="dialog" aria-modal="true" aria-labelledby="request-dialog-title">
<header>
<div>
<span>Предварительная проверка</span>
<h2 id="request-dialog-title">Заявка на изменение</h2>
<p>{lesson.subjectName} · {formatIsoDate(lesson.date)} · {trimTime(lesson.startTime)}–{trimTime(lesson.endTime)}</p>
</div>
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Закрыть" onClick={onClose}>×</button>
</header>
<form onSubmit={submit}>
<label>
<span>Что изменить</span>
<select value={requestType} onChange={event => setRequestType(event.target.value)}>
<option value="MOVE">Перенести дату или время</option>
<option value="CHANGE_CLASSROOM">Сменить аудиторию</option>
<option value="CANCEL">Отменить занятие</option>
</select>
</label>
{requestType === 'MOVE' && (
<label>
<span>Новая дата</span>
<select
value={targetDate}
onChange={event => {
setTargetDate(event.target.value);
void reloadCandidates(event.target.value);
}}
>
{(candidates?.availableDates || []).map(date => (
<option key={date} value={date}>{formatIsoDate(date)}</option>
))}
</select>
</label>
)}
{requestType === 'MOVE' && (
<label>
<span>Новая пара</span>
<select value={timeSlotId} onChange={event => setTimeSlotId(event.target.value)}>
{timeOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
)}
{requestType === 'CHANGE_CLASSROOM' && (
<label>
<span>Новая аудитория</span>
<select value={classroomId} onChange={event => setClassroomId(event.target.value)}>
{classroomOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
)}
<label className="request-reason-field">
<span>Обоснование</span>
<textarea
maxLength={2000}
rows={4}
placeholder="Почему требуется изменение"
required
value={reason}
onChange={event => setReason(event.target.value)}
/>
</label>
<div className={`request-dialog-message${message.isError ? ' error' : ''}`} role="alert">
{message.text}
</div>
<footer>
<button className="btn btn-md btn-secondary" type="button" onClick={onClose}>Закрыть</button>
<button className="btn btn-md btn-primary" type="submit" disabled={submitting || candidatesError}>
Отправить в учебный отдел
</button>
</footer>
</form>
</section>
</div>
);
}