Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
178 lines
8.1 KiB
JavaScript
178 lines
8.1 KiB
JavaScript
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>
|
||
);
|
||
}
|