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:
363
frontend/react/teacher/TeacherApp.jsx
Normal file
363
frontend/react/teacher/TeacherApp.jsx
Normal 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 'Загрузка расписания...';
|
||||
}
|
||||
Reference in New Issue
Block a user