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