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,156 @@
import { useEffect, useState } from 'react';
import { dayName, formatDate, toIsoDate, weekDates } from '../shared/dates.js';
import {
buildDesktopWeekModel,
buildTeacherLessonChips,
canRequestChange,
lessonsByDate
} from './teacher-service.js';
const MOBILE_BREAKPOINT = '(max-width: 980px)';
export function TeacherWeekGrid({ emptyMessage = 'Загрузка...', lessons = [], onRequestLesson, weekStart }) {
const mobile = useMobileLayout();
if (mobile) {
return <MobileWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
}
return <DesktopWeek emptyMessage={emptyMessage} lessons={lessons} onRequestLesson={onRequestLesson} weekStart={weekStart} />;
}
function DesktopWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
const { days, slots, lessonsByKey } = buildDesktopWeekModel(weekStart, lessons);
const hasLessons = lessons.length > 0;
return (
<section className="schedule" aria-live="polite">
<div className="corner" />
{days.map(day => <DayHead key={day.isoDate} date={day.date} />)}
{hasLessons ? (
slots.map(slot => (
<SlotRow
key={slot.order}
days={days}
lessonsByKey={lessonsByKey}
onRequestLesson={onRequestLesson}
slot={slot}
/>
))
) : (
<>
<div className="time-cell">Неделя</div>
{days.map(day => (
<div key={day.isoDate} className="day-cell">
<div className="empty">{emptyMessage}</div>
</div>
))}
</>
)}
</section>
);
}
function SlotRow({ days, lessonsByKey, onRequestLesson, slot }) {
return (
<>
<div className="time-cell">{slot.label}</div>
{days.map(day => {
const lesson = lessonsByKey.get(`${day.isoDate}:${slot.order}`);
return (
<div key={`${day.isoDate}:${slot.order}`} className="day-cell">
{lesson
? <LessonCard lesson={lesson} onRequestLesson={onRequestLesson} />
: <div className="empty">Нет занятия</div>}
</div>
);
})}
</>
);
}
function MobileWeek({ emptyMessage, lessons, onRequestLesson, weekStart }) {
const byDate = lessonsByDate(lessons);
return (
<section className="schedule" aria-live="polite">
{weekDates(weekStart).map(date => {
const isoDate = toIsoDate(date);
const dayLessons = byDate.get(isoDate) || [];
return (
<article key={isoDate} className="mobile-day">
<DayHead date={date} />
<div className="mobile-list">
{dayLessons.length > 0
? dayLessons.map((lesson, index) => (
<LessonCard
key={`${lesson.date}-${trimKey(lesson)}-${index}`}
lesson={lesson}
onRequestLesson={onRequestLesson}
/>
))
: <div className="empty">{emptyMessage}</div>}
</div>
</article>
);
})}
</section>
);
}
function DayHead({ date }) {
return (
<div className="day-head">
<strong>{dayName(date)}</strong>
<span>{formatDate(date)}</span>
</div>
);
}
function LessonCard({ lesson, onRequestLesson }) {
return (
<div className="lesson">
<div className="lesson-title">{lesson.subjectName}</div>
<div className="lesson-meta">
{buildTeacherLessonChips(lesson).map((chip, index) => (
<span key={index} className="chip">{chip}</span>
))}
</div>
{canRequestChange(lesson) ? (
<button
className="btn btn-sm btn-secondary lesson-request-button"
type="button"
onClick={() => onRequestLesson(lesson)}
>
Запросить изменение
</button>
) : (
<span className="lesson-change-note">Занятие уже изменено учебным отделом</span>
)}
</div>
);
}
function trimKey(lesson) {
return String(lesson.startTime || '').slice(0, 5);
}
function useMobileLayout() {
const [mobile, setMobile] = useState(() => matchesMobile());
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const query = window.matchMedia(MOBILE_BREAKPOINT);
const update = () => setMobile(query.matches);
update();
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return mobile;
}
function matchesMobile() {
return typeof window !== 'undefined' && Boolean(window.matchMedia)
&& window.matchMedia(MOBILE_BREAKPOINT).matches;
}