Перейти к основному содержимому

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, чтобы:

  1. новые модули 5.x подключались без переписывания layout;
  2. shop context и brand scope были явными и отражались в URL;
  3. RBAC (ADR-0004) и brand access (OP-31) фильтровали навигацию и маршруты;
  4. «скоро»-модули отображались как placeholders, не ломая IA;
  5. data-dense экраны (РНП, оцифровка) и другие UX-паттерны (rules, очереди, ИИ-chat) имели общие примитивы поверх одного shell.

Рассмотренные варианты​

Контекст активного кабинета WB​

  1. React state + /dashboard. Shop switcher меняет только client state.

    • Проще старт. − Нет deep links, «Назад» в браузере не работает, сложнее шарить ссылку внутри команды; не совпадает с референсом XWAY (/wb/shop/:id).
  2. Shop-scoped URL /app/wb/:accountId/....

    • Явный контекст в адресной строке; deep links; согласован с XWAY и backend (wb_accounts.id); layout валидирует доступ к accountId один раз. − Чуть больше boilerplate в ссылках и redirect-логике.

Навигация по модулям​

  1. Hardcoded пункты в AppSidebar.

    • Быстро для 3–5 пунктов. − Не масштабируется на 15 модулей + admin + settings; RBAC и «скоро» размазаны по JSX.
  2. Central navigation registry (navigation.config.ts).

    • Один источник истины: label, icon, group, roles, status, accountScoped; sidebar и breadcrumbs генерируются из конфига; OP-104 = добавление записей, не правка layout. − Нужна дисциплина: новый модуль = запись в registry + thin route.

Структура каталогов frontend​

  1. Всё в app/ и components/.

    • Мало папок. − Через 5–10 модулей смешиваются shell, auth, wb-accounts и feature UI; cross-imports между фичами.
  2. features/<domain> + thin routes в app/.

    • Аналог src/modules/ на backend (ADR-0002); фича изолирована; route file только re-export/page wrapper. − Дополнительный уровень каталогов.

Brand scope (фильтр бренда)​

  1. Только внутри модулей РНП/аналитики.

    • Меньше шума в header на старте. − Дублирование picker'а; риск рассинхрона между модулями.
  2. Глобальный 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, /registerAuthpublic
/appRedirectauth
/app/wb/:accountIdDashboard / homeaccount
/app/wb/:accountId/<moduleSlug>Модуль ТЗ (когда live)account
/app/settings/accountsКабинеты WBauth
/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.6soon (OP-104)
pricingЦены 5.2, репрайсер 5.3soon
advertisingАвтобиддер 5.4, ИИ-агент 5.10soon
communicationsАвтоответы 5.5soon
operationsБронировщик 5.8, товарный учёт 5.11soon
aiИИ-помощник 5.9, генерация 5.13soon
otherБухгалтерия 5.7, юр. блок 5.12, A/B 5.14, база знаний 5.15soon
settingsКабинеты WB, профильlive
adminПользователи, brand accesslive (после 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 недоступен пользователю (WbAccountsService scope на 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/

КомпонентНазначение
AppShellgrid: sidebar + main column
AppSidebarlogo, nav groups, collapse mobile
AppHeaderswitchers + breadcrumbs slot + user menu
WbAccountSwitcherсмена /app/wb/:accountId
BrandScopeSwitcherсмена ?brandId= или 'all'
AppNavрендер resolveNavigation()
PageHeadertitle, description, actions
PageToolbarфильтры, период, export
PageContentscroll 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 from features/*, минимум логики.
  • Фичи не импортируют друг друга напрямую; общее — в features/shared/ или components/.
  • Новый модуль 5.x → features/<slug>/ + запись в navigation.ts + route app/(app)/app/wb/[accountId]/<slug>/page.tsx.

7. RBAC на маршрутах​

УровеньМеханизм
/app/*middleware / session check (OP-95)
/app/admin/*layout: role === admin, иначе redirect /app
API callsbackend guards (источник истины — ADR-0004)
SidebarresolveNavigation по роли

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​

KeyStorageНазначение
optima:lastAccountIdlocalStorageredirect с /app
optima:sidebarCollapsedlocalStoragecollapse 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-94Design tokens из макета → globals.css / shadcn theme
OP-96API client + auth headers
OP-97, OP-95AuthSessionProvider + middleware
OP-98Реализация shell по этому ADR: routes, registry, providers, primitives
OP-104Заполнение registry модулями soon
OP-108, OP-109Backend для BrandScopeSwitcher и admin lists

Обновить frontend/CLAUDE.md — целевая структура каталогов и ссылка на ADR-0012.

Связанные​

  • ADR-0008 — стек frontend
  • ADR-0004 — роли
  • ADR-0002 — аналогия core/modules на backend
  • backend/docs/rbac-matrix.md — матрица прав
  • Макет: Optima - Каркас и дизайн-система.html
  • Референс UX: XWAY ЛК