0012. Frontend: App Shell, маршрутизация и navigation registry
Копия ADR из репозитория:
backend/docs/adr/0012-frontend-app-shell-and-navigation.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-06-11
- Связанные задачи: OP-93, OP-94, OP-95, OP-97, OP-98, OP-104
Контекст и проблема
По ТЗ «Оптима» — единое окно с 15
функциональными модулями (§5.1–5.15), тремя ролями (admin / seller / manager) и
разграничением данных на уровне бренда (§5.6, OP-31). UX-референс —
личный кабинет XWAY: тёмный sidebar, переключатель
магазина, модульная навигация. Визуальный каркас и design tokens зафиксированы в макете
Optima - Каркас и дизайн-система.html (Golos Text, --brand-*, --side-*).
Стек frontend принят в ADR-0008 (Next.js App Router + shadcn +
TanStack Query). Сейчас в frontend/ — hello-world без shell, без контекста кабинета WB
и без расширяемой навигации.
Нужно зафиксировать масштабируемую архитектуру UI до реализации OP-98, чтобы:
- новые модули 5.x подключались без переписывания layout;
- shop context и brand scope были явными и отражались в URL;
- RBAC (ADR-0004) и brand access (OP-31) фильтровали навигацию и маршруты;
- «скоро»-модули отображались как placeholders, не ломая IA;
- data-dense экраны (РНП, оцифровка) и другие UX-паттерны (rules, очереди, ИИ-chat) имели общие примитивы поверх одного shell.
Рассмотренные варианты
Контекст активного кабинета WB
-
React state +
/dashboard. Shop switcher меняет только client state.- Проще старт.
− Нет deep links, «Назад» в браузере не работает, сложнее шарить ссылку
внутри команды; не совпадает с референсом XWAY (
/wb/shop/:id).
- Проще старт.
− Нет deep links, «Назад» в браузере не работает, сложнее шарить ссылку
внутри команды; не совпадает с референсом XWAY (
-
Shop-scoped URL
/app/wb/:accountId/....- Явный контекст в адресной строке; deep links; согласован с XWAY и backend
(
wb_accounts.id); layout валидирует доступ к accountId один раз. − Чуть больше boilerplate в ссылках и redirect-логике.
- Явный контекст в адресной строке; deep links; согласован с XWAY и backend
(
Навигация по модулям
-
Hardcoded пункты в
AppSidebar.- Быстро для 3–5 пунктов. − Не масштабируется на 15 модулей + admin + settings; RBAC и «скоро» размазаны по JSX.
-
Central navigation registry (
navigation.config.ts).- Один источник истины: label, icon, group, roles, status, accountScoped; sidebar и breadcrumbs генерируются из конфига; OP-104 = добавление записей, не правка layout. − Нужна дисциплина: новый модуль = запись в registry + thin route.
Структура каталогов frontend
-
Всё в
app/иcomponents/.- Мало папок. − Через 5–10 модулей смешиваются shell, auth, wb-accounts и feature UI; cross-imports между фичами.
-
features/<domain>+ thin routes вapp/.- Аналог
src/modules/на backend (ADR-0002); фича изолирована; route file только re-export/page wrapper. − Дополнительный уровень каталогов.
- Аналог
Brand scope (фильтр бренда)
-
Только внутри модулей РНП/аналитики.
- Меньше шума в header на старте. − Дублирование picker'а; риск рассинхрона между модулями.
-
Глобальный brand filter в header (рядом с shop switcher).
- Единый контекст для всех
requiresBrandScopeмодулей; согласован с OP-31. − НуженBrandScopeProviderи правила «все бренды» vs один бренд.
- Единый контекст для всех
Решение
Приняты варианты: shop-scoped URL, navigation registry, feature folders, глобальный brand filter в header.
1. Route groups (Next.js App Router)
frontend/src/app/
layout.tsx # root: fonts, Providers
globals.css
(marketing)/
layout.tsx # публичный layout без shell
page.tsx # / — лендинг (OP-106)
(auth)/
layout.tsx # центрированные формы
login/page.tsx # /login
register/page.tsx # /register
(app)/
layout.tsx # auth gate (client/session check)
app/
layout.tsx # AppShell: sidebar + header
page.tsx # /app → redirect на активный кабинет
settings/
accounts/page.tsx # /app/settings/accounts — CRUD кабинетов (OP-101)
profile/page.tsx # /app/settings/profile (OP-102)
admin/
layout.tsx # RolesGuard: admin only
users/page.tsx # OP-103
brand-access/page.tsx # OP-105
wb/
[accountId]/
layout.tsx # WbAccountProvider, проверка scope OP-30
page.tsx # dashboard home / empty state (OP-99)
[...module]/page.tsx # зарезервировано под модули 5.x
Правило: маршруты под /app/wb/:accountId/ — account-scoped (данные кабинета WB).
/app/settings/* и /app/admin/* — не включают :accountId в path.
Middleware (middleware.ts) защищает префикс /app/* (OP-95): неавторизованный
→ /login?returnUrl=....
2. URL-схема
| Path | Назначение | Scope |
|---|---|---|
/ | Лендинг | public |
/login, /register | Auth | public |
/app | Redirect | auth |
/app/wb/:accountId | Dashboard / home | account |
/app/wb/:accountId/<moduleSlug> | Модуль ТЗ (когда live) | account |
/app/settings/accounts | Кабинеты WB | auth |
/app/settings/profile | Профиль | auth |
/app/admin/users | Пользователи | admin |
/app/admin/brand-access | Доступы к брендам | admin |
Query-параметр ?brandId=<uuid> — опциональный brand scope для account-scoped
страниц. При смене кабинета brand scope сбрасывается или восстанавливается из
persisted state (см. §6).
Slug модулей — kebab-case, стабильный (rnp, digitization, repricer, …),
не привязан к номеру пункта ТЗ.
3. Navigation registry
Файл: frontend/src/config/navigation.ts (единственный источник пунктов sidebar).
type NavItemStatus = 'live' | 'soon' | 'hidden';
type NavGroupId =
| 'overview'
| 'sales'
| 'pricing'
| 'advertising'
| 'communications'
| 'operations'
| 'ai'
| 'other'
| 'settings'
| 'admin';
interface NavContext {
accountId: string | null;
role: UserRole;
}
interface NavItemDefinition {
id: string;
label: string;
icon: LucideIconName;
group: NavGroupId;
roles: UserRole[] | 'all';
status: NavItemStatus;
accountScoped: boolean;
requiresBrandScope?: boolean;
href: (ctx: NavContext) => string;
}
Группы sidebar (порядок фиксирован):
| group | Содержание (roadmap) | Статус на Phase 1 |
|---|---|---|
overview | Главная | live (OP-99) |
sales | Оцифровка 5.1, РНП 5.6 | soon (OP-104) |
pricing | Цены 5.2, репрайсер 5.3 | soon |
advertising | Автобиддер 5.4, ИИ-агент 5.10 | soon |
communications | Автоответы 5.5 | soon |
operations | Бронировщик 5.8, товарный учёт 5.11 | soon |
ai | ИИ-помощник 5.9, генерация 5.13 | soon |
other | Бухгалтерия 5.7, юр. блок 5.12, A/B 5.14, база знаний 5.15 | soon |
settings | Кабинеты WB, профиль | live |
admin | Пользователи, brand access | live (после OP-108/109) |
Фильтрация: resolveNavigation(ctx) возвращает только пункты, где
status !== 'hidden', роль пользователя входит в roles, и для accountScoped
задан accountId. Пункты soon рендерятся disabled + badge «Скоро», без route.
Admin-группа видна только role === admin. Settings — всем авторизованным.
4. Провайдеры контекста (порядок вложенности)
RootLayout
QueryClientProvider # уже в Providers
AuthSessionProvider # OP-97: user, role, tokens, /auth/me
(app)/app/layout
AppShellProvider # sidebar collapsed, density (из макета)
AppHeader
WbAccountSwitcher # список из GET /wb-accounts
BrandScopeSwitcher # brands в scope пользователя для accountId
UserMenu
AppSidebar # resolveNavigation()
{children}
(app)/app/wb/[accountId]/layout
WbAccountProvider # accountId из params, validate access
{children}
- AuthSessionProvider — единственное место refresh/logout; API client читает access token отсюда (OP-96).
- WbAccountProvider — метаданные текущего кабинета; 404/403 если accountId
недоступен пользователю (
WbAccountsServicescope на backend). - BrandScopeProvider —
selectedBrandId: string | 'all' | null; для manager — только brands изBrandAccess; для seller — brands кабинета; admin — all.
TanStack Query keys (конвенция):
['auth', 'me']
['wb-accounts']
['wb-account', accountId]
['brands', accountId] // после OP-109
['brand-access', userId] // admin
5. Layout primitives (переиспользуемые компоненты shell)
Каталог: frontend/src/components/shell/
| Компонент | Назначение |
|---|---|
AppShell | grid: sidebar + main column |
AppSidebar | logo, nav groups, collapse mobile |
AppHeader | switchers + breadcrumbs slot + user menu |
WbAccountSwitcher | смена /app/wb/:accountId |
BrandScopeSwitcher | смена ?brandId= или 'all' |
AppNav | рендер resolveNavigation() |
PageHeader | title, description, actions |
PageToolbar | фильтры, период, export |
PageContent | scroll area с padding |
EmptyState | онбординг «Добавьте кабинет WB» (OP-99) |
Каталог: frontend/src/components/page/ — page-level primitives без бизнес-логики.
Page templates (добавляются по мере модулей, не в Phase 1):
DataTablePage— РНП, оцифровка (data-density=compactиз макета);RuleBuilderLayout— репрайсер, автобиддер;JobQueueLayout— фоновые задачи, статусы sync;AssistantDrawer— ИИ-помощник 5.9 (global overlay, не только nav item).
6. Feature folders
frontend/src/features/
auth/ # forms, session hooks (OP-97)
wb-accounts/ # list, form, check key (OP-101, OP-100)
admin/ # users, brand-access (OP-103, OP-105)
shell/ # hooks: useNavContext, useBrandScope (optional split)
shared/
data-table/ # TanStack Table + shadcn wrappers
filters/ # date range, brand picker building blocks
Правила (аналог ADR-0002):
app/**/page.tsx— thin: import page component fromfeatures/*, минимум логики.- Фичи не импортируют друг друга напрямую; общее — в
features/shared/илиcomponents/. - Новый модуль 5.x →
features/<slug>/+ запись вnavigation.ts+ routeapp/(app)/app/wb/[accountId]/<slug>/page.tsx.
7. RBAC на маршрутах
| Уровень | Механизм |
|---|---|
/app/* | middleware / session check (OP-95) |
/app/admin/* | layout: role === admin, иначе redirect /app |
| API calls | backend guards (источник истины — ADR-0004) |
| Sidebar | resolveNavigation по роли |
Frontend RBAC — UX-gate, не security boundary. Backend всегда возвращает 403.
8. Design system и shell chrome
Visual source of truth — макет коллеги + OP-94:
- шрифт Golos Text (не Geist в app shell);
- sidebar
--side-bg: #14162B, content--bg: #F4F5F9; - brand
#5B47F5→ shadcn--primary; - semantic
--pos/neg/warn/infoдля KPI и alerts; data-density="compact"на data-heavy страницах.
App shell всегда тёмный sidebar + светлый content (не toggle light/dark всего app на Phase 1).
9. Persisted UI state
| Key | Storage | Назначение |
|---|---|---|
optima:lastAccountId | localStorage | redirect с /app |
optima:sidebarCollapsed | localStorage | collapse desktop |
optima:brandScope:<accountId> | localStorage | последний brand filter |
Не хранить tokens в localStorage — только httpOnly refresh / in-memory access (OP-97).
Последствия
Положительные
- Новый модуль ТЗ = registry entry + feature folder + thin route; shell не трогаем.
- URL отражает shop context; согласован с XWAY и backend entity
wb_accounts. - Brand scope явный — готовность к РНП (§5.6) и OP-31 без переделки header.
- Feature isolation снижает coupling при параллельной разработке 5.x модулей.
- Placeholders «Скоро» управляются статусом в registry (OP-104).
Отрицательные / компромиссы
- Больше структуры каталогов, чем у minimal Next.js tutorial.
- Длиннее URLs (
/app/wb/uuid/...). - Brand filter в header может быть избыточен до первого analytics-модуля — но дешевле заложить сейчас, чем мигрировать позже.
?brandId=в query vs path segment — query проще для optional filter; для shareable brand-specific views можно позже добавить/brands/:brandId/без ломки shell.
Что нужно сделать дальше
| Задача | Содержание |
|---|---|
| OP-94 | Design tokens из макета → globals.css / shadcn theme |
| OP-96 | API client + auth headers |
| OP-97, OP-95 | AuthSessionProvider + middleware |
| OP-98 | Реализация shell по этому ADR: routes, registry, providers, primitives |
| OP-104 | Заполнение registry модулями soon |
| OP-108, OP-109 | Backend для BrandScopeSwitcher и admin lists |
Обновить frontend/CLAUDE.md — целевая структура каталогов и ссылка на ADR-0012.