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:
177
frontend/react/teacher/RequestDialog.jsx
Normal file
177
frontend/react/teacher/RequestDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user