Files
magistr/frontend/react/teacher/RequestDialog.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

178 lines
8.1 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, 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>
);
}