Files
magistr/frontend/react/teacher/TeacherApp.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

364 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 'Загрузка расписания...';
}