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,80 @@
import {
changeRequestTarget,
changeTypeLabel,
formatInstant,
preferenceStatusLabel
} from './teacher-service.js';
import { formatIsoDate, trimTime } from '../shared/dates.js';
export function RequestsTab({ items, onCancel, onRefresh, stateKind }) {
return (
<>
<section className="request-ledger-head">
<div>
<span>Журнал решений</span>
<h2>Заявки на изменение занятий</h2>
<p>Откройте занятие в расписании, чтобы запросить перенос, другую аудиторию или отмену. Здесь сохраняются статус, комментарий и вся история решения.</p>
</div>
<button className="btn btn-md btn-secondary" type="button" onClick={onRefresh}>Обновить</button>
</section>
<div className="request-ledger" aria-live="polite">
{stateKind === 'error' ? (
<div className="request-empty error">Не удалось загрузить заявки</div>
) : stateKind === 'loading' ? (
<div className="request-empty">Загрузка заявок...</div>
) : items.length === 0 ? (
<div className="request-empty">Заявок на изменение пока нет. Откройте занятие во вкладке «Расписание».</div>
) : items.map(item => (
<RequestLedgerItem key={item.id} item={item} onCancel={onCancel} />
))}
</div>
</>
);
}
function RequestLedgerItem({ item, onCancel }) {
const history = item.history || [];
const statusClass = String(item.status || '').toLowerCase();
return (
<article className="request-ledger-item">
<div className={`request-ledger-stripe ${statusClass}`} />
<div className="request-ledger-main">
<span>{formatIsoDate(item.lessonDate)} · {trimTime(item.sourceStartTime)}–{trimTime(item.sourceEndTime)}</span>
<h3>{item.subjectName}</h3>
<p>{(item.groupNames || []).join(', ')} · {item.sourceClassroomName}</p>
<div className="request-change-line">
<b>{changeTypeLabel(item.requestType)}</b>
<i>→</i>
<strong>{changeRequestTarget(item)}</strong>
</div>
<p className="request-reason">{item.reason}</p>
{item.reviewComment && <p className="request-review-comment">Решение: {item.reviewComment}</p>}
<details>
<summary>История решения · {history.length}</summary>
<ol>
{history.map((entry, index) => (
<li key={index}>
<time>{formatInstant(entry.createdAt)}</time>
<strong>{preferenceStatusLabel(entry.status)}</strong>
<span>{entry.comment || 'Без комментария'}</span>
</li>
))}
</ol>
</details>
</div>
<div className="request-ledger-side">
<span className={`request-status ${statusClass}`}>{preferenceStatusLabel(item.status)}</span>
{item.status === 'PENDING' && (
<button
type="button"
className="btn btn-sm btn-danger-subtle"
onClick={() => void onCancel(item.id)}
>
Отозвать
</button>
)}
</div>
</article>
);
}