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,363 @@
import { useEffect, useState } from 'react';
import { readUrlState, writeUrlState } from '../../admin/js/url-state.js';
import { trackUxEvent } from '../../telemetry.js';
import { buildScheduleOverviewModel } from '../../schedule-overview.js';
import { logoutAndRedirect } from '../shared/auth-client.js';
import { SESSION_PHASES } from '../shared/session-policy.js';
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
import { AsyncState } from '../shared/ui/AsyncState.jsx';
import { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
import {
addDays,
formatWeekLabel,
parseIsoDate,
startOfWeek,
toIsoDate,
weekEndIso
} from '../shared/dates.js';
import { AbsencePanel } from './AbsencePanel.jsx';
import { PreferencesTab } from './PreferencesTab.jsx';
import { RequestDialog } from './RequestDialog.jsx';
import { RequestsTab } from './RequestsTab.jsx';
import { TeacherWeekGrid } from './TeacherWeekGrid.jsx';
import {
cancelChangeRequest,
createChangeRequest,
fetchChangeRequests,
fetchTeacherSchedule,
pendingCount,
WORKSPACE_TABS
} from './teacher-service.js';
const THEME_STORAGE_KEY = 'theme';
const OVERVIEW_DAYS = 14;
const TAB_TITLES = Object.freeze({
preferences: 'Мои пожелания',
requests: 'Заявки на изменение',
schedule: 'Расписание'
});
export function TeacherApp() {
const sessionState = useAuthorizedSession(['TEACHER']);
useEffect(() => {
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
}, []);
if (sessionState.phase === SESSION_PHASES.loading) {
return (
<main className="shell">
<AsyncState kind="loading" title="Кабинет преподавателя" message="Проверяем сессию…" />
</main>
);
}
if (sessionState.phase !== SESSION_PHASES.ready) {
return (
<main className="shell">
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
</main>
);
}
return <TeacherWorkspace teacherId={sessionState.session.userId} />;
}
function TeacherWorkspace({ teacherId }) {
const [initialUrlState] = useState(() => readUrlState({ teacherTab: 'schedule', teacherDate: '' }));
const [activeTab, setActiveTab] = useState(() => (
WORKSPACE_TABS.includes(initialUrlState.teacherTab) ? initialUrlState.teacherTab : 'schedule'
));
const [weekStart, setWeekStart] = useState(() => startOfWeek(
initialUrlState.teacherDate ? parseIsoDate(initialUrlState.teacherDate) : new Date()
));
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
const [requestsState, setRequestsState] = useState({ kind: 'loading', items: [] });
const [dialogLesson, setDialogLesson] = useState(null);
const weekStartIso = toIsoDate(weekStart);
useEffect(() => {
writeUrlState({ teacherDate: weekStartIso });
}, [weekStartIso]);
useEffect(() => {
let active = true;
(async () => {
setRequestsState({ kind: 'loading', items: [] });
try {
const items = await fetchChangeRequests();
if (!active) return;
setRequestsState({ kind: 'ready', items });
} catch {
if (!active) return;
setRequestsState({ kind: 'error', items: [] });
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!teacherId) {
setScheduleState({ kind: 'error', message: 'Не удалось определить преподавателя' });
return undefined;
}
let active = true;
(async () => {
setScheduleState({ kind: 'loading' });
try {
const lessons = await fetchTeacherSchedule({
endDate: weekEndIso(weekStart),
startDate: weekStartIso,
teacherId
});
if (!active) return;
setScheduleState({ kind: 'ready', lessons });
trackUxEvent('teacher_schedule_loaded', { lessons: lessons.length, startDate: weekStartIso });
} catch {
if (!active) return;
setScheduleState({ kind: 'error' });
}
})();
return () => {
active = false;
};
}, [teacherId, weekStart, weekStartIso]);
useEffect(() => {
if (!teacherId) {
setOverviewState({ kind: 'error', message: 'Не удалось определить преподавателя' });
return undefined;
}
let active = true;
(async () => {
setOverviewState({ kind: 'loading' });
const now = new Date();
try {
const lessons = await fetchTeacherSchedule({
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS)),
startDate: toIsoDate(now),
teacherId
});
if (!active) return;
setOverviewState({ kind: 'ready', lessons, now });
const overview = buildScheduleOverviewModel(lessons, now);
trackUxEvent('teacher_schedule_overview_loaded', {
todayCount: overview.todayLessons.length,
hasNext: Boolean(overview.nextLesson)
});
} catch {
if (!active) return;
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
}
})();
return () => {
active = false;
};
}, [teacherId]);
function switchTab(name) {
setActiveTab(name);
writeUrlState({ teacherTab: name });
trackUxEvent('teacher_workspace_opened', { section: name });
}
function moveWeek(delta) {
setWeekStart(current => addDays(current, delta * 7));
}
function goToCurrentWeek() {
setWeekStart(startOfWeek(new Date()));
}
function handleDateChange(event) {
if (event.target.value) {
setWeekStart(startOfWeek(parseIsoDate(event.target.value)));
}
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem(THEME_STORAGE_KEY, next);
}
function handleLogout(event) {
event.preventDefault();
void logoutAndRedirect();
}
async function refreshRequests() {
try {
const items = await fetchChangeRequests();
setRequestsState({ kind: 'ready', items });
} catch {
setRequestsState({ kind: 'error', items: [] });
}
}
async function handleCancelRequest(id) {
try {
await cancelChangeRequest(id);
await refreshRequests();
} catch {
setRequestsState({ kind: 'error', items: [] });
}
}
async function handleSubmitRequest(payload) {
try {
await createChangeRequest(payload);
setDialogLesson(null);
await refreshRequests();
switchTab('requests');
} catch {
return false;
}
return true;
}
return (
<main className="shell">
<header className="topbar">
<div className="title">
<h1>Кабинет преподавателя</h1>
<span>{formatWeekLabel(weekStart)}</span>
</div>
<div className="actions">
<button
className="btn btn-icon-md btn-secondary"
type="button"
aria-label="Переключить тему"
onClick={toggleTheme}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
</svg>
</button>
<a className="btn btn-danger-subtle" href="/" onClick={handleLogout}>Выйти</a>
</div>
</header>
<nav className="workspace-nav" aria-label="Разделы кабинета">
{WORKSPACE_TABS.map(tab => (
<button
key={tab}
className={`workspace-tab${activeTab === tab ? ' active' : ''}`}
type="button"
onClick={() => switchTab(tab)}
>
{TAB_TITLES[tab]}
{tab === 'requests' && (
<span>{pendingCount(requestsState.items)}</span>
)}
</button>
))}
</nav>
<section className={`workspace-view${activeTab === 'schedule' ? ' active' : ''}`} hidden={activeTab !== 'schedule'}>
<section className="toolbar">
<div className="week-nav">
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Предыдущая неделя" onClick={() => moveWeek(-1)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button className="btn btn-md btn-primary" type="button" onClick={goToCurrentWeek}>Сегодня</button>
<button className="btn btn-icon-md btn-secondary" type="button" aria-label="Следующая неделя" onClick={() => moveWeek(1)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
<input
className="date-input"
type="date"
aria-label="Дата недели"
value={weekStartIso}
onChange={handleDateChange}
/>
</section>
{overviewState.kind === 'ready' ? (
<ScheduleOverview
lessons={overviewState.lessons}
now={overviewState.now}
onGoToday={goToCurrentWeek}
role="teacher"
/>
) : (
<section className="schedule-overview" aria-live="polite">
{overviewState.kind === 'error' ? (
<AsyncState
kind="error"
title="Быстрый обзор"
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
/>
) : (
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
)}
</section>
)}
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
{scheduleStatus(scheduleState)}
</div>
<AbsencePanel />
{scheduleState.kind === 'ready' ? (
<TeacherWeekGrid
lessons={scheduleState.lessons}
onRequestLesson={setDialogLesson}
weekStart={weekStart}
/>
) : (
<TeacherWeekGrid
emptyMessage={scheduleState.kind === 'error' ? 'Ошибка загрузки' : 'Загрузка...'}
lessons={[]}
onRequestLesson={setDialogLesson}
weekStart={weekStart}
/>
)}
</section>
<section className={`workspace-view${activeTab === 'preferences' ? ' active' : ''}`} hidden={activeTab !== 'preferences'}>
<PreferencesTab />
</section>
<section className={`workspace-view${activeTab === 'requests' ? ' active' : ''}`} hidden={activeTab !== 'requests'}>
<RequestsTab
items={requestsState.items}
onCancel={handleCancelRequest}
onRefresh={() => void refreshRequests()}
stateKind={requestsState.kind}
/>
</section>
{dialogLesson && (
<RequestDialog
lesson={dialogLesson}
onClose={() => setDialogLesson(null)}
onSubmitted={handleSubmitRequest}
/>
)}
</main>
);
}
function scheduleStatus(scheduleState) {
if (scheduleState.kind === 'error') {
return scheduleState.message || 'Не удалось загрузить расписание';
}
if (scheduleState.kind === 'ready') {
const count = scheduleState.lessons.length;
return count > 0 ? `Найдено занятий: ${count}` : 'На этой неделе занятий нет';
}
return 'Загрузка расписания...';
}