Files
magistr/frontend/react/student/StudentApp.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

327 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 { logoutAndRedirect } from '../shared/auth-client.js';
import { SESSION_PHASES } from '../shared/session-policy.js';
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
import { AsyncCombobox } from '../shared/ui/AsyncCombobox.jsx';
import { AsyncState } from '../shared/ui/AsyncState.jsx';
import { buildScheduleOverviewModel } from '../../schedule-overview.js';
import { ScheduleOverview } from '../shared/ui/ScheduleOverview.jsx';
import { WeekGrid } from './WeekGrid.jsx';
import {
addDays,
buildWeekStatusMessage,
fetchGroupOptions,
fetchSchedule,
formatWeekLabel,
groupOptionDescription,
GROUP_STORAGE_KEY,
OVERVIEW_DAYS,
parseIsoDate,
resolveSelectedGroup,
startOfWeek,
toIsoDate,
weekEndIso
} from './student-service.js';
const THEME_STORAGE_KEY = 'theme';
export function StudentApp() {
const sessionState = useAuthorizedSession(['STUDENT']);
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 <StudentWorkspace />;
}
function StudentWorkspace() {
const [initialUrlState] = useState(() => readUrlState({ studentGroup: '', studentDate: '' }));
const [weekStart, setWeekStart] = useState(() => startOfWeek(
initialUrlState.studentDate ? parseIsoDate(initialUrlState.studentDate) : new Date()
));
const [group, setGroup] = useState(null);
const [groupsState, setGroupsState] = useState({ kind: 'loading' });
const [groupsReload, setGroupsReload] = useState(0);
const [scheduleState, setScheduleState] = useState({ kind: 'loading' });
const [overviewState, setOverviewState] = useState({ kind: 'loading' });
const [overviewReload, setOverviewReload] = useState(0);
const weekStartIso = toIsoDate(weekStart);
const groupId = group ? String(group.id) : '';
useEffect(() => {
let active = true;
(async () => {
setGroupsState({ kind: 'loading' });
const savedGroupId = initialUrlState.studentGroup
|| localStorage.getItem(GROUP_STORAGE_KEY)
|| '';
try {
const groups = await fetchGroupOptions({ selectedId: savedGroupId });
if (!active) return;
if (!groups.length) {
setGroupsState({ kind: 'empty' });
setOverviewState({ kind: 'empty', message: 'Нет доступных групп' });
return;
}
const selected = resolveSelectedGroup(groups, savedGroupId);
applyGroup(selected);
setGroupsState({ kind: 'ready' });
} catch {
if (!active) return;
setGroupsState({ kind: 'error' });
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
}
})();
return () => {
active = false;
};
}, [groupsReload, initialUrlState]);
useEffect(() => {
writeUrlState({ studentDate: weekStartIso });
}, [weekStartIso]);
useEffect(() => {
if (!group) return undefined;
let active = true;
(async () => {
setScheduleState({ kind: 'loading' });
try {
const lessons = await fetchSchedule({
groupId: String(group.id),
startDate: weekStartIso,
endDate: weekEndIso(weekStart)
});
if (!active) return;
setScheduleState({ kind: 'ready', lessons });
trackUxEvent('student_schedule_loaded', {
lessons: lessons.length,
startDate: weekStartIso,
groupId: String(group.id)
});
} catch {
if (!active) return;
setScheduleState({ kind: 'error' });
}
})();
return () => {
active = false;
};
}, [group, weekStart, weekStartIso]);
useEffect(() => {
if (!group) return undefined;
let active = true;
(async () => {
setOverviewState({ kind: 'loading' });
const now = new Date();
try {
const lessons = await fetchSchedule({
groupId: String(group.id),
startDate: toIsoDate(now),
endDate: toIsoDate(addDays(now, OVERVIEW_DAYS))
});
if (!active) return;
setOverviewState({ kind: 'ready', lessons, now });
const overview = buildScheduleOverviewModel(lessons, now);
trackUxEvent('student_schedule_overview_loaded', {
todayCount: overview.todayLessons.length,
hasNext: Boolean(overview.nextLesson),
groupId: String(group.id)
});
} catch {
if (!active) return;
setOverviewState({ kind: 'error', message: 'Не удалось загрузить ближайшие занятия' });
}
})();
return () => {
active = false;
};
}, [group, overviewReload]);
function applyGroup(nextGroup) {
setGroup(nextGroup);
localStorage.setItem(GROUP_STORAGE_KEY, String(nextGroup.id));
writeUrlState({ studentGroup: String(nextGroup.id) });
}
function handleGroupSelect(nextGroup) {
if (!nextGroup) return;
applyGroup(nextGroup);
trackUxEvent('student_group_selected', { groupId: String(nextGroup.id) });
}
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();
}
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>
<section className="toolbar">
{groupsState.kind === 'ready' && group ? (
<AsyncCombobox
allowClear={false}
ariaLabel="Группа"
emptyLabel="Подходящие группы не найдены"
placeholder="Найдите группу по названию или специальности"
load={(query, selectedId) => fetchGroupOptions({ query, selectedId })}
onSelect={handleGroupSelect}
optionDescription={groupOptionDescription}
optionLabel={option => option.name}
optionValue={option => option.id}
selectedLabel={group.name}
selectedValue={groupId}
/>
) : (
<select className="select" aria-label="Группа" disabled>
<option value="">{groupSelectPlaceholder(groupsState.kind)}</option>
</select>
)}
<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="student"
/>
) : (
<section className="schedule-overview" aria-live="polite">
{overviewState.kind === 'empty' ? (
<AsyncState kind="empty" title="Быстрый обзор" message={overviewState.message || 'В ближайшие дни занятий нет'} />
) : overviewState.kind === 'error' ? (
<AsyncState
kind="error"
title="Быстрый обзор"
message={overviewState.message || 'Не удалось загрузить ближайшие занятия'}
onRetry={() => setOverviewReload(reload => reload + 1)}
/>
) : (
<AsyncState kind="loading" title="Быстрый обзор" message="Загружаем ближайшие занятия…" />
)}
</section>
)}
<div className={`status${scheduleState.kind === 'error' ? ' error' : ''}`}>
{statusMessage(scheduleState, groupsState)}
</div>
{groupsState.kind === 'error' ? (
<AsyncState
kind="error"
title="Группы"
message="Не удалось загрузить группы"
onRetry={() => setGroupsReload(reload => reload + 1)}
/>
) : scheduleState.kind === 'ready' ? (
<WeekGrid weekStart={weekStart} lessons={scheduleState.lessons} />
) : scheduleState.kind === 'error' ? (
<WeekGrid weekStart={weekStart} lessons={[]} emptyMessage="Ошибка загрузки" />
) : (
<WeekGrid
weekStart={weekStart}
lessons={[]}
emptyMessage={groupId ? 'Загрузка...' : 'Нет выбранной группы'}
/>
)}
</main>
);
}
function groupSelectPlaceholder(kind) {
if (kind === 'empty') return 'Группы не найдены';
if (kind === 'error') return 'Ошибка загрузки групп';
return 'Загрузка групп...';
}
function statusMessage(scheduleState, groupsState) {
if (groupsState.kind === 'error') return 'Не удалось загрузить группы';
if (groupsState.kind === 'empty') return 'Выберите группу';
if (scheduleState.kind === 'error') return 'Не удалось загрузить расписание';
if (scheduleState.kind === 'ready') return buildWeekStatusMessage(scheduleState.lessons);
return 'Загрузка расписания...';
}