# План перехода frontend на React ## Зачем выполняется миграция Цель перехода — снизить сложность развития интерфейса, сделать состояние страниц явным, переиспользовать компоненты и тестировать пользовательские сценарии изолированно. Сам React не гарантирует меньший размер страницы: вес, время загрузки и объём запросов контролируются отдельными бюджетами. Миграция выполняется постепенно по модели «strangler»: React и существующие ES-модули временно работают рядом, а переключение происходит по целым страницам или независимым разделам. Большой одномоментный rewrite не планируется. ## Обязательные ограничения - Сохраняются текущие URL, REST API и серверная ролевая модель. - Access JWT остаётся только в памяти, refresh-токен — в `HttpOnly` cookie. - Нельзя ослаблять CSP: удалённые и inline-скрипты по-прежнему запрещены. - UI, ошибки и пользовательские сообщения остаются на русском языке. - Общие CSS-токены и светлая/тёмная темы сохраняются до отдельного решения о редизайне. - Каждый перенесённый срез должен иметь тесты, production-сборку и измеримый бюджет bundle. - Vanilla-код удаляется только после переключения соответствующего маршрута на React. ## Целевая схема ```text frontend/ ├── react/ │ ├── shared/ # Общие React-компоненты, auth/api и состояния загрузки │ ├── login/ # Страница входа │ ├── student/ # Кабинет студента │ ├── teacher/ # Кабинет преподавателя │ └── admin/ # Shell, маршруты и feature-модули админ-панели ├── dist/react/ # Скомпилированные production-bundle, не хранятся в Git ├── admin/, student/, ... # Legacy-срезы до завершения их миграции └── scripts/build-react.mjs # Сборка React через esbuild ``` На переходном этапе Apache продолжает раздавать статические файлы и проксировать `/api`. React-bundle собираются в существующем Node-этапе Docker-образа. После появления нескольких React entrypoint сборка должна выделить общий runtime в кэшируемый chunk, а функциональные разделы — загружать лениво. ## Этапы | Этап | Содержание | Критерий завершения | Статус | |------|------------|---------------------|--------| | 0. Инвентаризация | Карта entrypoint, маршрутов, общих модулей и рисков | Выбран независимый первый срез; зафиксированы ограничения | Завершён | | 1. Основа + вход | React/React DOM, esbuild entrypoint, Docker, перенос `/`, тест API-контракта и бюджет bundle | `/` работает на React без изменения auth-контракта; `npm run check` проходит | Завершён | | 2. Общий React-слой | `react/shared`: auth/api adapters, состояния loading/empty/error, Error Boundary; общий runtime chunk; DOM-тесты | Следующие страницы не дублируют инфраструктурный код | Завершён | | 3. Автономные кабинеты | Сначала `/student/`, затем `/teacher/`; компоненты расписания и навигации | Оба URL полностью работают на React, legacy `app.js` удалены | Завершён: `/student/` и `/teacher/` перенесены | | 4. Настройки | React-shell `/admin/settings/`, затем вкладки `general`, `time-slots`, `edu-forms`, `database` | Роли и hash-навигация совпадают с текущим SPA | Завершён: `/admin/settings/` перенесён | | 5. Основная админ-панель | Shell и hash-router, затем перенос feature-модулей волнами | Все роли и вкладки работают на React, HTML-фрагменты больше не загружаются через `fetch` | Завершён: shell, hash-router и все 15 вкладок (волны 1–6) перенесены, переходный `LegacyTabView`/`legacy-views.js` удалён | | 6. Очистка и оптимизация | Удаление legacy-loader, дедупликация CSS, lazy loading, cache/compression, финальные performance budgets | Нет исполняемого legacy UI-кода; контрольные метрики не хуже baseline | Не начат | ### Очерёдность админских feature-модулей 1. Простые справочники: `classrooms`, `subjects`, `dashboard`. 2. Реестры и формы: `users`, `groups`, `university-structure`. 3. Ролевые рабочие места: `teacher-requests`, `department-workspace`. 4. Календарь и нагрузка: `academic-calendar`, `auditorium-workload`. 5. Контур публикации: `schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`. 6. Последними — `schedule` и `schedule-override-panel`, потому что у них больше всего связей, локального состояния и бизнес-ограничений. ## Стратегия проверки - На каждом этапе: `npm run build`, `npm run check` и smoke-проверка Docker-образа. - Для чистой логики сохраняются быстрые тесты на `node:test`. - На этапе 2 добавляются DOM/component-тесты для форм, ошибок, клавиатурной навигации и ролей. - Перед удалением legacy-среза сравниваются URL, payload запросов, редиректы и доступность. - Для React entrypoint действует gzip-бюджет. Первый срез `/` ограничен 75 000 байт gzip; следующие feature-chunk получают отдельные лимиты после выделения общего runtime. - Для тяжёлых разделов измеряются число запросов, transferred bytes и время до готовности интерфейса; миграция не считается оптимизацией без улучшения или сохранения этих метрик. ## Риски и меры | Риск | Мера | |------|------| | Рост bundle из-за React runtime | Общий кэшируемый chunk, lazy loading, лимит сборки | | Дублирование auth/api логики | Завершить этап 2 до переноса следующей страницы | | Рассинхрон ролей admin/settings | Использовать текущую `role-capabilities.js` как единый контракт до её переноса | | Поломка CSP | Проверять `.jsx` теми же security-тестами, что `.js` и `.html` | | Два источника истины в одном экране | Переключать целый маршрут/вкладку, не смешивать управление одним DOM-деревом | | Незаметная деградация производительности | Фиксировать raw/gzip размер и браузерные метрики на каждом этапе | ## Точка остановки **Дата:** 28 сентября 2026 года **Остановились после этапа 5, волны 1 — вкладки `dashboard`, `classrooms`, `subjects` основной админ-панели переведены на React-компоненты с lazy-загрузкой.** Выполнено на этапе 2 (общий слой): - `react/shared`: `apiClient`/`requestJson`, `auth-client` + `session-policy`, `useAuthorizedSession`, `AppErrorBoundary`, `AsyncState`, `FormAlert`; - общий кэшируемый runtime-chunk `dist/react/shared/chunks/` (esbuild `splitting`), Apache отдаёт chunk с `immutable`, entrypoint — `no-cache`; - DOM-проверки через `renderToStaticMarkup`, бюджеты сборки. Выполнено на этапе 3 (кабинет студента): - `react/student/`: `StudentApp` (сессия через `useAuthorizedSession(['STUDENT'])`, выбор группы, навигация по неделям, состояния loading/empty/error с повтором), `WeekGrid`, `ScheduleOverview`, `student-service` (дата-математика, чипы, контракты API); - React-версия поискового списка `AsyncCombobox` вынесена в `react/shared/ui/` с той же семантикой ARIA, debounce 320 мс и клавиатурной навигацией, что и vanilla-версия; - модель обзора «Сегодня / Следующая пара» переиспользует чистые функции `schedule-overview.js` (нужные помощники экспортированы аддитивно); - контракты сохранены: `GET /api/groups/options`, `GET /api/schedule`, `localStorage.studentGroupId`, query-параметры `studentGroup`/`studentDate`, telemetry-события `student_*`, logout через общий `logoutAndRedirect`; - `student/index.html` монтирует `/react/student.js`, legacy `student/app.js` удалён; - бюджеты: entrypoint студента 5 819 байт gzip (лимит 20 000), холодная загрузка 78 309 байт gzip (лимит 90 000), общие chunk 72 490 байт gzip (лимит 75 000); - `npm run check`: 124 теста пройдено (13 новых для кабинета студента). Выполнено на этапе 3 (кабинет преподавателя): - `react/teacher/`: `TeacherApp` (сессия через `useAuthorizedSession(['TEACHER'])`, вкладки расписания/пожеланий/заявок, навигация по неделям), `TeacherWeekGrid` (матрица слотов на десктопе и дневной список на мобильных через `matchMedia`), `AbsencePanel`, `PreferencesTab`, `RequestsTab`, `RequestDialog`, `teacher-service` (слоты, матрица недели, метки статусов, payload заявок, контракты API); - дата-математика выделена в `react/shared/dates.js` (единый источник для студента и преподавателя, `student-service` реэкспортирует), `ScheduleOverview` перенесён в `react/shared/ui/` и переиспользуется обоими кабинетами с ролью `student`/`teacher`; - контракты сохранены: `GET /api/schedule?teacherId=`, `GET|POST|DELETE /api/teacher-preferences`, `GET|POST|DELETE /api/teacher-change-requests`, `GET /api/teacher-change-requests/candidates`, `GET|POST|DELETE /api/teacher-absences`, query-параметры `teacherTab`/`teacherDate`, telemetry-события `teacher_*`; - `teacher/index.html` монтирует `/react/teacher.js`, legacy `teacher/app.js` удалён; - бюджеты: entrypoint преподавателя 10 095 байт gzip (лимит 20 000), холодная загрузка 84 590 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip (лимит 75 000); - `npm run check`: 146 тестов пройдено (22 новых для кабинета преподавателя). Выполнено на этапе 4 (страница настроек): - `react/settings/`: `SettingsApp` (сессия через `useAuthorizedSession(SETTINGS_ROLES)`, боковая панель с фильтрацией вкладок по `role-capabilities.js`, сворачивание панели с сохранением в `localStorage.sidebar-collapsed`, тема в React вместо `theme-toggle.js`), вкладки `GeneralTab`, `TimeSlotsTab`, `EduFormsTab`, `DatabaseTab`, `settings-service` (метки сеток времени, валидация слота/тенанта, контракты API); - общий `ConfirmDialog` в `react/shared/ui/` переиспользует классы `.project-dialog*` и закрывается по Escape/клику по фону; - роли и навигация совпадают с legacy-SPA: у настроек не было hash-навигации, активная вкладка живёт в состоянии React, как раньше — в памяти страницы; ADMIN видит все четыре вкладки (по умолчанию `general`), EDUCATION_OFFICE — `time-slots` и `edu-forms` (по умолчанию `time-slots`); - inline-стили legacy (`database.html`, `database.js`) заменены классами `.db-status-grid`, `.form-group-wide`, `.form-row-actions` в `admin/settings/css/main.css`, CSP-хэши `style-src-attr` в `security.conf` пересобраны по фактическим атрибутам; - контракты сохранены: `GET|POST|DELETE /api/admin/time-slots/scopes`, `GET|POST|PUT|DELETE /api/admin/time-slots`, `GET|POST|DELETE /api/education-forms`, `GET /api/database/status`, `GET|POST|DELETE /api/database/tenants`, `POST /api/database/test`; - `admin/settings/index.html` монтирует `/react/settings.js`, legacy `admin/settings/js/` и `admin/settings/views/` удалены; - бюджеты: entrypoint настроек 9 172 байта gzip (лимит 20 000), холодная загрузка 83 667 байт gzip (лимит 90 000), общие chunk 74 495 байт gzip (лимит 75 000); - `npm run check`: 159 тестов пройдено (13 новых для страницы настроек). Выполнено на этапе 5 (срез 1 — React-shell основной админ-панели): - `react/admin/`: `AdminApp` (сессия через `useAuthorizedSession(ADMIN_APP_ROLES)`) и `AdminWorkspace` (sidebar с секциями и фильтрацией по `role-capabilities.js`, topbar с заголовком вкладки и счётчиком заявок, тема в React вместо `theme-toggle.js`, сворачивание панели `localStorage.sidebar-collapsed`, настройки с выходом); - hash-router в React: чтение `location.hash` при старте, слушатель `hashchange`, переключение через `requestTabSwitch` с guard'ом `confirmAbandonDirtyChanges` (единый модуль `admin/js/dirty-state.js` для React и legacy-вкладок); - счётчик ожидающих заявок (только ADMIN): `GET /api/teacher-requests/page?status=PENDING&page=0&size=1`, обновление по событию `teacherRequestsChanged`, склонение «заявка/заявки/заявок», бейдж в навигации и заголовке; - переходный слой `LegacyTabView` + `legacy-views.js` (удалён после волны 6): ещё не перенесённые вкладки загружались как lazy-chunkи (fetch HTML-фрагмента + `init*()` legacy-модуля), каждая вьюха целиком владела своим DOM-поддеревом — React и vanilla не смешивались в одном дереве; после переноса вкладки её загрузчик и legacy-файл удалялись; - глобальные утилиты legacy (`applyRippleEffect`, `closeAllDropdownsOnOutsideClick`, `initAllCustomDropdowns`, `startDropdownAutoObserver`) вызываются оболочкой один раз; - `admin/index.html` монтирует `/react/admin.js`, legacy `admin/js/main.js` удалён; - бюджеты: entrypoint админ-панели 5 399 байт gzip (лимит 20 000), холодная загрузка 79 977 байт gzip (лимит 90 000), общие chunk 74 578 байт gzip (лимит 75 000), переходные lazy-вкладки 109 512 байт gzip (лимит 150 000, загружаются по одной); - `npm run check`: 170 тестов пройдено (10 новых для оболочки админки). Выполнено на этапе 5 (волна 1 — `dashboard`, `classrooms`, `subjects`): - `react/admin/react-tabs.js` — реестр перенесённых вкладок: каждая вкладка загружается отдельным lazy-chunk через `React.lazy` + `Suspense` (fallback — `tab-loading`); вкладки без записи в реестре едут через `LegacyTabView`; - `react/admin/tabs/DashboardTab.jsx`: метрики, мониторинг текущей пары (обновление каждые 30 с), свободные аудитории, быстрый переход, Red Zone; чистая логика выявления конфликтов (накладки преподавателей/аудиторий, превышение вместимости) перенесена в `dashboard-service.js` (`detectScheduleConflicts` возвращает сегменты с выделением вместо HTML-строк), презентации состояния — из общего `dashboard-conflicts.js`; - `react/admin/tabs/ClassroomsTab.jsx`: форма создания с React-мультиселектом оборудования (замена `initMultiSelect`), таблица с переключением статуса, модалки редактирования аудитории и справочника оборудования; - `react/admin/tabs/SubjectsTab.jsx`: реестр с поиском (debounce 320 мс), фильтром кафедры, сортировкой, пагинацией (общий `react/shared/ui/Pagination.jsx`), сохранением состояния в URL (`subjectQuery`/`subjectDepartment`/`subjectPage`/ `subjectSize`/`subjectSort`), модалка управления преподавателями с `AsyncCombobox`; контракты `GET /api/subjects/page`, `GET|POST|DELETE /api/teacher-subjects` сохранены; - legacy-файлы вкладок удалены (`admin/js/views/{dashboard,classrooms,subjects}.js`, `admin/views/{dashboard,classrooms,subjects}.html`), загрузчики убраны из `legacy-views.js`, у маршрутов перенесённых вкладок в `admin-service.js` больше нет `viewFile`; - inline-стили вкладок заменены классами в `admin/css/components.css` (дашборд: метрики/Red Zone/свободные аудитории; общие: `card-toolbar`, `form-row--*`, `modal-content--medium`, `th-actions`, `badge-list` и др.); CSP-хэши `style-src-attr` в `security.conf` пересчитаны (50 → 22); - классификация chunk'ов в `build-react.mjs` переведена на достижимость от entrypoint'ов: chunk, достижимый только из `dist/react/admin.js`, — lazy-вкладка (в общий runtime-бюджет не входит), даже если внутри есть модуль `react/shared` (например, `Pagination` внутри chunk'а `SubjectsTab`); - бюджеты: entrypoint админ-панели 5 530 байт gzip (лимит 20 000), холодная загрузка 83 877 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip (лимит поднят 75 000 → 85 000: `ConfirmDialog` и `AsyncCombobox` стали общими chunk'ами — их теперь используют и React-вкладки админки; лимит холодной загрузки входа поднят 80 000 → 85 000 по той же причине), lazy-вкладки 112 753 байта gzip в 26 chunk'ах (лимит 150 000, загружаются по одной); - `npm run check`: 182 теста пройдено (12 новых: `react-admin-tabs.test.mjs`). Выполнено на этапе 5 (волна 2 — `users`, `groups`, `university-structure`): - `react/admin/tabs/UsersTab.jsx`: форма создания пользователя (пароль — `type="password"` + `autoComplete="new-password"`), реестр с поиском (debounce 320 мс), фильтром роли, сортировкой и пагинацией, состояние в URL (`userQuery`/`userRole`/`userPage`/`userSize`/`userSort`), архивирование через `ConfirmDialog` и восстановление; контракты `GET /api/users/page`, `POST /api/users`, `DELETE /api/users/{id}`, `POST /api/users/{id}/restore` сохранены; чистая логика — `users-service.js`; - `react/admin/tabs/GroupsTab.jsx`: форма создания с каскадом специальность → профиль, реестр с мультиселектом форм обучения (React-фильтр с чекбоксами «Все» + формы, как `EquipmentMultiSelect`), поиском, сортировкой, сбросом фильтров и пагинацией, состояние в URL (включая массив `groupEducationForm`); модалки редактирования группы, подгрупп (режимы 0/2/3, подсказанные вместимости, валидация суммы, legacy-порядок синхронизации: лишние удаляются, затем обновления — сначала уменьшающие вместимость, затем создания) и календарных графиков (учебный год + `AsyncCombobox` совместимых графиков, список назначений с дисциплинами по семестрам); контракты `GET /api/groups/page`, `POST|PUT|DELETE /api/groups...`, `/subgroups`, `/calendar-assignments`, `GET /api/admin/academic-calendars/options` сохранены; чистая логика — `groups-service.js` (заголовки графиков — общий `admin/js/views/academic-calendar-title.js`); - `react/admin/tabs/UniversityStructureTab.jsx`: внутренние разделы кафедр/специальностей/профилей, формы создания и редактирования (модалки кафедр и специальностей, форма профиля на вкладке и в модалке специальности), клиентская фильтрация и пагинация списков как в legacy `listPage` (поиск с debounce 250 мс, `toLocaleLowerCase('ru')`); контракты `GET|POST|PUT|DELETE /api/departments`, `/api/specialties...`, `/api/specialties/profiles` сохранены; чистая логика — `university-structure-service.js`; - legacy-файлы вкладок удалены (`admin/js/views/{users,groups, university-structure}.js`, `admin/views/{users,groups, university-structure}.html`), загрузчики убраны из `legacy-views.js`, у маршрутов в `admin-service.js` больше нет `viewFile`; - inline-стили заменены классами в `admin/css/components.css` (`modal-content--wide/--xwide/--full`, `profile-form-*`, `form-actions-row`); CSP-хэши `style-src-attr` пересчитаны (22 → 6); - бюджеты: entrypoint админ-панели 5 508 байт gzip (лимит 20 000), холодная загрузка 83 855 байт gzip (лимит 90 000), общие chunk 78 347 байт gzip (лимит 85 000), lazy-вкладки 118 009 байт gzip в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 195 тестов пройдено (13 новых: контракты, валидация подгрупп, фильтрация списков, DOM-рендеры вкладок волны 2). Выполнено на этапе 5 (волна 3 — `teacher-requests`, `department-workspace`): - `react/admin/tabs/TeacherRequestsTab.jsx`: реестр заявок с поиском (debounce 320 мс), фильтром статуса (по умолчанию `PENDING`), пагинацией и состоянием в URL (`requestQuery`/`requestStatus`/`requestPage`/`requestSize`); редактируемые строки (кафедра, логин, ФИО, должность, пароль — `type="password"` + `autoComplete="new-password"`), одобрение с валидацией legacy (все поля + пароль от 8 символов), отклонение через новый общий `PromptDialog` (реплика `promptAction`: обязательная причина, ошибка внутри диалога); событие `teacherRequestsChanged` для счётчика sidebar диспетчируется как в legacy (count актуален только для нефильтрованного списка `PENDING`); контракты `GET /api/teacher-requests/page`, `POST /api/teacher-requests/{id}/approve|reject` сохранены; чистая логика — `teacher-requests-service.js`; - `react/shared/ui/ConfirmDialog.jsx`: добавлен `PromptDialog` — реплика legacy `promptAction` из `admin/js/dialog.js` (разметка `project-dialog*`, обязательное поле с `requiredMessage`, Escape/клик по фону закрывают); - `react/admin/tabs/DepartmentWorkspaceTab.jsx`: карточка управления (кафедра — скрыта для роли `DEPARTMENT`, период, обновление), загрузка дисциплин строками (добавить/удалить, валидация legacy), привязка преподавателя (доступные = все минус привязанные), заявка на преподавателя, сводка «Преподаватели и нагрузка» (сопоставление по `id:` и нормализованному имени, непривязанная нагрузка — «Есть занятия в расписании кафедры», как в legacy), таблицы заявок и дисциплин кафедры, комментарии к дисциплинам (карточка открывается по кнопке «Открыть»); период по умолчанию — из справочника семестров (`defaultSchedulePeriod`) с fallback «сегодня + 7 дней»; сессия — `getSession()` из `admin/js/api.js`, как в legacy; контракты `/api/department/*`, `/api/workload/teachers`, `/api/schedule/semesters`, `/api/users/teachers` сохранены; чистая логика — `department-workspace-service.js`; - legacy-файлы вкладок удалены (`admin/js/views/{teacher-requests, department-workspace}.js`, `admin/views/{teacher-requests, department-workspace}.html`), загрузчики убраны из `legacy-views.js`, у маршрутов в `admin-service.js` больше нет `viewFile`; - inline-стиль `style="margin:0"` из legacy HTML заменён классом `schedule-section-label--flush` в `admin/css/components.css`; CSP-хэши `style-src-attr` пересчитаны (6 → 5); - бюджеты: entrypoint админ-панели 4 371 байт gzip (лимит 20 000), холодная загрузка 83 268 байт gzip (лимит 90 000), общие chunk 78 897 байт gzip (лимит 85 000; `PromptDialog` добавил ~550 байт gzip), lazy-вкладки 121 368 байт gzip в 27 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 205 тестов пройдено (10 новых: состояние и контракты заявок, payload-валидация кабинета кафедры, сопоставление нагрузки, DOM-рендеры вкладок волны 3). Выполнено на этапе 5 (волна 4, срез 1 — `auditorium-workload`): - `react/admin/tabs/AuditoriumWorkloadTab.jsx`: переключатель «Что смотреть» (аудитории/преподаватели/кафедры) и селект «Отображение» (все/конкретная сущность), сводная таблица занятости на дату (ось — сущности, колонки — эффективные слоты, карточки занятий, «Свободно»), фильтры аудиторий (корпус/мин. вместимость/оборудование — React-мультиселекты, применяются к уже загруженным данным без перезапроса), двухнедельная сводка по выбранной сущности (чёт/нечёт недели в ячейках одного дня, сводка «Занято: N из M», предупреждения об ошибках загрузки и датах вне семестра); сводный текст «Дата: …, Занятых слотов: …» воспроизводит legacy `renderSummary` (включая «выбран один объект» и счётчик ошибок групп); при сбое части групп/сеток показываются доступные данные с предупреждением (legacy `allSettled`-стратегия); - `react/admin/auditorium-workload-service.js`: контракты `GET /api/classrooms`, `/api/equipments`, `/api/groups`, `/api/admin/calendar/years` (сбой молча даёт `[]`), `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots/effective?date=`, `/api/schedule?groupId=&startDate=&endDate=`; чистая логика: дедупликация занятий (`uniqueLessons` с мерджем групп/подгрупп), группировка по сущность×слот и дата×слот, сопоставление преподаватель→кафедра (`departmentId`, fallback по названию), двухнедельный период с понедельника, номера учебных недель и чётность (паритет занятия перекрывает расчёт по семестру, вне семестра — fallback по индексу даты), строки слотов сводной недели, состояния ячеек (busy/free/missing/empty) и сравнение наборов занятий подписью; - legacy-файлы вкладки удалены (`admin/js/views/auditorium-workload.js`, `admin/views/auditorium-workload.html`), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; - inline-стили legacy HTML (отступы заголовка/фильтров, ширина поля вместимости, дата) заменены классами `workload-card-header`, `workload-filter-row` (дополнен), `workload-min-capacity`, `workload-date-field` в `admin/css/auditorium-workload.css`; CSP-хэши `style-src-attr` пересчитаны (5 → 1: в корпусе остался один inline-стиль в `schedule-view.js`); - бюджеты: entrypoint админ-панели 4 372 байта gzip (лимит 20 000), холодная загрузка 83 266 байт gzip (лимит 90 000), общие chunk 78 894 байта gzip (лимит 85 000), lazy-вкладки 122 252 байта gzip в 27 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 212 тестов пройдено (7 новых: период и чётность недель, дедупликация/группировка занятий, фильтры и опции, контракты API, сводки и счётчики, DOM-рендер вкладки). **Следующее действие при продолжении:** этап 5, волна 5 — контур публикации (`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`) по той же схеме: выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки. Последними (волна 6) — `schedule` + `schedule-override-panel`. Выполнено на этапе 5 (волна 4, срез 2 — `academic-calendar`): - `react/admin/tabs/AcademicCalendarTab.jsx`: три внутренних раздела (Графики/Сетки/Дисциплины) с подтверждением ухода при несохранённых изменениях; раздел «Графики»: таблица учебных годов (редактирование года и отдельного семестра, удаление через `ConfirmDialog`), форма календарного графика (год, специальность → профиль, форма обучения, число курсов), реестр графиков (поиск с debounce 300 мс, фильтр по году, пагинация, кнопки «Изменить»/«Сетка»/«Дисциплины»/«Удалить»), конструктор учебного года (ввод года начала автоматически считает год окончания и даты обоих семестров, 4 поля дат с маской); раздел «Сетки»: выбор графика через `AsyncCombobox`, загрузка/сохранение сетки, заполнение диапазона (курс/даты/код активности), чипы итогов с маркером выделения, блоки курсов с таблицами семестров, диалог ячейки (одно/множественное выделение, код активности на всё выделение, информационная временная сетка, «Убрать ручную сетку»), drag-выделение ячеек и подсказка через портал в `document.body` с позиционированием `el.animate` (реплика legacy, без inline-стилей); раздел «Дисциплины»: выбор графика, семестр/дисциплина, привязки по семестрам чипами с удалением, добавление/сохранение с dirty-индикатором «•»; - `react/admin/academic-calendar-service.js`: контракты `GET /api/admin/academic-calendars/page|options`, `GET|PUT /api/admin/academic-calendars/{id}/grid|subjects`, `POST|PUT|DELETE /api/admin/calendar/years`, `DELETE /api/admin/academic-calendars/{id}`, `/api/specialties/{id}/profiles`, `/api/admin/time-slots/*`; чистая логика: расчёт семестров учебного года и валидация пересечений, построение сетки по датам учебного года, блоки семестров, итоги по кодам активностей, тексты диалога/подсказки, расчёт позиции подсказки; заголовки графиков — общий `admin/js/views/academic-calendar-title.js` (как в `groups-service`); - `MaskedDateInput` внутри вкладки — React-реплика `bindDateInputMask` из `admin/js/date-input.js` (форматирование при вводе, нормализация и `aria-invalid` при потере фокуса), переиспользует чистые функции `formatDateInputValue`/`dateInputValueToIso`/`isoDateToInputValue`; - мёртвый код legacy не переносился: «Применить» в диалоге ячейки только закрывает окно, выбор временной сетки информационный (задокументировано в FRONTEND.md), «Убрать ручную сетку» реально удаляет назначение; - legacy-файлы вкладки удалены (`admin/js/views/academic-calendar.js`, `admin/views/academic-calendar.html`; общие хелперы `academic-calendar-title.js`/`academic-calendar-grid.js` сохранены — используются React-сервисом), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; - inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr` не изменились (1 хэш, inline-стиль в `schedule-view.js`); - бюджеты: entrypoint админ-панели 4 375 байт gzip (лимит 20 000), холодная загрузка 83 848 байт gzip (лимит 90 000), общие chunk 79 473 байта gzip (лимит 85 000), lazy-вкладки 124 206 байт gzip в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 217 тестов пройдено (5 новых: контракты API и эндпоинты, ввод года и чтение дат, подписи и сортировки, построение сетки и тексты диалога/подсказки, DOM-рендер вкладки). **Следующее действие при продолжении:** этап 5, волна 5 — контур публикации (`schedule-view`, `schedule-versions`, `schedule-quality`, `teacher-absences`) по той же схеме: выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки. Последними (волна 6) — `schedule` + `schedule-override-panel`. Выполнено на этапе 5 (волна 5, срез 1 — `schedule-view`): - `react/admin/tabs/ScheduleViewTab.jsx`: карточка параметров (цель просмотра — для `DEPARTMENT` заблокирована на «Кафедра», `AsyncCombobox` группы/ преподавателя/аудитории, обычный select кафедры, скрытое поле даты, дополнительные фильтры с каскадом «учебный год → семестр», дисциплина, тип занятия, чётность), карточка результатов (счётчик, чипы разделов, кнопки предыдущего/следующего расписания, кнопка реестра правок) и одна активная таблица (семестровая матрица, совмещённая двухнедельная таблица выбранной сущности, мобильный блок одного дня с переключателем дней); состояние таблиц выводится производно из `sections/activeSectionIndex/range/isMobile`, пустое состояние с подсказкой содержит собственную кнопку «Показать расписание»; - `schedule-override-panel.js` (634 строки) сохранён как общий хелпер волны 6: React-вкладка рендерит drawer как статичный JSX (все `schedule-override-*` id сохранены) и вызывает `createScheduleOverridePanel` в эффекте после загрузки справочников; колбэки панели (`loadData`, `getRange`) читают только `latestRef`/`rangeRef`, поэтому захваченные панелью замыкания не протухают; - `react/admin/schedule-view-service.js`: контракты `GET /api/departments`, `/api/subjects`, `/api/lesson-types`, `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/groups/options`, `/api/users/teachers/options`, `/api/classrooms/options`; чистая логика: разделы по цели просмотра (выбранная группа собирает все занятия в один раздел — фильтрует сервер), семестровая матрица с чётностью и границами недель, совмещённые двухнедельные блоки, подписи недель из академических часов, URL-состояние фильтров (кафедра не сохраняет цель — она зафиксирована ролью); - прелоад трёх комбобоксов на монтировании воспроизводит legacy `combo.preload('')`: справочники сущностей наполняются заранее, подпись восстановленного из URL значения обновляется после загрузки опций; - legacy-файлы вкладки удалены (`admin/js/views/schedule-view.js`, `admin/views/schedule-view.html`), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; - inline-стиль legacy (flex в заголовке карточки занятия) оказался избыточным — правило уже есть в классе `.schedule-view-lesson-title` (`auditorium-workload.css`); CSP-хэши `style-src-attr` стали пустыми (1 → 0), директива `unsafe-hashes` сохранена; - мёртвый код legacy не переносился: кнопок периода `prev-period`/`today`/ `next-period` в разметке не было (в legacy они были null); - бюджеты: entrypoint админ-панели 4 383 байта gzip (лимит 20 000), холодная загрузка 83 856 байт gzip (лимит 90 000), общие chunk 79 473 байта gzip (лимит 85 000), lazy-вкладки 126 170 байт gzip в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 224 теста пройдено (7 новых: контракты API просмотра, разделы по цели, двухнедельная сетка и матрица семестра, подписи недель, URL-состояние, пополнение справочников, DOM-рендер вкладки). **Следующее действие при продолжении:** этап 5, волна 5, срезы 2–4 — `schedule-versions`, `schedule-quality`, `teacher-absences` по той же схеме: выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки. Последними (волна 6) — `schedule` + `schedule-override-panel`. Выполнено на этапе 5 (волна 5, срез 2 — `schedule-versions`): - `react/admin/tabs/ScheduleVersionsTab.jsx`: hero с каскадом «учебный год → семестр» (восстановление из `localStorage`-ключей `magistr.schedule.*`), статусная строка с `data-tone` (`loading`/`success`/`error`), карточка опубликованной версии (LIVE/ПУСТО), панель черновиков со счётчиком, панель сравнения (Δ / загрузка / ошибка / сводка «Что изменится после публикации» с карточками правил), архив публикаций, журнал действий и модальное окно операций (создание черновика, публикация со сводкой валидации, восстановление) с закрытием по Escape/фону и блокировкой отправки при непройденной проверке; - переходы «Редактировать»/«Качество» передают контекст через `localStorage` (`magistr.schedule.openVersionId`, `magistr.quality.*`) и переключают вкладку через `location.hash` — hash-router `AdminApp` проводит переход через guard несохранённых изменений, как клик по пункту меню в legacy; - `react/admin/schedule-versions-service.js`: контракты `GET /api/schedule/semesters`, `/api/edu-office/schedule/versions?semesterId=`, `.../history?semesterId=`, `.../{id}/diff`, `.../{id}/validate`, `POST /api/edu-office/schedule/versions` (черновик), `POST .../{id}/publish|restore` (причина); чистая логика: группировка версий (published/drafts/archive), подписи действий журнала и типов изменений, основа черновика, сводка валидации перед публикацией, формат даты-времени; localStorage-ключи экспортированы константами для волны 6; - до первой загрузки панели пусты, как в legacy HTML (пустые блоки «Нет активных черновиков» и т. п. появляются только после успешной загрузки); мёртвый CSS `version-modal-summary[data-tone]` не поддержан (в legacy атрибут никогда не ставился); - legacy-файлы вкладки удалены (`admin/js/views/schedule-versions.js`, `admin/views/schedule-versions.html`), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; - inline-стилей в legacy HTML вкладки не было — CSP-хэши `style-src-attr` остались пустыми; - бюджеты: entrypoint админ-панели 4 378 байт gzip (лимит 20 000), холодная загрузка 83 851 байт gzip (лимит 90 000), общие chunk 79 473 байта gzip (лимит 85 000), lazy-вкладки 127 193 байта gzip в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 227 тестов пройдено (3 новых: контракты API контура публикаций, группировка версий и подписи, DOM-рендер вкладки). **Следующее действие при продолжении:** этап 5, волна 5, срезы 3–4 — `schedule-quality`, `teacher-absences` по той же схеме: выделить чистую логику, переиспользовать `react/shared`, заменить загрузчик в `legacy-views.js` на запись в `react-tabs.js`, удалить legacy-файл вкладки. Последними (волна 6) — `schedule` + `schedule-override-panel`. Выполнено на этапе 5 (волна 5, срез 3 — `schedule-quality`): - `react/admin/tabs/ScheduleQualityTab.jsx`: каскад «учебный год → семестр → версия» (восстановление из `localStorage`-ключей `magistr.quality.*`, версия по умолчанию — сохранённая, иначе опубликованная, иначе первая), статусная строка с `data-tone` (включая `warning`), кнопка анализа с состоянием «Анализируем…», скорборд с SVG-кольцом (`stroke-dasharray` по клампу счёта, `data-band`), метрики, карта проблем с фильтрами важности/категории и сортировкой по штрафу, панель выбранного правила с переходом в конструктор; - блок результатов скрывается на время анализа и остаётся скрытым при ошибке (как `hidden` в legacy), пустые блоки фильтра — только после успешного анализа; - переход «Открыть правило в конструкторе» передаёт контекст через `localStorage` (`magistr.schedule.semesterId`, `openVersionId`, `openRuleId`) и переключает вкладку через `location.hash`; - `react/admin/schedule-quality-service.js`: контракт `GET /api/edu-office/schedule/quality?semesterId=&versionId=`; чистая логика: диапазоны оценки, фильтрация и сортировка проблем по категориям (окна/нагрузка/аудитории/пожелания), подписи статусов версий и важности, формат метрик и дат, склонение «занятие/занятия/занятий», сводка «Показаны первые N из M правил»; семестры и версии переиспользуют контракты `schedule-versions-service.js`; - legacy-файлы вкладки удалены (`admin/js/views/schedule-quality.js`, `admin/views/schedule-quality.html`), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; - бюджеты: entrypoint админ-панели 4 357 байт gzip (лимит 20 000), холодная загрузка 83 830 байт gzip (лимит 90 000), общие chunk 79 473 байта gzip (лимит 85 000), lazy-вкладки 130 276 байт gzip в 26 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 233 теста пройдено (3 новых: контракт API анализа, подписи и диапазоны, DOM-рендер вкладки). Выполнено на этапе 5 (волна 5, срез 4 — `teacher-absences`): - `react/admin/tabs/TeacherAbsencesTab.jsx`: сводка статистики, форма регистрации отсутствия (React-`AsyncCombobox` поиска преподавателя — для роли DEPARTMENT подставляет `departmentId` из сессии, даты по умолчанию сегодня), реестр инцидентов с фильтром статуса и действиями (подтвердить/отклонить/отменить/мастер), мастер замены (overlay с блокировкой прокрутки `absence-wizard-open` на body, решения по каждому занятию, журнал решений, «Применить выбранные» с проверкой «Выберите хотя бы одно решение»), согласование пожеланий на семестр (фильтры семестра/статуса, счётчик ожидающих, комментарий обязателен при отклонении) и заявки на изменение (применение доступно только ADMIN/EDUCATION_OFFICE, история решения); - модальные сообщения об ошибках действий воспроизведены локальным диалогом с классами `project-dialog` (разметка и поведение `alertAction` из `dialog.js`: закрытие по Escape/фону, кнопка «Понятно»); - до инициализации списки согласования пусты, реестр показывает «Загрузка...» — как в legacy HTML; мастер замены рендерится только при открытии; - `react/admin/teacher-absences-service.js`: контракты `GET/POST/DELETE /api/teacher-absences` (+ `/{id}/review`, `/{id}/wizard`, `/{id}/resolve`), `GET /api/teacher-preferences/meta`, `GET/POST /api/teacher-preferences`, `GET/POST /api/teacher-change-requests`, `GET /api/users/teachers/options`; чистая логика: варианты решений мастера и разбор `REPLACE_TEACHER|id` / `MOVE_TIME|date|slot` / `CHANGE_CLASSROOM|id`, подписи статусов и решений, склонение «решение/решения/решений», сводка статистики, цель заявки на изменение; - legacy-файлы вкладки удалены (`admin/js/views/teacher-absences.js`, `admin/views/teacher-absences.html`), загрузчик убран из `legacy-views.js`, у маршрута в `admin-service.js` больше нет `viewFile`; в `legacy-views.js` остался единственный загрузчик — `schedule` (волна 6); - бюджеты: lazy-вкладки 130 276 байт gzip в 26 chunk'ах (лимит 150 000), остальные без изменений — лимиты не поднимались; - `npm run check`: 233 теста пройдено (3 новых: контракты API отсутствий, подписи и сводки, DOM-рендер вкладки). Выполнено на этапе 5 (волна 6, финальная — `schedule` + `schedule-override-panel`): - `react/admin/tabs/ScheduleTab.jsx`: конструктор правил — каскад учебный год → семестр → версия (контекст-бар с eyebrow/заголовком/заметкой, выбор версии по localStorage-ключам `magistr.schedule.openVersionId`/ `openRuleId` из `schedule-versions-service.js`, модалка создания черновика с копией правил), форма правила (дисциплина, семестр синхронизирован с версией, часы/недели начала по типам занятий, мультиселект групп с поиском и кэшем выбранных, слоты с подгруппами только для лабораторных, подсказки согласованных пожеланий преподавателя), реестр правил с поиском, пагинацией и удалением, модалка конфликта (причины, редактируемые слоты конфликтующего правила, повтор с сохранением payload), визуальная матрица (drawer с фильтром года/семестра/групп, ячейки по чётности, контекстное меню пары — позиционирование через Web Animations API, действия «открыть правило»/ «изменить день и пару»/«удалить»), dirty-guard формы через общий `dirty-state.js`; - `react/admin/schedule-service.js`: чистая логика и контракты API — слоты правил и payload-валидация (часы по типам, подгруппы только лабораторных и по одной на группу), payload конфликтующего правила, подписи правил/черновиков, группировка опций версий, визуальная матрица (ячейки по чётности, активные недели, дедупликация занятий), позиционирование контекстного меню, `GET /api/admin/schedule-rules/page|/{id}`, `POST|PUT|DELETE /api/admin/schedule-rules`, `GET /api/groups/options`, `GET /api/groups/subgroups`, `GET /api/teacher-preferences?status=APPROVED`; - боковая панель разовых правок `schedule-override-panel.js` абсорбирована в `ScheduleViewTab.jsx`: drawer переписан на React-состояние (`forwardRef` + `useImperativeHandle`: `openLesson`/`openRegistry`), реестром правок за период владеет вкладка (кнопка «Изменения за период» со счётчиком), вкладка «Редактирование» — сравнение «было/станет», недельный выбор дат, календарь семестра, время по эффективным слотам, преподаватель/аудитория через React-`AsyncCombobox` с пополнением справочников, отмена занятия и возврат по правилу; чистая логика — `react/admin/schedule-override-service.js` (контракты `GET /api/edu-office/schedule/overrides/availability`, `GET /api/admin/time-slots/effective`, `POST|PUT|DELETE /api/edu-office/schedule/overrides`); - переходный слой удалён: `react/admin/LegacyTabView.jsx`, `react/admin/legacy-views.js`, `admin/views/schedule.html`, `admin/js/views/schedule.js`, `admin/js/views/schedule-override-panel.js`, а также оставшиеся без пользователей `admin/js/async-combobox.js` и `admin/js/pagination.js` (React-аналоги — `react/shared/ui/AsyncCombobox.jsx` и `Pagination.jsx`); все 15 вкладок админ-панели — React, HTML-фрагменты больше не загружаются через `fetch`; - бюджеты: entrypoint админ-панели 5 640 байт gzip (лимит 20 000), холодная загрузка 84 857 байт gzip (лимит 90 000), общие chunk 79 217 байт gzip (лимит 85 000), lazy-вкладки 128 571 байт gzip в 25 chunk'ах (лимит 150 000, загружаются по одной) — лимиты не поднимались; - `npm run check`: 237 тестов пройдено (обновлены импорты тестов с legacy-модулей на React-сервисы, добавлены payload-валидация, DOM-рендер конструктора и контроль разрешения lazy-вкладок; проверки `LegacyTabView`/`legacy-views` заменены на контроль отсутствия legacy-модулей). Починено при первом браузерном smoke-тесте после волны 6: реестр `react-tabs.js` использовал голый `import()` внутри `React.lazy`, а компоненты вкладок экспортируются именованными экспортами — без `default` React падал при открытии любой вкладки (React error #306, «Интерфейс временно недоступен» после авторизации). Исправлено явным отображением `.then(m => ({ default: m.XTab }))`; добавлен регрессионный тест, проверяющий разрешение всех 15 lazy-вкладок. Этап 5 закрыт: основная админ-панель полностью работает на React. Команды для возобновления: ```bash cd frontend npm ci npm run build npm run check ```