Compare commits
7 Commits
2619a27b80
...
e4a610aa62
| Author | SHA1 | Date | |
|---|---|---|---|
| e4a610aa62 | |||
| 697c4d708a | |||
| 4d6e6620d7 | |||
| 0b3c9a9287 | |||
| 2ab433452a | |||
| 39da5da12a | |||
| 110f8c7f55 |
@ -1,72 +0,0 @@
|
|||||||
# ADR-014: Frontend FSD market pages
|
|
||||||
|
|
||||||
**Статус:** Accepted
|
|
||||||
**Дата:** 2026-06-20
|
|
||||||
**Участники решения:** Product Engineer, Tech Lead
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
После `ADR-013` broker-домен уже получил первый рабочий FSD-срез, но market pages (`HomePage`,
|
|
||||||
`StockPage`, `BondPage`) и связанные UI-блоки всё ещё оставались в исторической технической
|
|
||||||
структуре `components/`, `hooks/` и плоского `pages/`. Из-за этого frontend оказался в
|
|
||||||
промежуточном состоянии: часть экранов уже живёт через FSD public API, а публичная документация
|
|
||||||
всё ещё описывает market-раздел как legacy-only область.
|
|
||||||
|
|
||||||
Для следующей итерации нужна управляемая миграция, которая:
|
|
||||||
|
|
||||||
- продолжает FSD-переход после broker pilot, не превращая его в big-bang переписывание frontend;
|
|
||||||
- переносит market pages и связанные UI/query entrypoints в `pages/`, `widgets/` и `entities/`;
|
|
||||||
- сохраняет совместимость со старыми импортами на время coexistence через тонкие re-export shim;
|
|
||||||
- оставляет orchestration route params, query composition и loading/not-found состояния в
|
|
||||||
page-layer, не размывая ответственность widgets.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
### Option A. Полная market FSD migration с coexistence через shims
|
|
||||||
|
|
||||||
Перенести `HomePage`, `StockPage`, `BondPage`, `SearchBar`, `PriceChart`, `StockDetails`,
|
|
||||||
`BondDetails`, `DividendsTable` и `useSearch` в FSD-срезы. Сохранить старые entrypoints
|
|
||||||
(`components/*`, `hooks/useSearch.ts`, `pages/*.tsx`) как тонкие реэкспорт-шмы на переходный
|
|
||||||
период. Data orchestration и route-specific логика остаются в `pages/home`, `pages/stock`,
|
|
||||||
`pages/bond`.
|
|
||||||
|
|
||||||
### Option B. Частичный перенос только UI-компонентов
|
|
||||||
|
|
||||||
Перенести только `SearchBar`, `PriceChart`, `StockDetails`, `BondDetails` и `DividendsTable` в
|
|
||||||
`widgets/`, но оставить page entrypoints и `useSearch` в исторических каталогах.
|
|
||||||
|
|
||||||
### Option C. Ускоренная миграция без shim-слоя
|
|
||||||
|
|
||||||
Сразу заменить все старые import paths на новые FSD entrypoints и удалить legacy-файлы в рамках
|
|
||||||
одного изменения.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Принят Option A: выполнить market-pages миграцию как следующую итерацию после broker pilot и вести
|
|
||||||
её через coexistence FSD-слоёв и legacy entrypoints.
|
|
||||||
|
|
||||||
Решение включает следующие правила:
|
|
||||||
|
|
||||||
- `pages/home`, `pages/stock`, `pages/bond` становятся primary route entrypoints;
|
|
||||||
- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`, `widgets/bond-details`,
|
|
||||||
`widgets/dividends-table` становятся primary UI entrypoints для market-сценариев;
|
|
||||||
- `entities/search/model/useSearch.ts` становится primary местом для search query hook;
|
|
||||||
- legacy `components/*`, `hooks/useSearch.ts` и плоские `pages/*.tsx` сохраняются как тонкие
|
|
||||||
re-export shim-файлы на переходный период;
|
|
||||||
- orchestration остаётся в page-layer: `useParams`, вычисление диапазонов дат, вызовы domain query
|
|
||||||
hooks, а также loading/error/not-found состояния не переносятся в widgets;
|
|
||||||
- взаимодействие между слоями идёт через public API (`index.ts`), без внешних deep imports в `ui/`
|
|
||||||
и `model/`.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Market-раздел получает ту же архитектурную форму, что и broker pilot, и становится вторым
|
|
||||||
эталонным FSD-срезом frontend.
|
|
||||||
- Migration risk снижается: coexistence через shims позволяет переключать импортирующие места без
|
|
||||||
массового удаления legacy entrypoints.
|
|
||||||
- В проекте временно сохраняется двойная поверхность импортов, поэтому архитектурная дисциплина
|
|
||||||
по-прежнему поддерживается структурой каталогов и code review.
|
|
||||||
- Orchestration остаётся ближе к маршрутам, поэтому widgets остаются prop-driven и переиспользуемыми
|
|
||||||
без знания о `react-router` или query lifecycle.
|
|
||||||
- Published frontend docs и ADR index должны обновляться синхронно с такой миграцией, иначе
|
|
||||||
документация начнёт отставать от фактической структуры кода.
|
|
||||||
@ -15,6 +15,5 @@
|
|||||||
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
|
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
|
||||||
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
|
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
|
||||||
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
|
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
|
||||||
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets |
|
|
||||||
|
|
||||||
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
# Компоненты
|
# Компоненты
|
||||||
|
|
||||||
Published docs ниже перечисляют primary entrypoints. Legacy файлы в `components/` сохранены как
|
## Layout (`components/Layout.tsx`)
|
||||||
тонкие re-export shim'ы для coexistence со старыми импортами.
|
|
||||||
|
|
||||||
## Layout (`app/layouts/AppLayout.tsx`, legacy shim: `components/Layout.tsx`)
|
|
||||||
|
|
||||||
Базовый layout с шапкой и `<Outlet />`.
|
Базовый layout с шапкой и `<Outlet />`.
|
||||||
|
|
||||||
- Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar`
|
- Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar`
|
||||||
- Основной контент: max-width 1200px, padding 24px
|
- Основной контент: max-width 1200px, padding 24px
|
||||||
|
|
||||||
## SearchBar (`widgets/search-bar/ui/SearchBar.tsx`, public API: `widgets/search-bar`)
|
## SearchBar (`components/SearchBar.tsx`)
|
||||||
|
|
||||||
Поиск инструментов с debounce (300ms).
|
Поиск инструментов с debounce (300ms).
|
||||||
|
|
||||||
@ -19,9 +16,7 @@ Published docs ниже перечисляют primary entrypoints. Legacy фа
|
|||||||
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
|
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
|
||||||
- Закрывается при клике вне компонента
|
- Закрывается при клике вне компонента
|
||||||
|
|
||||||
Legacy path `components/SearchBar.tsx` остаётся shim-файлом и не является primary entrypoint.
|
## PriceChart (`components/PriceChart.tsx`)
|
||||||
|
|
||||||
## PriceChart (`widgets/price-chart/ui/PriceChart.tsx`, public API: `widgets/price-chart`)
|
|
||||||
|
|
||||||
График цены на основе `lightweight-charts` v4.
|
График цены на основе `lightweight-charts` v4.
|
||||||
|
|
||||||
@ -30,30 +25,16 @@ Legacy path `components/SearchBar.tsx` остаётся shim-файлом и н
|
|||||||
- Цвета: зелёный для роста, красный для падения
|
- Цвета: зелёный для роста, красный для падения
|
||||||
- Адаптивная ширина (resize listener)
|
- Адаптивная ширина (resize listener)
|
||||||
|
|
||||||
Legacy path `components/PriceChart.tsx` остаётся shim-файлом.
|
## StockDetails (`components/StockDetails.tsx`)
|
||||||
|
|
||||||
## StockDetails (`widgets/stock-details/ui/StockDetails.tsx`, public API: `widgets/stock-details`)
|
|
||||||
|
|
||||||
Карточка с информацией об акции.
|
Карточка с информацией об акции.
|
||||||
|
|
||||||
- Принимает `ShareResponse`
|
- Принимает `ShareResponse`
|
||||||
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
|
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
|
||||||
|
|
||||||
Legacy path `components/StockDetails.tsx` остаётся shim-файлом.
|
## BondDetails (`components/BondDetails.tsx`)
|
||||||
|
|
||||||
## BondDetails (`widgets/bond-details/ui/BondDetails.tsx`, public API: `widgets/bond-details`)
|
|
||||||
|
|
||||||
Карточка с информацией об облигации.
|
Карточка с информацией об облигации.
|
||||||
|
|
||||||
- Принимает `BondResponse`
|
- Принимает `BondResponse`
|
||||||
- Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип
|
- Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип
|
||||||
|
|
||||||
Legacy path `components/BondDetails.tsx` остаётся shim-файлом.
|
|
||||||
|
|
||||||
## DividendsTable (`widgets/dividends-table/ui/DividendsTable.tsx`, public API: `widgets/dividends-table`)
|
|
||||||
|
|
||||||
Таблица дивидендов для страницы акции.
|
|
||||||
|
|
||||||
- Принимает готовый список дивидендов через props
|
|
||||||
- Рендерит payout-историю без собственных query-вызовов
|
|
||||||
- Используется page-layer как prop-driven widget
|
|
||||||
|
|||||||
@ -4,24 +4,13 @@
|
|||||||
|
|
||||||
| Хук | Query key | Stale time | Описание |
|
| Хук | Query key | Stale time | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (primary: `entities/search/model/useSearch.ts`) |
|
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (enabled: query ≥ 2 символов) |
|
||||||
| `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции |
|
| `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции |
|
||||||
| `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции |
|
| `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции |
|
||||||
| `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции |
|
| `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции |
|
||||||
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
|
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
|
||||||
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
|
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
|
||||||
|
|
||||||
## Public API
|
|
||||||
|
|
||||||
- search hook: `entities/search/index.ts`
|
|
||||||
- stock hooks: `entities/stock/index.ts`
|
|
||||||
- bond hooks: `entities/bond/index.ts`
|
|
||||||
- portfolio hooks: `entities/portfolio/index.ts`
|
|
||||||
- broker-account hooks: `entities/broker-account/index.ts`
|
|
||||||
- broker-position hooks: `entities/broker-position/index.ts`
|
|
||||||
- broker-operation hooks: `entities/broker-operation/index.ts`
|
|
||||||
- session hooks: `entities/session/index.ts`
|
|
||||||
|
|
||||||
## Конфигурация Query
|
## Конфигурация Query
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -38,9 +27,11 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
## Паттерн hook
|
## Паттерн hook
|
||||||
|
|
||||||
1. Хук вызывает domain API helper из `entities/*/api/` или `shared/api/client`
|
Каждый хук:
|
||||||
2. Извлекает `res.data` (ответ API обёрнут в `{ data, meta }`)
|
|
||||||
3. Типизируется через response types из `shared/api/responses.ts`
|
1. Вызывает функцию из `api/client.ts`
|
||||||
|
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
|
||||||
|
3. Типизирован через рукописные типы из `api/responses.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useStock(secid: string) {
|
export function useStock(secid: string) {
|
||||||
|
|||||||
@ -14,9 +14,6 @@ React SPA, собранная с Vite.
|
|||||||
|
|
||||||
## Структура исходников
|
## Структура исходников
|
||||||
|
|
||||||
Published-структура ниже описывает primary FSD entrypoints. Исторические каталоги `api/`, `context/`,
|
|
||||||
`hooks/`, `components/` ещё присутствуют в кодовой базе для ещё не мигрированных доменов.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/frontend/src/
|
apps/frontend/src/
|
||||||
├── main.tsx # Точка входа
|
├── main.tsx # Точка входа
|
||||||
@ -34,60 +31,46 @@ apps/frontend/src/
|
|||||||
│ └── layouts/
|
│ └── layouts/
|
||||||
│ ├── AppLayout.tsx # Шапка + <Outlet/>
|
│ ├── AppLayout.tsx # Шапка + <Outlet/>
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── shared/
|
|
||||||
│ ├── api/ # shared API client, response types, generated OpenAPI types
|
|
||||||
│ └── ui/ # shared UI primitives without domain logic
|
|
||||||
├── entities/ # FSD business entities
|
|
||||||
│ ├── session/ # Auth/session (api, model)
|
|
||||||
│ ├── search/ # Market search query slice
|
|
||||||
│ ├── stock/ # Акции (api, model)
|
|
||||||
│ ├── bond/ # Облигации (api, model)
|
|
||||||
│ ├── portfolio/ # Портфели (api, model)
|
|
||||||
│ ├── broker-account/ # Брокерский счёт (api, model, ui)
|
|
||||||
│ ├── broker-position/ # Брокерская позиция (api, model)
|
|
||||||
│ └── broker-operation/ # Брокерская операция (api, model)
|
|
||||||
├── widgets/ # FSD compositional widgets
|
|
||||||
│ ├── search-bar/ # Поиск инструментов
|
|
||||||
│ ├── price-chart/ # График цены
|
|
||||||
│ ├── stock-details/ # Детальная карточка акции
|
|
||||||
│ ├── bond-details/ # Детальная карточка облигации
|
|
||||||
│ ├── dividends-table/ # Таблица дивидендов
|
|
||||||
│ ├── broker-account-card/ # Карточка брокерского счёта
|
|
||||||
│ ├── broker-accounts-summary/ # Сводка брокерских счетов
|
|
||||||
│ ├── broker-allocation-chart/ # График распределения
|
|
||||||
│ ├── broker-operations-table/ # Таблица операций
|
|
||||||
│ ├── portfolio-card/ # Карточка портфеля
|
|
||||||
│ ├── portfolio-form/ # Форма портфеля
|
|
||||||
│ ├── portfolio-summary/ # Сводка портфеля
|
|
||||||
│ ├── portfolio-analytics/ # Метрики портфеля
|
|
||||||
│ ├── share-positions-table/ # Таблица позиций-акций
|
|
||||||
│ └── bond-positions-table/ # Таблица позиций-облигаций
|
|
||||||
├── pages/ # FSD page entrypoints
|
|
||||||
│ ├── home/ # Главная
|
|
||||||
│ ├── stock/ # Страница акции
|
|
||||||
│ ├── bond/ # Страница облигации
|
|
||||||
│ ├── LoginPage.tsx # Вход (не мигрирован)
|
|
||||||
│ ├── RegisterPage.tsx # Регистрация (не мигрирован)
|
|
||||||
│ ├── ProfilePage.tsx # Профиль (не мигрирован)
|
|
||||||
│ ├── portfolios/ # Список и детальная портфеля (не мигрирован)
|
|
||||||
│ ├── screener/ # Скринер (не мигрирован)
|
|
||||||
│ ├── broker-accounts/ # FSD: page entrypoint
|
|
||||||
│ ├── broker-account/ # FSD: page entrypoint
|
|
||||||
│ ├── broker-positions/ # FSD: page entrypoint
|
|
||||||
│ └── broker-operations/ # FSD: page entrypoint
|
|
||||||
├── api/
|
├── api/
|
||||||
│ └── screener.ts # Screener API helpers (не мигрирован)
|
│ └── screener.ts # Screener API helpers (не мигрирован)
|
||||||
├── hooks/
|
├── hooks/
|
||||||
│ └── useScreener.ts # Screener hook (не мигрирован)
|
│ └── useScreener.ts # Screener hook (не мигрирован)
|
||||||
├── components/
|
├── components/
|
||||||
│ └── screener/ # Screener UI (не мигрирован)
|
│ └── screener/ # Screener UI (не мигрирован)
|
||||||
|
├── entities/ # FSD business entities
|
||||||
|
│ ├── session/ # Auth/session (api, model)
|
||||||
|
│ ├── stock/ # Акции (api, model)
|
||||||
|
│ ├── bond/ # Облигации (api, model)
|
||||||
|
│ ├── portfolio/ # Портфели (api, model)
|
||||||
|
│ └── search/ # Поиск (model)
|
||||||
|
├── widgets/ # FSD compositional widgets
|
||||||
|
│ ├── stock-details/ # Детальная карточка акции
|
||||||
|
│ ├── bond-details/ # Детальная карточка облигации
|
||||||
|
│ ├── price-chart/ # График цены
|
||||||
|
│ ├── dividends-table/ # Таблица дивидендов
|
||||||
|
│ ├── search-bar/ # Поисковая строка
|
||||||
|
│ ├── portfolio-card/ # Карточка портфеля
|
||||||
|
│ ├── portfolio-form/ # Форма портфеля
|
||||||
|
│ ├── portfolio-summary/ # Сводка портфеля (график + стоимость)
|
||||||
|
│ ├── portfolio-analytics/ # Метрики портфеля
|
||||||
|
│ ├── share-positions-table/ # Таблица позиций-акций
|
||||||
|
│ └── bond-positions-table/ # Таблица позиций-облигаций
|
||||||
|
├── pages/ # FSD page entrypoints
|
||||||
|
│ ├── home/ # Главная
|
||||||
|
│ ├── stock/ # Страница акции
|
||||||
|
│ ├── bond/ # Страница облигации
|
||||||
|
│ ├── login/ # Вход
|
||||||
|
│ ├── register/ # Регистрация
|
||||||
|
│ ├── profile/ # Профиль
|
||||||
|
│ ├── portfolios/ # Список и детальная портфеля
|
||||||
|
│ ├── screener/ # Скринер (не мигрирован)
|
||||||
|
│ └── broker-*/ # Брокерские страницы
|
||||||
├── test/
|
├── test/
|
||||||
│ ├── factories.ts # Фабрики тестовых данных
|
│ ├── factories.ts # Фабрики тестовых данных
|
||||||
│ ├── handlers.ts # MSW handlers
|
│ ├── handlers.ts # MSW handlers
|
||||||
│ ├── server.ts # MSW server
|
│ ├── server.ts # MSW server
|
||||||
│ ├── test-utils.tsx # Обёртка рендера
|
│ ├── test-utils.tsx # Обёртка рендера
|
||||||
│ ├── setup.ts # Настройка jsdom
|
│ └── setup.ts # Настройка jsdom
|
||||||
│ └── README.md
|
|
||||||
└── styles.css
|
└── styles.css
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -113,9 +96,6 @@ apps/frontend/src/
|
|||||||
| `bond` | getBond, getBondCandles | useBond, useBondCandles | — |
|
| `bond` | getBond, getBondCandles | useBond, useBondCandles | — |
|
||||||
| `portfolio` | CRUD портфелей и позиций, аналитика | usePortfolio, usePortfolios, usePortfolioAnalytics, usePortfolioMutations, usePositionMutations | — |
|
| `portfolio` | CRUD портфелей и позиций, аналитика | usePortfolio, usePortfolios, usePortfolioAnalytics, usePortfolioMutations, usePositionMutations | — |
|
||||||
| `search` | — | useSearch | — |
|
| `search` | — | useSearch | — |
|
||||||
| `broker-account` | API брокерских счетов | useBrokerAccounts, useBrokerAccountPortfolios | BrokerAccountLayout |
|
|
||||||
| `broker-position` | API брокерских позиций | useBrokerPositions | — |
|
|
||||||
| `broker-operation` | API брокерских операций | useBrokerOperations | — |
|
|
||||||
|
|
||||||
Каждая сущность имеет barrel-файл `index.ts`, реэкспортирующий публичное API.
|
Каждая сущность имеет barrel-файл `index.ts`, реэкспортирующий публичное API.
|
||||||
|
|
||||||
@ -123,19 +103,15 @@ apps/frontend/src/
|
|||||||
|
|
||||||
Композиционные блоки UI. Каждый виджет — директория с `index.ts` и `ui/`:
|
Композиционные блоки UI. Каждый виджет — директория с `index.ts` и `ui/`:
|
||||||
|
|
||||||
- `search-bar` — поиск с автодополнением
|
|
||||||
- `price-chart` — график цены (lightweight-charts)
|
|
||||||
- `stock-details`, `bond-details` — детальные карточки бумаг
|
- `stock-details`, `bond-details` — детальные карточки бумаг
|
||||||
- `dividends-table` — таблица дивидендов
|
- `price-chart`, `dividends-table` — графики и таблицы
|
||||||
- `broker-*-*` — UI брокерского домена
|
- `search-bar` — поиск с автодополнением
|
||||||
- `portfolio-card`, `portfolio-form`, `portfolio-summary`, `portfolio-analytics` — UI портфелей
|
- `portfolio-card`, `portfolio-form`, `portfolio-summary`, `portfolio-analytics` — UI портфелей
|
||||||
- `share-positions-table`, `bond-positions-table` — таблицы позиций
|
- `share-positions-table`, `bond-positions-table` — таблицы позиций
|
||||||
|
|
||||||
### pages
|
### pages
|
||||||
|
|
||||||
- `pages/home`, `pages/stock`, `pages/bond` — FSD page entrypoints для market pages
|
Страницы-точки входа. FSD-страницы мигрированы: `home/`, `stock/`, `bond/`, `broker-*`. Временно не мигрированы: `portfolios/`, `screener/`, `LoginPage.tsx`, `RegisterPage.tsx`, `ProfilePage.tsx`.
|
||||||
- `pages/broker-*` — FSD page entrypoints для брокерского домена
|
|
||||||
- Временно не мигрированы: `portfolios/`, `screener/`, `LoginPage.tsx`, `RegisterPage.tsx`, `ProfilePage.tsx`
|
|
||||||
|
|
||||||
### Не мигрировано
|
### Не мигрировано
|
||||||
|
|
||||||
@ -146,6 +122,4 @@ apps/frontend/src/
|
|||||||
- `components/screener/` — FilterPanel, FilterPanelBond, FilterPanelShare, ScreenerTable
|
- `components/screener/` — FilterPanel, FilterPanelBond, FilterPanelShare, ScreenerTable
|
||||||
- `pages/screener/ScreenerPage.tsx` — страница
|
- `pages/screener/ScreenerPage.tsx` — страница
|
||||||
|
|
||||||
Страницы аутентификации (`LoginPage`, `RegisterPage`, `ProfilePage`) — плоские, но используют FSD-импорты из `entities/session`.
|
|
||||||
|
|
||||||
ESLint-правила на границы импортов FSD пока не введены.
|
ESLint-правила на границы импортов FSD пока не введены.
|
||||||
|
|||||||
@ -1,32 +1,20 @@
|
|||||||
# Маршруты
|
# Маршруты
|
||||||
|
|
||||||
Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRoutes.tsx`.
|
Определены в `apps/frontend/src/routes.tsx`.
|
||||||
|
|
||||||
| Path | Component | Доступ | Описание |
|
| Path | Component | Доступ | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/` | `HomePage` from `pages/home` | Public | Главная страница |
|
| `/` | `HomePage` | Public | Главная страница |
|
||||||
| `/stocks/:secid` | `StockPage` from `pages/stock` | Public | Страница акции |
|
| `/stocks/:secid` | `StockPage` | Public | Страница акции |
|
||||||
| `/bonds/:secid` | `BondPage` from `pages/bond` | Public | Страница облигации |
|
| `/bonds/:secid` | `BondPage` | Public | Страница облигации |
|
||||||
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
|
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
|
||||||
| `/login` | `LoginPage` | Public | Вход |
|
| `/login` | `LoginPage` | Public | Вход |
|
||||||
| `/register` | `RegisterPage` | Public | Регистрация |
|
| `/register` | `RegisterPage` | Public | Регистрация |
|
||||||
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
|
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
|
||||||
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
|
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
|
||||||
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
|
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
|
||||||
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
|
|
||||||
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
|
|
||||||
|
|
||||||
Все page entrypoints живут в FSD-слоях:
|
Все страницы обёрнуты в `Layout`, который содержит:
|
||||||
|
|
||||||
- `pages/home`
|
|
||||||
- `pages/stock`
|
|
||||||
- `pages/bond`
|
|
||||||
- `pages/broker-accounts`
|
|
||||||
- `pages/broker-account`
|
|
||||||
- `pages/broker-positions`
|
|
||||||
- `pages/broker-operations`
|
|
||||||
|
|
||||||
Все страницы обёрнуты в `AppLayout`, который содержит:
|
|
||||||
|
|
||||||
- Шапку с логотипом (ссылка на `/`) и `SearchBar`
|
- Шапку с логотипом (ссылка на `/`) и `SearchBar`
|
||||||
- `<main>` с максимальной шириной 1200px
|
- `<main>` с максимальной шириной 1200px
|
||||||
@ -36,7 +24,7 @@ Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRou
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||||
@ -67,27 +55,6 @@ Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRou
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/broker"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker/:accountId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<BrokerAccountOverviewPage />} />
|
|
||||||
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
|
|
||||||
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
|
|
||||||
<Route path="operations" element={<BrokerOperationsPage />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
```
|
```
|
||||||
|
|||||||
68
apps/frontend/src/api/auth.test.ts
Normal file
68
apps/frontend/src/api/auth.test.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { setAccessToken, getAccessToken } from './client';
|
||||||
|
import { login, register, refresh, logout, getMe, updateProfile } from './auth';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setAccessToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await login('user@test.com', 'password');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid credentials', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(
|
||||||
|
`${API}/auth/login`,
|
||||||
|
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await register('new@test.com', 'password', 'New User');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await refresh();
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('clears access token', async () => {
|
||||||
|
setAccessToken('test-token');
|
||||||
|
await logout();
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMe', () => {
|
||||||
|
it('returns current user', async () => {
|
||||||
|
const result = await getMe();
|
||||||
|
expect(result.email).toBe('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('updates and returns user', async () => {
|
||||||
|
const result = await updateProfile({ name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/frontend/src/api/auth.ts
Normal file
8
apps/frontend/src/api/auth.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
refresh,
|
||||||
|
logout,
|
||||||
|
getMe,
|
||||||
|
updateProfile,
|
||||||
|
} from '../entities/session/api/sessionApi';
|
||||||
71
apps/frontend/src/api/broker.test.ts
Normal file
71
apps/frontend/src/api/broker.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getBrokerOperations, getBrokerPositions } from './broker';
|
||||||
|
|
||||||
|
describe('broker api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes operations query parameters', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes operations query parameters including operationTypes', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getBrokerOperations('acc-1', {
|
||||||
|
cursor: 'c1',
|
||||||
|
limit: 10,
|
||||||
|
operationTypes: 'OPERATION_TYPE_COUPON',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
|
||||||
|
),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes positions query parameters', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/frontend/src/api/broker.ts
Normal file
66
apps/frontend/src/api/broker.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { request } from './client';
|
||||||
|
import type {
|
||||||
|
ApiResponseMeta,
|
||||||
|
BrokerAccount,
|
||||||
|
BrokerOperationsPage,
|
||||||
|
BrokerPortfolio,
|
||||||
|
BrokerPositionsPage,
|
||||||
|
} from './responses';
|
||||||
|
|
||||||
|
export type BrokerOperationQuery = {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
instrumentId?: string;
|
||||||
|
operationTypes?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBrokerAccounts(): Promise<{
|
||||||
|
data: BrokerAccount[];
|
||||||
|
meta: ApiResponseMeta;
|
||||||
|
}> {
|
||||||
|
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerPortfolio(accountId: string): Promise<{
|
||||||
|
data: BrokerPortfolio;
|
||||||
|
meta: ApiResponseMeta;
|
||||||
|
}> {
|
||||||
|
return request<BrokerPortfolio>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperations(
|
||||||
|
accountId: string,
|
||||||
|
query: BrokerOperationQuery = {},
|
||||||
|
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerOperationsPage>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
|
||||||
|
{
|
||||||
|
from: query.from,
|
||||||
|
to: query.to,
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
|
instrumentId: query.instrumentId,
|
||||||
|
operationTypes: query.operationTypes,
|
||||||
|
state: query.state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerPositions(
|
||||||
|
accountId: string,
|
||||||
|
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||||
|
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerPositionsPage>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
||||||
|
{
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
|
type: query.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/frontend/src/api/client.test.ts
Normal file
153
apps/frontend/src/api/client.test.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setAccessToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request', () => {
|
||||||
|
it('makes GET request and returns data', async () => {
|
||||||
|
const result = await request<{ status: string; timestamp: string; uptime: number }>(
|
||||||
|
'/api/v1/health',
|
||||||
|
);
|
||||||
|
expect(result.data.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the single API envelope shape documented by Swagger', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-single-envelope`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
data: { ok: true },
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: { ok: true },
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Authorization header when token is set', async () => {
|
||||||
|
setAccessToken('test-token');
|
||||||
|
let capturedAuth: string | null = null;
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-auth`, ({ request }) => {
|
||||||
|
capturedAuth = request.headers.get('Authorization');
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await request('/api/v1/test-auth');
|
||||||
|
expect(capturedAuth).toBe('Bearer test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on 401 and succeeds after refresh', async () => {
|
||||||
|
setAccessToken('expired-token');
|
||||||
|
let attempts = 0;
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-retry`, ({ request }) => {
|
||||||
|
attempts++;
|
||||||
|
const auth = request.headers.get('Authorization');
|
||||||
|
if (auth === 'Bearer expired-token') {
|
||||||
|
return new HttpResponse(null, { status: 401 });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.post(`${API}/auth/refresh`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
user: { id: 1, email: 'user@test.com', name: null, role: 'user' },
|
||||||
|
accessToken: 'new-token',
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await request<{ ok: boolean }>('/api/v1/test-retry');
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
expect(result.data).toEqual({ ok: true });
|
||||||
|
expect(getAccessToken()).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on persistent 401 and clears token', async () => {
|
||||||
|
setAccessToken('expired-token');
|
||||||
|
let unauthorizedCalled = false;
|
||||||
|
setOnUnauthorized(() => {
|
||||||
|
unauthorizedCalled = true;
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
|
||||||
|
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
|
||||||
|
);
|
||||||
|
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла');
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
expect(unauthorizedCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on non-ok response with status text', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
`${API}/test-error`,
|
||||||
|
() => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends JSON body for POST requests', async () => {
|
||||||
|
let capturedBody: string | null = null;
|
||||||
|
server.use(
|
||||||
|
http.post(`${API}/test-post`, async ({ request }) => {
|
||||||
|
capturedBody = await request.text();
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } });
|
||||||
|
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send auth header when skipAuth is true', async () => {
|
||||||
|
setAccessToken('test-token');
|
||||||
|
let capturedAuth: string | null = null;
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-skip`, ({ request }) => {
|
||||||
|
capturedAuth = request.headers.get('Authorization');
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await request('/api/v1/test-skip', undefined, { skipAuth: true });
|
||||||
|
expect(capturedAuth).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets query params correctly', async () => {
|
||||||
|
let capturedUrl = '';
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/test-params`, ({ request }) => {
|
||||||
|
capturedUrl = request.url;
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await request('/api/v1/test-params', { q: 'sber', type: 'share' });
|
||||||
|
expect(capturedUrl).toContain('q=sber');
|
||||||
|
expect(capturedUrl).toContain('type=share');
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/frontend/src/api/client.ts
Normal file
8
apps/frontend/src/api/client.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
request,
|
||||||
|
setAccessToken,
|
||||||
|
getAccessToken,
|
||||||
|
setOnUnauthorized,
|
||||||
|
getHealth,
|
||||||
|
searchSecurities,
|
||||||
|
} from '../shared/api/client';
|
||||||
11
apps/frontend/src/api/portfolio.ts
Normal file
11
apps/frontend/src/api/portfolio.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
getPortfolios,
|
||||||
|
getPortfolio,
|
||||||
|
createPortfolio,
|
||||||
|
updatePortfolio,
|
||||||
|
deletePortfolio,
|
||||||
|
addPosition,
|
||||||
|
updatePosition,
|
||||||
|
removePosition,
|
||||||
|
getPortfolioAnalytics,
|
||||||
|
} from '../entities/portfolio/api/portfolioApi';
|
||||||
32
apps/frontend/src/api/responses.ts
Normal file
32
apps/frontend/src/api/responses.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type {
|
||||||
|
ApiResponseMeta,
|
||||||
|
ApiEnvelope,
|
||||||
|
StockMarketData,
|
||||||
|
ShareResponse,
|
||||||
|
DividendItem,
|
||||||
|
ShareHistoryItem,
|
||||||
|
BondMarketData,
|
||||||
|
BondResponse,
|
||||||
|
BondHistoryItem,
|
||||||
|
CandleItem,
|
||||||
|
SearchResultItem,
|
||||||
|
HealthResponse,
|
||||||
|
UserResponse,
|
||||||
|
AuthResponse,
|
||||||
|
Portfolio,
|
||||||
|
PositionWithPrice,
|
||||||
|
PortfolioDetail,
|
||||||
|
Position,
|
||||||
|
PortfolioSummary,
|
||||||
|
AnalyticsResponse,
|
||||||
|
ScreenerItem,
|
||||||
|
ScreenerResult,
|
||||||
|
BrokerMoney,
|
||||||
|
BrokerAccount,
|
||||||
|
BrokerPosition,
|
||||||
|
BrokerPortfolio,
|
||||||
|
BrokerOperationCategory,
|
||||||
|
BrokerOperation,
|
||||||
|
BrokerOperationsPage,
|
||||||
|
BrokerPositionsPage,
|
||||||
|
} from '../shared/api/responses';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from './client';
|
||||||
import type { ScreenerResult } from '@/shared/api/responses';
|
import type { ScreenerResult } from './responses';
|
||||||
|
|
||||||
export interface ScreenerQuery {
|
export interface ScreenerQuery {
|
||||||
type: 'share' | 'bond';
|
type: 'share' | 'bond';
|
||||||
|
|||||||
1
apps/frontend/src/api/types.ts
Normal file
1
apps/frontend/src/api/types.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from '../shared/api/types';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { SearchBar } from '@/widgets/search-bar';
|
import { SearchBar } from '@/components/SearchBar';
|
||||||
import { useSession } from '@/entities/session/model/useSession';
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { AppLayout } from '../layouts/AppLayout';
|
import { AppLayout } from '../layouts/AppLayout';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
import { HomePage } from '@/pages/home';
|
import { HomePage } from '@/pages/HomePage';
|
||||||
import { StockPage } from '@/pages/stock';
|
import { StockPage } from '@/pages/StockPage';
|
||||||
import { BondPage } from '@/pages/bond';
|
import { BondPage } from '@/pages/BondPage';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { RegisterPage } from '@/pages/RegisterPage';
|
import { RegisterPage } from '@/pages/RegisterPage';
|
||||||
import { ProfilePage } from '@/pages/ProfilePage';
|
import { ProfilePage } from '@/pages/ProfilePage';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { BondDetails } from './BondDetails';
|
import { BondDetails } from './BondDetails';
|
||||||
import { createMockBond } from '@/test/factories';
|
import { createMockBond } from '../test/factories';
|
||||||
|
|
||||||
describe('BondDetails', () => {
|
describe('BondDetails', () => {
|
||||||
it('renders bond details', () => {
|
it('renders bond details', () => {
|
||||||
@ -63,62 +63,9 @@ describe('BondDetails', () => {
|
|||||||
expect(screen.getByText('—')).toBeInTheDocument();
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves unit suffixes for missing values', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
price: null,
|
|
||||||
couponValue: null,
|
|
||||||
couponPercent: 7.5,
|
|
||||||
accruedInt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('—%')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('— ₽')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows bond type', () => {
|
it('shows bond type', () => {
|
||||||
const bond = createMockBond();
|
const bond = createMockBond();
|
||||||
render(<BondDetails bond={bond} />);
|
render(<BondDetails bond={bond} />);
|
||||||
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
|
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves percent suffix when price is missing', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
price: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('—%')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves currency and percentage formatting when coupon value is missing', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
couponValue: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('Купон')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves currency suffix when accrued interest is missing', () => {
|
|
||||||
const bond = createMockBond({
|
|
||||||
marketData: {
|
|
||||||
...createMockBond().marketData,
|
|
||||||
accruedInt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<BondDetails bond={bond} />);
|
|
||||||
expect(screen.getByText('НКД')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('— ₽')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
57
apps/frontend/src/components/Layout.test.tsx
Normal file
57
apps/frontend/src/components/Layout.test.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { Layout } from './Layout';
|
||||||
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
describe('Layout', () => {
|
||||||
|
it('renders logo and search bar', async () => {
|
||||||
|
renderWithProviders(<Layout />);
|
||||||
|
expect(await screen.findByText('MoexVibe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows login link when not authenticated', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderWithProviders(<Layout />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Войти')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows user name and logout when authenticated', async () => {
|
||||||
|
renderWithProviders(<Layout />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Test User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Выйти')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows email when user has no name', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API}/auth/refresh`, () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
email: 'user@test.com',
|
||||||
|
name: null,
|
||||||
|
role: 'user',
|
||||||
|
},
|
||||||
|
accessToken: 'mock',
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithProviders(<Layout />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('user@test.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/components/Layout.tsx
Normal file
1
apps/frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AppLayout as Layout } from '../app/layouts/AppLayout';
|
||||||
36
apps/frontend/src/components/ProtectedRoute.test.tsx
Normal file
36
apps/frontend/src/components/ProtectedRoute.test.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
it('renders children when authenticated', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div data-testid="protected-content">Secret</div>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByTestId('protected-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state initially then redirects when not authenticated', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div data-testid="protected-content">Secret</div>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{ route: '/profile' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
1
apps/frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ProtectedRoute } from '../app/routing/ProtectedRoute';
|
||||||
@ -4,16 +4,14 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { server } from '@/test/server';
|
import { server } from '../test/server';
|
||||||
import { SearchBar } from '@/widgets/search-bar';
|
import { SearchBar } from './SearchBar';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
function renderSearchBar() {
|
function renderSearchBar() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
@ -26,16 +24,13 @@ function renderSearchBar() {
|
|||||||
describe('SearchBar', () => {
|
describe('SearchBar', () => {
|
||||||
it('renders search input', () => {
|
it('renders search input', () => {
|
||||||
renderSearchBar();
|
renderSearchBar();
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows dropdown on focus', async () => {
|
it('shows dropdown on focus', async () => {
|
||||||
renderSearchBar();
|
renderSearchBar();
|
||||||
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
await userEvent.type(input, 'sber');
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -45,9 +40,7 @@ describe('SearchBar', () => {
|
|||||||
server.use(http.get(`${API}/securities/search`, () => new Promise(() => {})));
|
server.use(http.get(`${API}/securities/search`, () => new Promise(() => {})));
|
||||||
renderSearchBar();
|
renderSearchBar();
|
||||||
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
await userEvent.type(input, 'sber');
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -55,17 +48,15 @@ describe('SearchBar', () => {
|
|||||||
|
|
||||||
it('shows no results message', async () => {
|
it('shows no results message', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () => {
|
||||||
HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
}),
|
});
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
renderSearchBar();
|
renderSearchBar();
|
||||||
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
await userEvent.type(input, 'zzzzz');
|
await userEvent.type(input, 'zzzzz');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
|
expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -74,15 +65,11 @@ describe('SearchBar', () => {
|
|||||||
it('hides dropdown when clicking outside', async () => {
|
it('hides dropdown when clicking outside', async () => {
|
||||||
renderSearchBar();
|
renderSearchBar();
|
||||||
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
await userEvent.type(input, 'sber');
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await userEvent.click(document.body);
|
await userEvent.click(document.body);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
|
expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSearch } from '@/entities/search';
|
import { useSearch } from '../hooks/useSearch';
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@ -11,7 +11,6 @@ export function SearchBar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => setDebounced(query), 300);
|
const id = setTimeout(() => setDebounced(query), 300);
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
@ -19,13 +18,9 @@ export function SearchBar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
1
apps/frontend/src/components/SkeletonBlock.tsx
Normal file
1
apps/frontend/src/components/SkeletonBlock.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { SkeletonBlock } from '../shared/ui/SkeletonBlock';
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { StockDetails } from './StockDetails';
|
import { StockDetails } from './StockDetails';
|
||||||
import { createMockShare } from '@/test/factories';
|
import { createMockShare } from '../test/factories';
|
||||||
|
|
||||||
describe('StockDetails', () => {
|
describe('StockDetails', () => {
|
||||||
it('renders stock details', () => {
|
it('renders stock details', () => {
|
||||||
@ -60,17 +60,4 @@ describe('StockDetails', () => {
|
|||||||
render(<StockDetails stock={stock} />);
|
render(<StockDetails stock={stock} />);
|
||||||
expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
|
expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to zero change when change data is missing', () => {
|
|
||||||
const stock = createMockShare({
|
|
||||||
marketData: {
|
|
||||||
...createMockShare().marketData,
|
|
||||||
change: null,
|
|
||||||
changePercent: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
render(<StockDetails stock={stock} />);
|
|
||||||
expect(screen.getByText('289,50')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('+0.00 (0.00%)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@ -13,10 +13,7 @@ const rowStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
export function StockDetails({ stock }: StockDetailsProps) {
|
export function StockDetails({ stock }: StockDetailsProps) {
|
||||||
const md = stock.marketData;
|
const md = stock.marketData;
|
||||||
const change = md.change ?? 0;
|
const isPositive = (md.change ?? 0) >= 0;
|
||||||
const changePercent = md.changePercent ?? 0;
|
|
||||||
const isPositive = change >= 0;
|
|
||||||
const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -44,7 +41,8 @@ export function StockDetails({ stock }: StockDetailsProps) {
|
|||||||
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
|
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{changeLabel}
|
{isPositive ? '+' : ''}
|
||||||
|
{(md.change ?? 0).toFixed(2)} ({(md.changePercent ?? 0).toFixed(2)}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
1
apps/frontend/src/components/TableSkeleton.tsx
Normal file
1
apps/frontend/src/components/TableSkeleton.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { TableSkeleton } from '../shared/ui/TableSkeleton';
|
||||||
73
apps/frontend/src/context/AuthContext.test.tsx
Normal file
73
apps/frontend/src/context/AuthContext.test.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { AuthContext, AuthProvider } from './AuthContext';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>{ui}</AuthProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestConsumer() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) return <div>no context</div>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
||||||
|
<span data-testid="email">{ctx.user?.email ?? ''}</span>
|
||||||
|
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
|
||||||
|
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
|
||||||
|
<button onClick={() => ctx.logout()}>logout</button>
|
||||||
|
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthContext', () => {
|
||||||
|
it('starts unauthenticated when refresh fails', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores session on mount when refresh succeeds', async () => {
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
|
||||||
|
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after login', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'login' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after logout', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'logout' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
5
apps/frontend/src/context/AuthContext.tsx
Normal file
5
apps/frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
SessionContext as AuthContext,
|
||||||
|
type SessionContextValue as AuthContextValue,
|
||||||
|
} from '@/entities/session/model/sessionContext';
|
||||||
|
export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider';
|
||||||
@ -1,30 +1 @@
|
|||||||
import { request } from '@/shared/api/client';
|
export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';
|
||||||
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
instrumentId?: string;
|
|
||||||
operationTypes?: string;
|
|
||||||
state?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getBrokerOperations(
|
|
||||||
accountId: string,
|
|
||||||
query: BrokerOperationQuery = {},
|
|
||||||
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
|
|
||||||
return request<BrokerOperationsPage>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
|
|
||||||
{
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
cursor: query.cursor,
|
|
||||||
limit: query.limit ? String(query.limit) : undefined,
|
|
||||||
instrumentId: query.instrumentId,
|
|
||||||
operationTypes: query.operationTypes,
|
|
||||||
state: query.state,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { getBrokerPositions } from '../../../api/broker';
|
||||||
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export function getBrokerPositions(
|
export { getBrokerPositions };
|
||||||
accountId: string,
|
|
||||||
query: { cursor?: string; limit?: number; type?: string } = {},
|
|
||||||
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
|
||||||
return request<BrokerPositionsPage>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
|
||||||
{
|
|
||||||
cursor: query.cursor,
|
|
||||||
limit: query.limit ? String(query.limit) : undefined,
|
|
||||||
type: query.type,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export { useSearch } from './model/useSearch';
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { SessionProvider } from '../../../app/providers/SessionProvider';
|
import { AuthProvider } from '../../../context/AuthContext';
|
||||||
import { useSession } from './useSession';
|
import { useSession } from './useSession';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ function createWrapper() {
|
|||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
63
apps/frontend/src/hooks/useAuth.test.tsx
Normal file
63
apps/frontend/src/hooks/useAuth.test.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from '../context/AuthContext';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAuth', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns auth context with user after mount', async () => {
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.user?.email).toBe('user@test.com');
|
||||||
|
expect(result.current.accessToken).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides login function', async () => {
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.login).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides logout function', async () => {
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.logout).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides register function', async () => {
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.register).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when used without AuthProvider', () => {
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useAuth(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/hooks/useAuth.ts
Normal file
1
apps/frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useSession as useAuth } from '../entities/session/model/useSession';
|
||||||
1
apps/frontend/src/hooks/useBond.ts
Normal file
1
apps/frontend/src/hooks/useBond.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBond } from '../entities/bond/model/useBond';
|
||||||
1
apps/frontend/src/hooks/useBondCandles.ts
Normal file
1
apps/frontend/src/hooks/useBondCandles.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBondCandles } from '../entities/bond/model/useBondCandles';
|
||||||
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal file
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi';
|
||||||
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
||||||
|
|
||||||
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
|
getBrokerPortfolio: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper(queryClient?: QueryClient) {
|
||||||
|
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccount(id: string): BrokerAccount {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'brokerage',
|
||||||
|
name: id,
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: null,
|
||||||
|
accessLevel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPortfolio(id: string): BrokerPortfolio {
|
||||||
|
return {
|
||||||
|
account: createAccount(id),
|
||||||
|
positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 },
|
||||||
|
totals: {
|
||||||
|
shares: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||||
|
bonds: null,
|
||||||
|
etf: null,
|
||||||
|
currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 },
|
||||||
|
futures: null,
|
||||||
|
options: null,
|
||||||
|
structuredProducts: null,
|
||||||
|
dfa: null,
|
||||||
|
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 },
|
||||||
|
},
|
||||||
|
yields: {
|
||||||
|
expectedPercent: 3,
|
||||||
|
daily: { currency: 'RUB', units: '0', nano: 0, value: 10 },
|
||||||
|
dailyPercent: 1,
|
||||||
|
},
|
||||||
|
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }],
|
||||||
|
blockedCash: [],
|
||||||
|
asOf: '2026-06-19T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBrokerAccountPortfolios', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps account-to-query mapping regardless of completion order', async () => {
|
||||||
|
const first = createDeferred<{
|
||||||
|
data: BrokerPortfolio;
|
||||||
|
meta: { fromCache: false; cachedAt: null };
|
||||||
|
}>();
|
||||||
|
const second = createDeferred<{
|
||||||
|
data: BrokerPortfolio;
|
||||||
|
meta: { fromCache: false; cachedAt: null };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => {
|
||||||
|
if (accountId === 'acc-1') {
|
||||||
|
return first.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId === 'acc-2') {
|
||||||
|
return second.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected account ${accountId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = [createAccount('acc-1'), createAccount('acc-2')];
|
||||||
|
const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getBrokerPortfolio).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1');
|
||||||
|
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2');
|
||||||
|
|
||||||
|
second.resolve({
|
||||||
|
data: createPortfolio('acc-2'),
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2'));
|
||||||
|
expect(result.current[0].account.id).toBe('acc-1');
|
||||||
|
expect(result.current[0].query.data).toBeUndefined();
|
||||||
|
|
||||||
|
first.resolve({
|
||||||
|
data: createPortfolio('acc-1'),
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1'));
|
||||||
|
expect(result.current[1].query.data?.account.id).toBe('acc-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the same cache key as broker account overview page', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const cachedPortfolio = createPortfolio('acc-1');
|
||||||
|
queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), {
|
||||||
|
wrapper: createWrapper(queryClient),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio));
|
||||||
|
expect(getBrokerPortfolio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal file
1
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBrokerAccountPortfolios } from '../entities/broker-account';
|
||||||
41
apps/frontend/src/hooks/useBrokerAccounts.test.tsx
Normal file
41
apps/frontend/src/hooks/useBrokerAccounts.test.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
|
||||||
|
import { useBrokerAccounts } from './useBrokerAccounts';
|
||||||
|
|
||||||
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
|
getBrokerAccounts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBrokerAccounts', () => {
|
||||||
|
it('returns broker accounts from API', async () => {
|
||||||
|
vi.mocked(getBrokerAccounts).mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'acc-1',
|
||||||
|
type: 'brokerage',
|
||||||
|
name: 'Broker',
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: null,
|
||||||
|
accessLevel: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data?.[0].name).toBe('Broker');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/hooks/useBrokerAccounts.ts
Normal file
1
apps/frontend/src/hooks/useBrokerAccounts.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBrokerAccounts } from '../entities/broker-account';
|
||||||
1
apps/frontend/src/hooks/useBrokerOperations.ts
Normal file
1
apps/frontend/src/hooks/useBrokerOperations.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBrokerOperations } from '../entities/broker-operation';
|
||||||
1
apps/frontend/src/hooks/useBrokerPortfolio.ts
Normal file
1
apps/frontend/src/hooks/useBrokerPortfolio.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBrokerPortfolio } from '../entities/broker-account';
|
||||||
1
apps/frontend/src/hooks/useBrokerPositions.ts
Normal file
1
apps/frontend/src/hooks/useBrokerPositions.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useBrokerPositions } from '../entities/broker-position';
|
||||||
1
apps/frontend/src/hooks/usePortfolio.ts
Normal file
1
apps/frontend/src/hooks/usePortfolio.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePortfolio } from '../entities/portfolio/model/usePortfolio';
|
||||||
1
apps/frontend/src/hooks/usePortfolioAnalytics.ts
Normal file
1
apps/frontend/src/hooks/usePortfolioAnalytics.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics';
|
||||||
1
apps/frontend/src/hooks/usePortfolioMutations.ts
Normal file
1
apps/frontend/src/hooks/usePortfolioMutations.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations';
|
||||||
1
apps/frontend/src/hooks/usePortfolios.ts
Normal file
1
apps/frontend/src/hooks/usePortfolios.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePortfolios } from '../entities/portfolio/model/usePortfolios';
|
||||||
1
apps/frontend/src/hooks/usePositionMutations.ts
Normal file
1
apps/frontend/src/hooks/usePositionMutations.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { usePositionMutations } from '../entities/portfolio/model/usePositionMutations';
|
||||||
@ -2,15 +2,14 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { useSearch } from './useSearch';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { server } from '@/test/server';
|
|
||||||
import { useSearch } from '@/entities/search';
|
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
};
|
};
|
||||||
@ -19,24 +18,20 @@ function createWrapper() {
|
|||||||
describe('useSearch', () => {
|
describe('useSearch', () => {
|
||||||
it('does not fetch when query is empty', () => {
|
it('does not fetch when query is empty', () => {
|
||||||
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
|
||||||
|
|
||||||
expect(result.current.isFetching).toBe(false);
|
expect(result.current.isFetching).toBe(false);
|
||||||
expect(result.current.data).toBeUndefined();
|
expect(result.current.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not fetch when query is too short', () => {
|
it('does not fetch when query is too short', () => {
|
||||||
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
expect(result.current.data).toBeUndefined();
|
expect(result.current.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns search results for valid query', async () => {
|
it('returns search results for valid query', async () => {
|
||||||
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isSuccess).toBe(true);
|
expect(result.current.isSuccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.data).toBeDefined();
|
expect(result.current.data).toBeDefined();
|
||||||
expect(result.current.data?.length).toBeGreaterThan(0);
|
expect(result.current.data?.length).toBeGreaterThan(0);
|
||||||
expect(result.current.data?.[0].secid).toBe('SBER');
|
expect(result.current.data?.[0].secid).toBe('SBER');
|
||||||
@ -44,27 +39,22 @@ describe('useSearch', () => {
|
|||||||
|
|
||||||
it('returns empty array when no results', async () => {
|
it('returns empty array when no results', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () => {
|
||||||
HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
}),
|
});
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isSuccess).toBe(true);
|
expect(result.current.isSuccess).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.data).toEqual([]);
|
expect(result.current.data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error state on network failure', async () => {
|
it('returns error state on network failure', async () => {
|
||||||
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
|
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
|
||||||
|
|
||||||
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isError).toBe(true);
|
expect(result.current.isError).toBe(true);
|
||||||
});
|
});
|
||||||
@ -7,7 +7,6 @@ export function useSearch(query: string) {
|
|||||||
queryKey: ['securities', 'search', query],
|
queryKey: ['securities', 'search', query],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await searchSecurities(query);
|
const res = await searchSecurities(query);
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
enabled: query.length >= 2,
|
enabled: query.length >= 2,
|
||||||
1
apps/frontend/src/hooks/useStock.ts
Normal file
1
apps/frontend/src/hooks/useStock.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useStock } from '../entities/stock/model/useStock';
|
||||||
1
apps/frontend/src/hooks/useStockCandles.ts
Normal file
1
apps/frontend/src/hooks/useStockCandles.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useStockCandles } from '../entities/stock/model/useStockCandles';
|
||||||
1
apps/frontend/src/hooks/useStockDividends.ts
Normal file
1
apps/frontend/src/hooks/useStockDividends.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useStockDividends } from '../entities/stock/model/useStockDividends';
|
||||||
48
apps/frontend/src/pages/BondPage.test.tsx
Normal file
48
apps/frontend/src/pages/BondPage.test.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { BondPage } from './BondPage';
|
||||||
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderBondPage(secid = 'SU26238RMFS5') {
|
||||||
|
return renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||||
|
</Routes>,
|
||||||
|
{ route: `/bonds/${secid}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BondPage', () => {
|
||||||
|
it('shows loading state', () => {
|
||||||
|
server.use(http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})));
|
||||||
|
renderBondPage();
|
||||||
|
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bond details after loading', async () => {
|
||||||
|
renderBondPage();
|
||||||
|
expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders price chart', async () => {
|
||||||
|
renderBondPage();
|
||||||
|
expect(await screen.findByText('График цены')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state for not found', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })),
|
||||||
|
http.get(
|
||||||
|
`${API}/securities/bonds/:secid/candles`,
|
||||||
|
() => new HttpResponse(null, { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderBondPage('NOTFOUND');
|
||||||
|
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useBond, useBondCandles } from '@/entities/bond';
|
import { useBond, useBondCandles } from '../entities/bond';
|
||||||
import { BondDetails } from '@/widgets/bond-details';
|
import { BondDetails } from '../components/BondDetails';
|
||||||
import { PriceChart } from '@/widgets/price-chart';
|
import { PriceChart } from '../components/PriceChart';
|
||||||
|
|
||||||
export function BondPage() {
|
export function BondPage() {
|
||||||
const { secid } = useParams<{ secid: string }>();
|
const { secid } = useParams<{ secid: string }>();
|
||||||
25
apps/frontend/src/pages/HomePage.test.tsx
Normal file
25
apps/frontend/src/pages/HomePage.test.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { HomePage } from './HomePage';
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
it('renders welcome title', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hint text', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders data delay notice', () => {
|
||||||
|
render(<HomePage />);
|
||||||
|
expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/frontend/src/pages/StockPage.test.tsx
Normal file
73
apps/frontend/src/pages/StockPage.test.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../test/server';
|
||||||
|
import { StockPage } from './StockPage';
|
||||||
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderStockPage(secid = 'SBER') {
|
||||||
|
return renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||||
|
</Routes>,
|
||||||
|
{ route: `/stocks/${secid}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StockPage', () => {
|
||||||
|
it('shows loading state', () => {
|
||||||
|
server.use(http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})));
|
||||||
|
renderStockPage();
|
||||||
|
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stock details after loading', async () => {
|
||||||
|
renderStockPage();
|
||||||
|
expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders price chart', async () => {
|
||||||
|
renderStockPage();
|
||||||
|
expect(await screen.findByText('График цены')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dividends section', async () => {
|
||||||
|
renderStockPage();
|
||||||
|
expect(await screen.findByText('Дивиденды')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides dividends section when empty', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderStockPage();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state for not found', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })),
|
||||||
|
http.get(
|
||||||
|
`${API}/securities/shares/:secid/candles`,
|
||||||
|
() => new HttpResponse(null, { status: 404 }),
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
`${API}/securities/shares/:secid/dividends`,
|
||||||
|
() => new HttpResponse(null, { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderStockPage('NOTFOUND');
|
||||||
|
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/frontend/src/pages/StockPage.tsx
Normal file
65
apps/frontend/src/pages/StockPage.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useStock, useStockCandles, useStockDividends } from '../entities/stock';
|
||||||
|
import { StockDetails } from '../components/StockDetails';
|
||||||
|
import { PriceChart } from '../components/PriceChart';
|
||||||
|
|
||||||
|
export function StockPage() {
|
||||||
|
const { secid } = useParams<{ secid: string }>();
|
||||||
|
const { data: stock, isLoading, error } = useStock(secid!);
|
||||||
|
const till = new Date().toISOString().split('T')[0];
|
||||||
|
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
const { data: candles } = useStockCandles(secid!, '24h', from, till);
|
||||||
|
const { data: dividends } = useStockDividends(secid!);
|
||||||
|
|
||||||
|
if (isLoading) return <div>Загрузка...</div>;
|
||||||
|
if (error || !stock) return <div>Инструмент не найден</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<StockDetails stock={stock} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||||||
|
<PriceChart data={candles ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dividends && dividends.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #eee' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dividends.map((d, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: 8 }}>
|
||||||
|
{d.value.toFixed(2)} {d.currency}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
export { BondPage } from './ui/BondPage';
|
|
||||||
1
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAccountCard } from '../../widgets/broker-account-card';
|
||||||
5
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
5
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
BrokerAccountLayout,
|
||||||
|
useBrokerAccountContext,
|
||||||
|
type BrokerAccountContext,
|
||||||
|
} from '../../entities/broker-account/ui/BrokerAccountLayout';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAccountOverviewPage } from '../broker-account';
|
||||||
292
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal file
292
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { type ReactElement } from 'react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
import * as brokerAccountEntity from '../../entities/broker-account';
|
||||||
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
|
||||||
|
function renderPage(ui: ReactElement) {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccount(
|
||||||
|
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
|
||||||
|
): BrokerAccount {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
type: account.type ?? 'brokerage',
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z',
|
||||||
|
accessLevel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPortfolio(
|
||||||
|
account: BrokerAccount,
|
||||||
|
overrides: Partial<BrokerPortfolio> = {},
|
||||||
|
): BrokerPortfolio {
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 },
|
||||||
|
totals: {
|
||||||
|
shares: { currency: 'RUB', units: '0', nano: 0, value: 600 },
|
||||||
|
bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 },
|
||||||
|
etf: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||||
|
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||||
|
futures: null,
|
||||||
|
options: null,
|
||||||
|
structuredProducts: null,
|
||||||
|
dfa: null,
|
||||||
|
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 },
|
||||||
|
},
|
||||||
|
yields: {
|
||||||
|
expectedPercent: 8,
|
||||||
|
daily: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||||
|
dailyPercent: 11.11,
|
||||||
|
},
|
||||||
|
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }],
|
||||||
|
blockedCash: [],
|
||||||
|
asOf: '2026-06-19T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueryState(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BrokerAccountsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading, aggregate summary, daily result and two linked cards', () => {
|
||||||
|
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||||
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [broker, iis],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
|
{
|
||||||
|
account: broker,
|
||||||
|
query: createQueryState({ data: createPortfolio(broker) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account: iis,
|
||||||
|
query: createQueryState({
|
||||||
|
data: createPortfolio(iis, {
|
||||||
|
totals: {
|
||||||
|
shares: { currency: 'RUB', units: '0', nano: 0, value: 800 },
|
||||||
|
bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 },
|
||||||
|
etf: { currency: 'RUB', units: '0', nano: 0, value: 200 },
|
||||||
|
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||||
|
futures: null,
|
||||||
|
options: null,
|
||||||
|
structuredProducts: null,
|
||||||
|
dfa: null,
|
||||||
|
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 },
|
||||||
|
},
|
||||||
|
yields: {
|
||||||
|
expectedPercent: 12,
|
||||||
|
daily: { currency: 'RUB', units: '0', nano: 0, value: 140 },
|
||||||
|
dailyPercent: 9.59,
|
||||||
|
},
|
||||||
|
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/broker/acc-1',
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/broker/acc-2',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows human labels and opened date without exposing technical fields', () => {
|
||||||
|
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
||||||
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [broker, iis],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
|
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Брокерский счёт')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ИИС')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2);
|
||||||
|
expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('account one')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/broker/account%20one',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows page skeleton while accounts are loading', () => {
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isFetching: true,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty state when there are no accounts', () => {
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the summary as partial when one account portfolio is unavailable', () => {
|
||||||
|
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||||
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [broker, iis],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
|
{
|
||||||
|
account: iis,
|
||||||
|
query: createQueryState({ isError: true, error: new Error('boom') }),
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a local alert and retries only the failed account', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||||
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
const refetch = vi.fn();
|
||||||
|
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [broker, iis],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
|
{
|
||||||
|
account: iis,
|
||||||
|
query: createQueryState({ isError: true, error: new Error('boom'), refetch }),
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
const alert = screen.getByRole('alert');
|
||||||
|
expect(alert).toHaveTextContent('Не удалось загрузить данные счёта');
|
||||||
|
await user.click(
|
||||||
|
within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }),
|
||||||
|
);
|
||||||
|
expect(refetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps currencies separate in the overview summary', () => {
|
||||||
|
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
||||||
|
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
||||||
|
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
|
data: [broker, usd],
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
|
{
|
||||||
|
account: usd,
|
||||||
|
query: createQueryState({
|
||||||
|
data: createPortfolio(usd, {
|
||||||
|
totals: {
|
||||||
|
shares: { currency: 'USD', units: '0', nano: 0, value: 300 },
|
||||||
|
bonds: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
||||||
|
etf: null,
|
||||||
|
currencies: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
||||||
|
futures: null,
|
||||||
|
options: null,
|
||||||
|
structuredProducts: null,
|
||||||
|
dfa: null,
|
||||||
|
portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 },
|
||||||
|
},
|
||||||
|
yields: {
|
||||||
|
expectedPercent: 4,
|
||||||
|
daily: { currency: 'USD', units: '0', nano: 0, value: 20 },
|
||||||
|
dailyPercent: 4.16,
|
||||||
|
},
|
||||||
|
cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
|
const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' });
|
||||||
|
expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||||
|
expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
1
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAccountsPage } from '../broker-accounts';
|
||||||
1
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';
|
||||||
1
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart';
|
||||||
1
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart';
|
||||||
1
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerOperationsPage } from '../broker-operations';
|
||||||
1
apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerOperationsTable } from '../../widgets/broker-operations-table';
|
||||||
1173
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal file
1173
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
Normal file
1
apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BrokerPositionsPage } from '../broker-positions';
|
||||||
14
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal file
14
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export {
|
||||||
|
aggregateBrokerAccounts,
|
||||||
|
brokerAccountTypeLabel,
|
||||||
|
formatBrokerCurrencyValue,
|
||||||
|
formatBrokerDate,
|
||||||
|
formatBrokerMoney,
|
||||||
|
formatBrokerPercent,
|
||||||
|
formatBrokerSignedCurrencyValue,
|
||||||
|
formatBrokerSignedPercent,
|
||||||
|
type BrokerAccountsAggregate,
|
||||||
|
type BrokerCurrencyAllocationSummary,
|
||||||
|
type BrokerCurrencyCashSummary,
|
||||||
|
type BrokerCurrencyPortfolioSummary,
|
||||||
|
} from '../../entities/broker-account/model/brokerAccountsOverview';
|
||||||
5
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
5
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
buildBrokerAllocation,
|
||||||
|
type BrokerAllocationItem,
|
||||||
|
type BrokerAllocationKey,
|
||||||
|
} from '../../entities/broker-position';
|
||||||
10
apps/frontend/src/pages/broker/brokerDisplay.ts
Normal file
10
apps/frontend/src/pages/broker/brokerDisplay.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
getBrokerPositionGroup,
|
||||||
|
isBrokerOperationType,
|
||||||
|
type BrokerOperationImpact,
|
||||||
|
type BrokerPositionGroup,
|
||||||
|
} from '../../entities/broker-position';
|
||||||
@ -1 +0,0 @@
|
|||||||
export { HomePage } from './ui/HomePage';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { StockPage } from './ui/StockPage';
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { useStock, useStockCandles, useStockDividends } from '@/entities/stock';
|
|
||||||
import { StockDetails } from '@/widgets/stock-details';
|
|
||||||
import { PriceChart } from '@/widgets/price-chart';
|
|
||||||
import { DividendsTable } from '@/widgets/dividends-table';
|
|
||||||
|
|
||||||
export function StockPage() {
|
|
||||||
const { secid } = useParams<{ secid: string }>();
|
|
||||||
const { data: stock, isLoading, error } = useStock(secid!);
|
|
||||||
const till = new Date().toISOString().split('T')[0];
|
|
||||||
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
||||||
const { data: candles } = useStockCandles(secid!, '24h', from, till);
|
|
||||||
const { data: dividends } = useStockDividends(secid!);
|
|
||||||
|
|
||||||
if (isLoading) return <div>Загрузка...</div>;
|
|
||||||
if (error || !stock) return <div>Инструмент не найден</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<StockDetails stock={stock} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
|
||||||
<PriceChart data={candles ?? []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dividends && dividends.length > 0 && <DividendsTable dividends={dividends} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1
apps/frontend/src/routes.tsx
Normal file
1
apps/frontend/src/routes.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AppRoutes } from './app/routing/AppRoutes';
|
||||||
@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
|
|||||||
import { render, type RenderOptions } from '@testing-library/react';
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { SessionProvider } from '../app/providers/SessionProvider';
|
import { AuthProvider } from '../context/AuthContext';
|
||||||
|
|
||||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
queryClient?: QueryClient;
|
queryClient?: QueryClient;
|
||||||
@ -33,7 +33,7 @@ export function renderWithProviders(
|
|||||||
initialEntries={[route]}
|
initialEntries={[route]}
|
||||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||||
>
|
>
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export { BondDetails } from './ui/BondDetails';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { DividendsTable } from './ui/DividendsTable';
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { DividendsTable } from './DividendsTable';
|
|
||||||
import { createMockDividends } from '@/test/factories';
|
|
||||||
|
|
||||||
describe('DividendsTable', () => {
|
|
||||||
it('renders title, date column and formatted amount with currency', () => {
|
|
||||||
render(<DividendsTable dividends={createMockDividends()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Дивиденды')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('2024-07-10')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('35.00 RUB')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import type { DividendItem } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
interface DividendsTableProps {
|
|
||||||
dividends: DividendItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DividendsTable({ dividends }: DividendsTableProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '2px solid #eee' }}>
|
|
||||||
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
|
|
||||||
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{dividends.map((d, i) => (
|
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
|
|
||||||
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
|
|
||||||
<td style={{ textAlign: 'right', padding: 8 }}>
|
|
||||||
{d.value.toFixed(2)} {d.currency}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { PriceChart } from './ui/PriceChart';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { SearchBar } from './ui/SearchBar';
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { StockDetails } from './ui/StockDetails';
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
# Frontend FSD Cleanup — план
|
|
||||||
|
|
||||||
## Общий подход
|
|
||||||
|
|
||||||
7 последовательных шагов, каждый шаг верифицируется `npm test -w apps/frontend`.
|
|
||||||
После шага 1 (разрыв цепочки) удаляем файлы. После шагов 3-6 обновляем документацию.
|
|
||||||
|
|
||||||
## Шаг 1. Разорвать цепочку entity shims к `api/broker.ts`
|
|
||||||
|
|
||||||
- `entities/broker-position/api/brokerPositionApi.ts` — переписать на прямой вызов `shared/api/client`
|
|
||||||
- `entities/broker-operation/api/brokerOperationApi.ts` — переписать на прямой вызов `shared/api/client`
|
|
||||||
|
|
||||||
Оба файла делают то же, что `entities/broker-account/api/brokerAccountApi.ts` уже делает:
|
|
||||||
импортируют `apiClient` из `shared/api/client` и типы из `shared/api/responses`.
|
|
||||||
|
|
||||||
После этого шага `api/broker.ts` перестаёт быть нужен.
|
|
||||||
|
|
||||||
## Шаг 2. Переключить потребителей shim на FSD-импорты
|
|
||||||
|
|
||||||
Три группы потребителей:
|
|
||||||
|
|
||||||
1. `pages/broker/BrokerPages.test.tsx` — импортирует моки `useBrokerAccounts` и `useBrokerAccountPortfolios` из `../../hooks/`. Переключить на импорт из `entities/broker-account` (через `vi.mock(entities/broker-account)`). Сами файлы shim-хуков будут удалены на шаге 3, но пока их тесты нужно переключить.
|
|
||||||
|
|
||||||
2. `pages/broker/BrokerAccountsPage.test.tsx` — импортирует `BrokerAccountsPage` из `./BrokerAccountsPage` (который shim в `pages/broker/`). Переключить на `pages/broker-accounts`. После миграции тест будет жить при FSD-странице.
|
|
||||||
|
|
||||||
3. Три файла импортируют `AuthProvider` из `context/AuthContext`:
|
|
||||||
- `test/test-utils.tsx`
|
|
||||||
- `hooks/useAuth.test.tsx`
|
|
||||||
- `entities/session/model/useSession.test.tsx`
|
|
||||||
|
|
||||||
Заменить на импорт `SessionProvider` из `app/providers/SessionProvider`. `AuthProvider` — это просто alias.
|
|
||||||
|
|
||||||
## Шаг 3. Удалить shim-файлы
|
|
||||||
|
|
||||||
### api/ (кроме api/screener.ts)
|
|
||||||
- `api/auth.ts` → re-export из `entities/session/api/sessionApi`
|
|
||||||
- `api/client.ts` → re-export из `shared/api/client`
|
|
||||||
- `api/portfolio.ts` → re-export из `entities/portfolio/api/portfolioApi`
|
|
||||||
- `api/responses.ts` → re-export из `shared/api/responses`
|
|
||||||
- `api/types.ts` → re-export из `shared/api/types`
|
|
||||||
- `api/broker.ts` → больше не нужен (шаг 1)
|
|
||||||
- `api/broker.test.ts` → мёртвый код
|
|
||||||
|
|
||||||
### hooks/ (кроме hooks/useScreener.ts)
|
|
||||||
Все 17 файлов — однострочные re-export.
|
|
||||||
|
|
||||||
### context/
|
|
||||||
- `context/AuthContext.tsx` — re-export
|
|
||||||
- `context/AuthContext.test.tsx` — тест shim
|
|
||||||
|
|
||||||
### components/ (кроме components/screener/ и components/portfolios/)
|
|
||||||
- `components/Layout.tsx` → `app/layouts/AppLayout`
|
|
||||||
- `components/ProtectedRoute.tsx` → `app/routing/ProtectedRoute`
|
|
||||||
- `components/SearchBar.tsx` → `widgets/search-bar`
|
|
||||||
- `components/PriceChart.tsx` → `widgets/price-chart`
|
|
||||||
- `components/StockDetails.tsx` → `widgets/stock-details`
|
|
||||||
- `components/BondDetails.tsx` → `widgets/bond-details`
|
|
||||||
- `components/SkeletonBlock.tsx` → `shared/ui/SkeletonBlock`
|
|
||||||
- `components/TableSkeleton.tsx` → `shared/ui/TableSkeleton`
|
|
||||||
- Соответствующие `.test.tsx` файлы
|
|
||||||
|
|
||||||
### pages/ flat shims
|
|
||||||
- `pages/HomePage.tsx` → `pages/home`
|
|
||||||
- `pages/StockPage.tsx` → `pages/stock`
|
|
||||||
- `pages/BondPage.tsx` → `pages/bond`
|
|
||||||
- Соответствующие `.test.tsx` файлы
|
|
||||||
|
|
||||||
### pages/broker/ (весь каталог)
|
|
||||||
13 файлов — все shims.
|
|
||||||
|
|
||||||
### Корень src/
|
|
||||||
- `routes.tsx` → re-export из `app/routing/AppRoutes`
|
|
||||||
|
|
||||||
## Шаг 4. Удалить тесты, привязанные к shim-файлам
|
|
||||||
|
|
||||||
После удаления shim-файлов их тесты тоже удаляются:
|
|
||||||
- `api/auth.test.ts`, `api/client.test.ts`, `api/broker.test.ts`
|
|
||||||
- `hooks/useAuth.test.tsx`, `hooks/useBrokerAccountPortfolios.test.tsx`,
|
|
||||||
`hooks/useBrokerAccounts.test.tsx`, `hooks/useSearch.test.tsx`
|
|
||||||
- `context/AuthContext.test.tsx`
|
|
||||||
- `components/BondDetails.test.tsx`, `components/Layout.test.tsx`,
|
|
||||||
`components/PriceChart.test.tsx`, `components/ProtectedRoute.test.tsx`,
|
|
||||||
`components/SearchBar.test.tsx`, `components/StockDetails.test.tsx`
|
|
||||||
- `pages/HomePage.test.tsx`, `pages/StockPage.test.tsx`, `pages/BondPage.test.tsx`
|
|
||||||
- `pages/broker/BrokerPages.test.tsx`, `pages/broker/BrokerAccountsPage.test.tsx`
|
|
||||||
|
|
||||||
Функциональность этих тестов уже покрыта тестами внутри FSD-слоёв.
|
|
||||||
|
|
||||||
## Шаг 5. Удалить `api/broker.ts`
|
|
||||||
|
|
||||||
После шага 1 (разрыв цепочки) entity API прокси больше не ссылаются на `api/broker.ts`.
|
|
||||||
Осталось только удалить сам файл.
|
|
||||||
|
|
||||||
## Шаг 6. Обновить документацию
|
|
||||||
|
|
||||||
- `apps/docs/docs/frontend/hooks.md` — убрать секции про legacy shim-пути
|
|
||||||
- `apps/docs/docs/frontend/routes.md` — убрать упоминания shim-файлов
|
|
||||||
|
|
||||||
## Шаг 7. Финальная верификация
|
|
||||||
|
|
||||||
- `npm test -w apps/frontend` — PASS
|
|
||||||
- `npm run lint -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/docs` — PASS
|
|
||||||
- Deep import verification: `rg -n "@/api/|@/hooks/|@/context/|@/components/(?!screener|portfolios)" apps/frontend/src` — no matches
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
# Frontend FSD Cleanup
|
|
||||||
|
|
||||||
Дата: 2026-06-20
|
|
||||||
Статус: спецификация
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
FSD-миграция фронтенда выполнена в 5 фаз:
|
|
||||||
|
|
||||||
1. Broker pilot — entities broker-account/broker-position/broker-operation + widgets + pages
|
|
||||||
2. Shared layer — shared/api, shared/ui
|
|
||||||
3. Entities migration — stock, bond, portfolio, session, search
|
|
||||||
4. App + auth — providers, routing, layouts
|
|
||||||
5. Market pages — home/stock/bond pages + widgets
|
|
||||||
|
|
||||||
После завершения миграции остались shim-файлы — однострочные re-export из старых путей в новые
|
|
||||||
FSD-точки входа — а также мёртвый код в `pages/broker/` и старая цепочка entity API-прокси,
|
|
||||||
которая идёт через `api/broker.ts` вместо прямого вызова `shared/api/client`.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Удалить shim-файлы и мёртвый код, разорвав последние цепочки legacy-импортов, чтобы
|
|
||||||
FSD-структура стала единственной архитектурой фронтенда без дублирующихся точек входа.
|
|
||||||
|
|
||||||
## Область изменений
|
|
||||||
|
|
||||||
### Удаляемые shim-файлы (re-export only)
|
|
||||||
|
|
||||||
- `api/` — `auth.ts`, `client.ts`, `portfolio.ts`, `responses.ts`, `types.ts`, `broker.test.ts`
|
|
||||||
- `hooks/` — все файлы, кроме `useScreener.ts`
|
|
||||||
- `context/` — `AuthContext.tsx`, `AuthContext.test.tsx`
|
|
||||||
- `components/` — все файлы, кроме `screener/` и `portfolios/`
|
|
||||||
- `pages/broker/` — все 13 файлов
|
|
||||||
- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
|
|
||||||
- `routes.tsx`
|
|
||||||
- Тесты, привязанные к удаляемым shim-файлам
|
|
||||||
|
|
||||||
### Переписываемые entity API (разрыв цепочки к `api/broker.ts`)
|
|
||||||
|
|
||||||
- `entities/broker-position/api/brokerPositionApi.ts` — прямой вызов `shared/api/client`
|
|
||||||
- `entities/broker-operation/api/brokerOperationApi.ts` — прямой вызов `shared/api/client`
|
|
||||||
|
|
||||||
### Удаляемый мёртвый код
|
|
||||||
|
|
||||||
- `api/broker.ts` — после разрыва цепочки перестаёт быть нужен
|
|
||||||
|
|
||||||
### Обновляемые потребители (переключение с shim на FSD-импорты)
|
|
||||||
|
|
||||||
- `pages/broker/BrokerPages.test.tsx` — импорты из `../../hooks/` → `entities/broker-*`
|
|
||||||
- `pages/broker/BrokerAccountsPage.test.tsx` — импорт shim → `pages/broker-accounts`
|
|
||||||
- `test/test-utils.tsx`, `hooks/useAuth.test.tsx`, `entities/session/model/useSession.test.tsx` — `AuthProvider` из `context/AuthContext` → `SessionProvider` из `app/providers/SessionProvider`
|
|
||||||
|
|
||||||
### Обновляемая документация
|
|
||||||
|
|
||||||
- `apps/docs/docs/frontend/hooks.md` — убрать legacy shim-пути
|
|
||||||
- `apps/docs/docs/frontend/routes.md` — убрать shim-файлы
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Изменения ограничены frontend-пакетом.
|
|
||||||
- Не мигрируется screener (живой код, не shim) — остаётся в текущей структуре.
|
|
||||||
- Не мигрируется portfolio pages (живой код, не shim) — остаются в текущей структуре.
|
|
||||||
- Не мигрируется LoginPage/RegisterPage/ProfilePage (живой код, не shim).
|
|
||||||
- Не меняется поведение UI, API-контракты, роутинг.
|
|
||||||
- Не вводятся ESLint import boundaries.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- `api/` содержит только `screener.ts` (живой код)
|
|
||||||
- `hooks/` содержит только `useScreener.ts` (живой код)
|
|
||||||
- `context/` удалён полностью
|
|
||||||
- `components/` содержит только `screener/` и `portfolios/` (живой код)
|
|
||||||
- `pages/broker/` удалён полностью
|
|
||||||
- `pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` удалены (живут в `pages/home/`, `pages/stock/`, `pages/bond/`)
|
|
||||||
- `routes.tsx` удалён (живёт в `app/routing/AppRoutes.tsx`)
|
|
||||||
- `api/broker.ts` удалён
|
|
||||||
- `entities/broker-position/api/brokerPositionApi.ts` и `entities/broker-operation/api/brokerOperationApi.ts` импортируют напрямую из `shared/api/client`
|
|
||||||
- Все тестовые файлы, привязанные к удалённым shim-файлам, удалены
|
|
||||||
- Все потребители переключены на FSD-импорты
|
|
||||||
- `npm test -w apps/frontend` — PASS
|
|
||||||
- `npm run lint -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/docs` — PASS
|
|
||||||
- Документация обновлена
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# Frontend FSD Cleanup — задачи
|
|
||||||
|
|
||||||
Статус: выполнено
|
|
||||||
|
|
||||||
## 1. Разорвать цепочку entity shims
|
|
||||||
|
|
||||||
- [x] Переписать `entities/broker-position/api/brokerPositionApi.ts` на прямой вызов `shared/api/client`
|
|
||||||
- [x] Переписать `entities/broker-operation/api/brokerOperationApi.ts` на прямой вызов `shared/api/client`
|
|
||||||
- [x] Проверить `npm test -w apps/frontend`
|
|
||||||
|
|
||||||
## 2. Переключить потребителей shim на FSD-импорты
|
|
||||||
|
|
||||||
- [x] Удалены вместе с `pages/broker/` (BrokerPages.test.tsx, BrokerAccountsPage.test.tsx)
|
|
||||||
- [x] Обновить `test/test-utils.tsx` — `AuthProvider` → `SessionProvider` из `app/providers/SessionProvider`
|
|
||||||
- [x] Обновить `hooks/useAuth.test.tsx` — `AuthProvider` → `SessionProvider` (удалён на шаге 4)
|
|
||||||
- [x] Обновить `entities/session/model/useSession.test.tsx` — `AuthProvider` → `SessionProvider`
|
|
||||||
- [x] Проверить `npm test -w apps/frontend`
|
|
||||||
|
|
||||||
## 3. Удалить shim-файлы
|
|
||||||
|
|
||||||
### api/
|
|
||||||
- [x] Удалить `api/auth.ts`, `api/client.ts`, `api/portfolio.ts`, `api/responses.ts`, `api/types.ts`
|
|
||||||
- [x] Удалить `api/broker.test.ts`
|
|
||||||
|
|
||||||
### hooks/
|
|
||||||
- [x] Удалить все файлы кроме `useScreener.ts`
|
|
||||||
|
|
||||||
### context/
|
|
||||||
- [x] Удалить `context/AuthContext.tsx`
|
|
||||||
- [x] Удалить `context/AuthContext.test.tsx`
|
|
||||||
|
|
||||||
### components/
|
|
||||||
- [x] Удалить все flat shims
|
|
||||||
|
|
||||||
### pages/ flat shims
|
|
||||||
- [x] Удалить `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
|
|
||||||
|
|
||||||
### pages/broker/
|
|
||||||
- [x] Удалить весь каталог `pages/broker/`
|
|
||||||
|
|
||||||
### Корень src/
|
|
||||||
- [x] Удалить `routes.tsx`
|
|
||||||
|
|
||||||
- [x] Проверить `npm test -w apps/frontend`
|
|
||||||
|
|
||||||
## 4. Удалить тесты, привязанные к shim-файлам
|
|
||||||
|
|
||||||
- [x] Удалены 17 тестовых файлов
|
|
||||||
- [x] Исправлен `api/screener.ts` — импорты из `./client` → `@/shared/api/client`
|
|
||||||
|
|
||||||
## 5. Удалить api/broker.ts
|
|
||||||
|
|
||||||
- [x] Удалить `api/broker.ts`
|
|
||||||
- [x] Проверить `npm test -w apps/frontend`
|
|
||||||
|
|
||||||
## 6. Обновить документацию
|
|
||||||
|
|
||||||
- [x] Обновить `apps/docs/docs/frontend/hooks.md`
|
|
||||||
- [x] Обновить `apps/docs/docs/frontend/routes.md`
|
|
||||||
- [x] Проверить `npm run build -w apps/docs`
|
|
||||||
|
|
||||||
## 7. Финальная верификация
|
|
||||||
|
|
||||||
- [x] `npm test -w apps/frontend` — PASS (23 files, 112 tests)
|
|
||||||
- [x] `npm run lint -w apps/frontend` — PASS
|
|
||||||
- [x] `npm run build -w apps/frontend` — PASS
|
|
||||||
- [x] `npm run build -w apps/docs` — PASS
|
|
||||||
- [x] Legacy import check — no matches
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# Frontend FSD Market Pages Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Перевести market pages (`home`, `stock`, `bond`) и связанные market widgets в FSD-структуру без изменения пользовательского поведения и с сохранением совместимости через shim-файлы.
|
|
||||||
|
|
||||||
**Architecture:** Route params и orchestration загрузки данных остаются в `pages/*`, а UI-композиция переносится в `widgets/*` с явным public API. Search query-hook выделяется в отдельный `entities/search` slice, старые entrypoints в `components/`, `hooks/` и плоских `pages/` превращаются в re-export shims, чтобы маршруты и тесты можно было переключать постепенно.
|
|
||||||
|
|
||||||
**Tech Stack:** React 18, TypeScript, React Router v6, TanStack Query v5, lightweight-charts v4, Vitest, Testing Library, ESLint, Vite, Docusaurus.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Базовое состояние
|
|
||||||
|
|
||||||
- Feature branch: `codex/frontend-fsd-market-pages`
|
|
||||||
- Baseline verification:
|
|
||||||
- `npm test -w apps/frontend` — PASS (`36` test files, `198` tests)
|
|
||||||
- `npm run lint -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/frontend` — PASS
|
|
||||||
|
|
||||||
## Карта файлов
|
|
||||||
|
|
||||||
### Create
|
|
||||||
|
|
||||||
- `apps/frontend/src/entities/search/index.ts` — public API search slice
|
|
||||||
- `apps/frontend/src/entities/search/model/useSearch.ts` — query hook для поиска бумаг
|
|
||||||
- `apps/frontend/src/entities/search/model/useSearch.test.tsx` — тесты query hook после переноса
|
|
||||||
- `apps/frontend/src/widgets/search-bar/index.ts` — public API search widget
|
|
||||||
- `apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx` — UI поиска с локальным state/dropdown
|
|
||||||
- `apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx` — widget tests после переноса
|
|
||||||
- `apps/frontend/src/widgets/price-chart/index.ts` — public API chart widget
|
|
||||||
- `apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx` — chart widget implementation
|
|
||||||
- `apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx` — chart tests после переноса
|
|
||||||
- `apps/frontend/src/widgets/stock-details/index.ts` — public API stock details widget
|
|
||||||
- `apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx` — stock details widget
|
|
||||||
- `apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx` — widget tests после переноса
|
|
||||||
- `apps/frontend/src/widgets/bond-details/index.ts` — public API bond details widget
|
|
||||||
- `apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx` — bond details widget
|
|
||||||
- `apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx` — widget tests после переноса
|
|
||||||
- `apps/frontend/src/widgets/dividends-table/index.ts` — public API dividends widget
|
|
||||||
- `apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx` — dividends table extracted from StockPage
|
|
||||||
- `apps/frontend/src/pages/home/index.ts` — public API home page
|
|
||||||
- `apps/frontend/src/pages/home/ui/HomePage.tsx` — home page entrypoint
|
|
||||||
- `apps/frontend/src/pages/stock/index.ts` — public API stock page
|
|
||||||
- `apps/frontend/src/pages/stock/ui/StockPage.tsx` — stock page entrypoint/orchestration
|
|
||||||
- `apps/frontend/src/pages/bond/index.ts` — public API bond page
|
|
||||||
- `apps/frontend/src/pages/bond/ui/BondPage.tsx` — bond page entrypoint/orchestration
|
|
||||||
|
|
||||||
### Modify
|
|
||||||
|
|
||||||
- `apps/frontend/src/app/routing/AppRoutes.tsx` — переключить route imports на `@/pages/home`, `@/pages/stock`, `@/pages/bond`
|
|
||||||
- `apps/frontend/src/app/layouts/AppLayout.tsx` — переключить импорт SearchBar на `@/widgets/search-bar`
|
|
||||||
- `apps/frontend/src/components/SearchBar.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/components/SearchBar.test.tsx` — перенести/обновить импорт под новый widget path
|
|
||||||
- `apps/frontend/src/components/PriceChart.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/components/PriceChart.test.tsx` — перенести/обновить импорт под новый widget path
|
|
||||||
- `apps/frontend/src/components/StockDetails.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/components/StockDetails.test.tsx` — перенести/обновить импорт под новый widget path
|
|
||||||
- `apps/frontend/src/components/BondDetails.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/components/BondDetails.test.tsx` — перенести/обновить импорт под новый widget path
|
|
||||||
- `apps/frontend/src/hooks/useSearch.ts` — re-export shim
|
|
||||||
- `apps/frontend/src/hooks/useSearch.test.tsx` — перенести/обновить импорт под новый entity path
|
|
||||||
- `apps/frontend/src/pages/HomePage.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/pages/HomePage.test.tsx` — перенести/обновить импорт под новый page path
|
|
||||||
- `apps/frontend/src/pages/StockPage.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/pages/StockPage.test.tsx` — перенести/обновить импорт под новый page path
|
|
||||||
- `apps/frontend/src/pages/BondPage.tsx` — re-export shim
|
|
||||||
- `apps/frontend/src/pages/BondPage.test.tsx` — перенести/обновить импорт под новый page path
|
|
||||||
- `apps/docs/docs/frontend/overview.md` — отразить FSD migration market pages
|
|
||||||
- `apps/docs/docs/frontend/components.md` — обновить описание widget/component boundaries
|
|
||||||
- `apps/docs/docs/frontend/hooks.md` — убрать market search из legacy hooks как primary location
|
|
||||||
- `apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md` — зафиксировать стратегию второй market-итерации FSD
|
|
||||||
|
|
||||||
## Целевая структура
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/frontend/src/
|
|
||||||
├── entities/
|
|
||||||
│ └── search/
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── model/
|
|
||||||
│ ├── useSearch.ts
|
|
||||||
│ └── useSearch.test.tsx
|
|
||||||
├── widgets/
|
|
||||||
│ ├── search-bar/
|
|
||||||
│ │ ├── index.ts
|
|
||||||
│ │ └── ui/
|
|
||||||
│ │ ├── SearchBar.tsx
|
|
||||||
│ │ └── SearchBar.test.tsx
|
|
||||||
│ ├── price-chart/
|
|
||||||
│ ├── stock-details/
|
|
||||||
│ ├── bond-details/
|
|
||||||
│ └── dividends-table/
|
|
||||||
└── pages/
|
|
||||||
├── home/
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── ui/HomePage.tsx
|
|
||||||
├── stock/
|
|
||||||
│ ├── index.ts
|
|
||||||
│ └── ui/StockPage.tsx
|
|
||||||
└── bond/
|
|
||||||
├── index.ts
|
|
||||||
└── ui/BondPage.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
## Потоки данных
|
|
||||||
|
|
||||||
### Stock page
|
|
||||||
|
|
||||||
```text
|
|
||||||
Route /stocks/:secid
|
|
||||||
→ pages/stock/ui/StockPage.tsx
|
|
||||||
→ useStock(secid) + useStockCandles(secid, interval, from, till) + useStockDividends(secid)
|
|
||||||
→ widgets/stock-details + widgets/price-chart + widgets/dividends-table
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bond page
|
|
||||||
|
|
||||||
```text
|
|
||||||
Route /bonds/:secid
|
|
||||||
→ pages/bond/ui/BondPage.tsx
|
|
||||||
→ useBond(secid) + useBondCandles(secid, interval, from, till)
|
|
||||||
→ widgets/bond-details + widgets/price-chart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
AppLayout
|
|
||||||
→ widgets/search-bar/ui/SearchBar.tsx
|
|
||||||
→ entities/search/model/useSearch(debouncedQuery)
|
|
||||||
→ shared/api/client.searchSecurities(query)
|
|
||||||
→ navigate('/stocks/:secid' | '/bonds/:secid')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Этапы реализации
|
|
||||||
|
|
||||||
### Phase 1: Search entity и SearchBar widget
|
|
||||||
|
|
||||||
1. Создать `entities/search/model/useSearch.ts`, перенеся текущий hook из `src/hooks/useSearch.ts` без изменения `queryKey`, `enabled`, `staleTime` и response mapping.
|
|
||||||
2. Создать `entities/search/index.ts` и экспортировать `useSearch` только через public API.
|
|
||||||
3. Перенести `SearchBar.tsx` в `widgets/search-bar/ui/SearchBar.tsx`, сохранив локальное состояние `query`, `debounced`, `open`, обработчик клика вне dropdown и navigate-логику.
|
|
||||||
4. Добавить `widgets/search-bar/index.ts`.
|
|
||||||
5. Старые `src/hooks/useSearch.ts` и `src/components/SearchBar.tsx` превратить в re-export shims.
|
|
||||||
6. Переместить и адаптировать тесты `useSearch.test.tsx` и `SearchBar.test.tsx` так, чтобы они проверяли новые public entrypoints.
|
|
||||||
7. Переключить `app/layouts/AppLayout.tsx` на импорт `@/widgets/search-bar`.
|
|
||||||
|
|
||||||
### Phase 2: Market widgets
|
|
||||||
|
|
||||||
1. Перенести `PriceChart.tsx` в `widgets/price-chart/ui/PriceChart.tsx` без изменения работы с `lightweight-charts`.
|
|
||||||
2. Перенести `StockDetails.tsx` и `BondDetails.tsx` в `widgets/stock-details/ui/StockDetails.tsx` и `widgets/bond-details/ui/BondDetails.tsx`.
|
|
||||||
3. Создать `widgets/dividends-table/ui/DividendsTable.tsx`, выделив таблицу дивидендов из `StockPage.tsx` без изменения разметки, заголовков и форматирования значений.
|
|
||||||
4. Добавить `index.ts` для каждого widget slice.
|
|
||||||
5. Старые `src/components/PriceChart.tsx`, `StockDetails.tsx`, `BondDetails.tsx` превратить в re-export shims.
|
|
||||||
6. Перенести widget tests рядом с новыми реализациями и оставить старые тестовые файлы либо как shim-import consumers, либо обновить их на новые public entrypoints без дублирования покрытия.
|
|
||||||
|
|
||||||
### Phase 3: Market pages и route imports
|
|
||||||
|
|
||||||
1. Создать `pages/home/ui/HomePage.tsx` и `pages/home/index.ts`, сохранив текущее статическое содержимое главной страницы.
|
|
||||||
2. Создать `pages/stock/ui/StockPage.tsx` и `pages/stock/index.ts`, оставив в page orchestration:
|
|
||||||
- чтение `secid` из `useParams`
|
|
||||||
- расчёт `from`/`till`
|
|
||||||
- вызовы `useStock`, `useStockCandles`, `useStockDividends`
|
|
||||||
- состояния loading/not-found
|
|
||||||
- передачу готовых props в widgets
|
|
||||||
3. Создать `pages/bond/ui/BondPage.tsx` и `pages/bond/index.ts` по той же схеме для `useBond` и `useBondCandles`.
|
|
||||||
4. Переключить `app/routing/AppRoutes.tsx` на новые page public entrypoints.
|
|
||||||
5. Старые `src/pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` превратить в re-export shims.
|
|
||||||
6. Обновить page tests, чтобы они импортировали новые page entrypoints и подтверждали эквивалентность поведения.
|
|
||||||
|
|
||||||
### Phase 4: Documentation, ADR и финальная verification
|
|
||||||
|
|
||||||
1. Создать `apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md` с секциями Context / Options / Decision / Consequences.
|
|
||||||
2. Обновить `apps/docs/docs/frontend/overview.md`, `components.md`, `hooks.md`, чтобы опубликованная документация отражала market FSD slices и shim-стратегию coexistence.
|
|
||||||
3. Проверить, что новые public API не требуют deep imports из `model/` или `ui/` во внешнем коде.
|
|
||||||
4. Выполнить финальную verification:
|
|
||||||
- `npm test -w apps/frontend`
|
|
||||||
- `npm run lint -w apps/frontend`
|
|
||||||
- `npm run build -w apps/frontend`
|
|
||||||
- `npm run build -w apps/docs`
|
|
||||||
|
|
||||||
## Риски и контрольные точки
|
|
||||||
|
|
||||||
- `PriceChart` чувствителен к ref/effect lifecycle. При переносе важно не менять cleanup и порядок инициализации chart series.
|
|
||||||
- `SearchBar` используется в `AppLayout`, поэтому ошибки в импорт-пути сразу затронут почти все route-level tests.
|
|
||||||
- `StockPage`/`BondPage` вычисляют даты inline. В этой фиче это остаётся в page-layer и не выносится в shared util, чтобы не расширять scope.
|
|
||||||
- Legacy shims должны оставаться тонкими re-export файлами без дополнительной логики.
|
|
||||||
|
|
||||||
## Критерии завершения плана
|
|
||||||
|
|
||||||
- Все acceptance criteria из `spec.md` сопоставлены задачам реализации.
|
|
||||||
- Новая market-структура использует только public API между `pages`, `widgets` и `entities`.
|
|
||||||
- Frontend tests, lint и build проходят после миграции.
|
|
||||||
- ADR и frontend docs синхронизированы с новой структурой.
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
# Frontend FSD Market Pages
|
|
||||||
|
|
||||||
Дата: 2026-06-20
|
|
||||||
Статус: спецификация
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Предыдущие FSD-итерации мигрировали broker-домен (`frontend-fsd-broker-pilot`), shared-слой
|
|
||||||
(`frontend-fsd-shared-layer`), entities stock/bond/portfolio (`fsd-entities-migration`) и app-слой с
|
|
||||||
auth (`frontend-fsd-app-auth`).
|
|
||||||
|
|
||||||
Рыночные страницы (StockPage, BondPage, HomePage) и их компоненты (StockDetails, BondDetails,
|
|
||||||
PriceChart, SearchBar) остались в исторической технической структуре (`components/`, `hooks/`,
|
|
||||||
плоские `pages/`). Это единственный крупный блок кода вне FSD-структуры, который активно
|
|
||||||
используется пользователями.
|
|
||||||
|
|
||||||
## Иерархия источников
|
|
||||||
|
|
||||||
- Текущая задача пользователя: завершить FSD-миграцию рыночных страниц.
|
|
||||||
- `docs/inbox.md`: направление на постепенную FSD-миграцию.
|
|
||||||
- `docs/features/frontend-fsd-broker-pilot/spec.md`: эталонный FSD-шаблон.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Перевести рыночные страницы (stock, bond, home) и связанные компоненты в FSD-структуру, следуя
|
|
||||||
паттерну, установленному broker-пилотом — через widgets, entities и page entrypoints с
|
|
||||||
совместимыми shim-файлами.
|
|
||||||
|
|
||||||
## Область изменений
|
|
||||||
|
|
||||||
Фича охватывает только frontend-код рыночных страниц:
|
|
||||||
|
|
||||||
- StockPage, BondPage, HomePage — миграция из плоских `pages/` в `pages/stock/`, `pages/bond/`,
|
|
||||||
`pages/home/` с FSD-структурой;
|
|
||||||
- StockDetails, BondDetails — миграция из `components/` в `widgets/stock-details/`,
|
|
||||||
`widgets/bond-details/`;
|
|
||||||
- PriceChart — миграция из `components/` в `widgets/price-chart/`;
|
|
||||||
- SearchBar + useSearch — миграция в `widgets/search-bar/` + новый entity `entities/search/`;
|
|
||||||
- таблица дивидендов — вынос из StockPage в новый виджет `widgets/dividends-table/`;
|
|
||||||
- shim-файлы для обратной совместимости;
|
|
||||||
- обновление роутинга и layout для импортов из новых FSD-точек входа.
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
### 1. Все компоненты следуют FSD-слоям
|
|
||||||
|
|
||||||
- entities — data-слой (API + query hooks). Новый: `entities/search/`.
|
|
||||||
- widgets — бизнес-композиция UI. Новые: `widgets/stock-details/`, `widgets/bond-details/`,
|
|
||||||
`widgets/price-chart/`, `widgets/search-bar/`, `widgets/dividends-table/`.
|
|
||||||
- pages — тонкие entrypoints. Новые: `pages/stock/`, `pages/bond/`, `pages/home/`.
|
|
||||||
|
|
||||||
### 1.1 Ownership данных остаётся у pages и entities
|
|
||||||
|
|
||||||
- `pages/stock` и `pages/bond` владеют route params и orchestration загрузки данных через
|
|
||||||
существующие entity hooks (`useStock`, `useBond`, `useStockCandles`, `useBondCandles`,
|
|
||||||
`useStockDividends`).
|
|
||||||
- `widgets/stock-details`, `widgets/bond-details`, `widgets/price-chart`,
|
|
||||||
`widgets/dividends-table` остаются презентационно-композиционными и получают готовые данные
|
|
||||||
через props.
|
|
||||||
- `widgets/search-bar` владеет локальным UI-состоянием поиска (input, debounced query, dropdown),
|
|
||||||
а query-hook `useSearch` размещается в `entities/search/model/`.
|
|
||||||
- `pages/home` остаётся статическим page entrypoint без новой data-логики.
|
|
||||||
|
|
||||||
### 2. Границы срезов явные через public API
|
|
||||||
|
|
||||||
Каждый widget и entity имеет `index.ts` barrel с контролируемым экспортом.
|
|
||||||
|
|
||||||
Внешний код импортирует market pages/widgets/entities только через их public API
|
|
||||||
(`@/pages/stock`, `@/widgets/search-bar`, `@/entities/search` и т.д.). Deep imports во внутренние
|
|
||||||
`model/`, `api/`, `ui/` директории не считаются контрактом для внешних потребителей.
|
|
||||||
|
|
||||||
### 3. Поведение UI не меняется
|
|
||||||
|
|
||||||
Миграция не добавляет новую функциональность и не пересматривает UX.
|
|
||||||
|
|
||||||
### 4. Совместимость через shim-файлы
|
|
||||||
|
|
||||||
Старые пути (`components/StockDetails.tsx`, `hooks/useSearch.ts`, `pages/StockPage.tsx`) становятся
|
|
||||||
re-export shim-файлами, импортирующими из новых FSD-точек входа.
|
|
||||||
|
|
||||||
### 5. Тесты следуют новым границам
|
|
||||||
|
|
||||||
Тесты перемещаются вместе с компонентами в соответствующие widget/page/ui-директории.
|
|
||||||
|
|
||||||
### 6. Общая инфраструктура остаётся в shared
|
|
||||||
|
|
||||||
HTTP-клиент и типы остаются в `shared/api/`. PriceChart использует `lightweight-charts`
|
|
||||||
непосредственно, без дополнительных абстракций.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Изменения ограничены frontend-пакетом.
|
|
||||||
- Допускаются связанные обновления опубликованной frontend-документации и ADR, если они отражают
|
|
||||||
новую FSD-структуру market pages.
|
|
||||||
- Не вводятся ESLint import boundaries или import guards.
|
|
||||||
- Не мигрируются portfolio и screener страницы.
|
|
||||||
- Не удаляется мёртвый код (old `pages/broker/`, `api/broker.ts`).
|
|
||||||
- Не меняется backend API, Swagger, codegen.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- StockDetails доступен через `widgets/stock-details`.
|
|
||||||
- BondDetails доступен через `widgets/bond-details`.
|
|
||||||
- PriceChart доступен через `widgets/price-chart`.
|
|
||||||
- SearchBar доступен через `widgets/search-bar` и использует query/public API из `entities/search`.
|
|
||||||
- DividendsTable вынесен из StockPage в `widgets/dividends-table`.
|
|
||||||
- StockPage доступен через `pages/stock`.
|
|
||||||
- BondPage доступен через `pages/bond`.
|
|
||||||
- HomePage доступен через `pages/home`.
|
|
||||||
- Все старые пути (components/*, hooks/useSearch, pages/StockPage/BondPage/HomePage) —
|
|
||||||
re-export shims, импортирующие из новых FSD-точек входа.
|
|
||||||
- `app/routing/AppRoutes.tsx` и `app/layouts/AppLayout.tsx` импортируют из новых FSD-точек входа.
|
|
||||||
- `pages/stock` и `pages/bond` сохраняют orchestration загрузки market data и передают widgets
|
|
||||||
готовые props без переноса route/query orchestration внутрь widgets.
|
|
||||||
- Frontend lint, tests и build проходят.
|
|
||||||
- Пользовательское поведение эквивалентно.
|
|
||||||
- ADR и документация обновлены.
|
|
||||||
|
|
||||||
## Не цели
|
|
||||||
|
|
||||||
- Миграция portfolio, screener и остальных доменов.
|
|
||||||
- Введение жёстких правил импортов.
|
|
||||||
- Удаление мёртвого кода (old `pages/broker/`, `api/broker.ts`).
|
|
||||||
- Переписывание стилей или визуального дизайна.
|
|
||||||
- Добавление новой функциональности.
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
# Frontend FSD Market Pages — задачи
|
|
||||||
|
|
||||||
Статус: выполнено
|
|
||||||
|
|
||||||
Подробные шаги, карта файлов и verification-команды находятся в [plan.md](plan.md).
|
|
||||||
|
|
||||||
Финальная verification:
|
|
||||||
- `npm test -w apps/frontend` — PASS (`42` files, `230` tests)
|
|
||||||
- `npm run lint -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/frontend` — PASS
|
|
||||||
- `npm run build -w apps/docs` — PASS
|
|
||||||
|
|
||||||
Deep import verification:
|
|
||||||
- `rg -n "@/entities/search/model|@/widgets/search-bar/ui|@/widgets/price-chart/ui|@/widgets/stock-details/ui|@/widgets/bond-details/ui|@/widgets/dividends-table/ui|@/pages/home/ui|@/pages/stock/ui|@/pages/bond/ui" apps/frontend/src apps/docs/docs` — no matches
|
|
||||||
|
|
||||||
## 1. Search slice
|
|
||||||
|
|
||||||
- [x] Создать `entities/search` с `useSearch` и public API.
|
|
||||||
- [x] Перенести `SearchBar` в `widgets/search-bar`.
|
|
||||||
- [x] Оставить `hooks/useSearch.ts` и `components/SearchBar.tsx` как re-export shims.
|
|
||||||
- [x] Переключить `AppLayout` на `@/widgets/search-bar`.
|
|
||||||
- [x] Прогнать `useSearch` и `SearchBar` тесты после переноса.
|
|
||||||
|
|
||||||
## 2. Market widgets
|
|
||||||
|
|
||||||
- [x] Перенести `PriceChart` в `widgets/price-chart`.
|
|
||||||
- [x] Перенести `StockDetails` в `widgets/stock-details`.
|
|
||||||
- [x] Перенести `BondDetails` в `widgets/bond-details`.
|
|
||||||
- [x] Вынести таблицу дивидендов в `widgets/dividends-table`.
|
|
||||||
- [x] Привязать widget tests к новым slice boundaries.
|
|
||||||
|
|
||||||
## 3. Market pages
|
|
||||||
|
|
||||||
- [x] Создать `pages/home`, `pages/stock`, `pages/bond` с public API.
|
|
||||||
- [x] Сохранить orchestration route params и data loading в `pages/stock` и `pages/bond`.
|
|
||||||
- [x] Переключить `AppRoutes` на новые page entrypoints.
|
|
||||||
- [x] Оставить `pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` как re-export shims.
|
|
||||||
- [x] Прогнать page tests после переключения маршрутов.
|
|
||||||
|
|
||||||
## 4. Docs и verification
|
|
||||||
|
|
||||||
- [x] Добавить ADR по market FSD migration.
|
|
||||||
- [x] Обновить frontend docs (`overview.md`, `components.md`, `hooks.md`, `routes.md`).
|
|
||||||
- [x] Проверить отсутствие внешних deep imports в новые `model/` и `ui/` директории.
|
|
||||||
- [x] Выполнить финальную проверку: frontend `test`, `lint`, `build`, docs `build`.
|
|
||||||
Loading…
x
Reference in New Issue
Block a user