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,158 @@
import { useEffect, useState } from 'react';
import { formatIsoDate, toIsoDate } from '../shared/dates.js';
import {
absenceStatusClass,
absenceStatusLabel,
cancelAbsence,
createAbsence,
fetchAbsences,
validateAbsence
} from './teacher-service.js';
export function AbsencePanel() {
const [formOpen, setFormOpen] = useState(false);
const [startDate, setStartDate] = useState(() => toIsoDate(new Date()));
const [endDate, setEndDate] = useState(() => toIsoDate(new Date()));
const [reason, setReason] = useState('');
const [status, setStatus] = useState({ message: '', isError: false });
const [items, setItems] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
(async () => {
try {
const loaded = await fetchAbsences();
if (active) setItems(loaded);
} catch {
if (active) setError(true);
}
})();
return () => {
active = false;
};
}, []);
async function reload() {
try {
setItems(await fetchAbsences());
setError(false);
} catch {
setError(true);
}
}
async function submit(event) {
event.preventDefault();
const validation = validateAbsence({ endDate, reason, startDate });
if (validation) {
setStatus({ message: validation, isError: true });
return;
}
try {
await createAbsence({ endDate, reason: reason.trim(), startDate });
setReason('');
setStatus({ message: 'Заявка отправлена кафедре', isError: false });
await reload();
} catch {
setStatus({ message: 'Не удалось отправить заявку об отсутствии', isError: true });
}
}
async function cancel(id) {
try {
await cancelAbsence(id);
setStatus({ message: 'Заявка отменена', isError: false });
await reload();
} catch {
setStatus({ message: 'Не удалось отменить заявку', isError: true });
}
}
return (
<section className="teacher-absence-panel">
<div className="teacher-absence-heading">
<div>
<span>Изменение планов</span>
<h2>Сообщить об отсутствии</h2>
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
</div>
<button
className="btn btn-md btn-secondary"
type="button"
aria-expanded={formOpen}
onClick={() => setFormOpen(open => !open)}
>
{formOpen ? 'Скрыть форму' : 'Новая заявка'}
</button>
</div>
<form className="teacher-absence-form" hidden={!formOpen} onSubmit={submit}>
<label>
<span>Начало</span>
<input
className="date-input"
type="date"
required
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</label>
<label>
<span>Окончание</span>
<input
className="date-input"
type="date"
required
value={endDate}
onChange={event => setEndDate(event.target.value)}
/>
</label>
<label className="teacher-absence-reason">
<span>Причина</span>
<input
type="text"
maxLength={2000}
placeholder="Например, командировка"
required
value={reason}
onChange={event => setReason(event.target.value)}
/>
</label>
<button className="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
</form>
<div className={`teacher-absence-status${status.isError ? ' error' : ''}${status.message && !status.isError ? ' success' : ''}`}>
{status.message}
</div>
<div className="teacher-absence-list" aria-live="polite">
{error ? (
<div className="teacher-absence-empty error">Не удалось загрузить заявки об отсутствии</div>
) : items === null ? (
<div className="teacher-absence-empty">Загрузка заявок...</div>
) : items.length === 0 ? (
<div className="teacher-absence-empty">Заявок об отсутствии пока нет</div>
) : items.map(item => (
<article key={item.id} className="teacher-absence-item">
<span className={`teacher-absence-marker ${absenceStatusClass(item.status)}`} />
<div>
<strong>{formatIsoDate(item.startDate)} — {formatIsoDate(item.endDate)}</strong>
<p>{item.reason || 'Причина не указана'}</p>
</div>
<span className={`teacher-absence-badge ${absenceStatusClass(item.status)}`}>
{absenceStatusLabel(item.status)}
</span>
{item.status === 'PENDING' && (
<button
className="btn btn-sm btn-danger-subtle"
type="button"
onClick={() => void cancel(item.id)}
>
Отменить
</button>
)}
</article>
))}
</div>
</section>
);
}