Compare commits
2 Commits
20f0efa083
...
6f9e368126
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f9e368126 | |||
| 40d7792b9e |
72
apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md
Normal file
72
apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# 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,5 +15,6 @@
|
|||||||
| [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,13 +1,16 @@
|
|||||||
# Компоненты
|
# Компоненты
|
||||||
|
|
||||||
## Layout (`components/Layout.tsx`)
|
Published docs ниже перечисляют primary entrypoints. Legacy файлы в `components/` сохранены как
|
||||||
|
тонкие 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 (`components/SearchBar.tsx`)
|
## SearchBar (`widgets/search-bar/ui/SearchBar.tsx`, public API: `widgets/search-bar`)
|
||||||
|
|
||||||
Поиск инструментов с debounce (300ms).
|
Поиск инструментов с debounce (300ms).
|
||||||
|
|
||||||
@ -16,7 +19,9 @@
|
|||||||
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
|
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
|
||||||
- Закрывается при клике вне компонента
|
- Закрывается при клике вне компонента
|
||||||
|
|
||||||
## PriceChart (`components/PriceChart.tsx`)
|
Legacy path `components/SearchBar.tsx` остаётся shim-файлом и не является primary entrypoint.
|
||||||
|
|
||||||
|
## PriceChart (`widgets/price-chart/ui/PriceChart.tsx`, public API: `widgets/price-chart`)
|
||||||
|
|
||||||
График цены на основе `lightweight-charts` v4.
|
График цены на основе `lightweight-charts` v4.
|
||||||
|
|
||||||
@ -25,16 +30,30 @@
|
|||||||
- Цвета: зелёный для роста, красный для падения
|
- Цвета: зелёный для роста, красный для падения
|
||||||
- Адаптивная ширина (resize listener)
|
- Адаптивная ширина (resize listener)
|
||||||
|
|
||||||
## StockDetails (`components/StockDetails.tsx`)
|
Legacy path `components/PriceChart.tsx` остаётся shim-файлом.
|
||||||
|
|
||||||
|
## StockDetails (`widgets/stock-details/ui/StockDetails.tsx`, public API: `widgets/stock-details`)
|
||||||
|
|
||||||
Карточка с информацией об акции.
|
Карточка с информацией об акции.
|
||||||
|
|
||||||
- Принимает `ShareResponse`
|
- Принимает `ShareResponse`
|
||||||
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
|
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
|
||||||
|
|
||||||
## BondDetails (`components/BondDetails.tsx`)
|
Legacy path `components/StockDetails.tsx` остаётся shim-файлом.
|
||||||
|
|
||||||
|
## 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,13 +4,21 @@
|
|||||||
|
|
||||||
| Хук | Query key | Stale time | Описание |
|
| Хук | Query key | Stale time | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (enabled: query ≥ 2 символов) |
|
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (primary: `entities/search/model/useSearch.ts`) |
|
||||||
| `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 | Свечи облигации |
|
||||||
|
|
||||||
|
## Primary и legacy paths
|
||||||
|
|
||||||
|
- `useSearch` primary path: `apps/frontend/src/entities/search/model/useSearch.ts`
|
||||||
|
- Public API для search slice: `apps/frontend/src/entities/search/index.ts`
|
||||||
|
- Legacy compatibility path: `apps/frontend/src/hooks/useSearch.ts` — это shim, который
|
||||||
|
реэкспортирует primary hook
|
||||||
|
- Market pages используют `entities/search` как source of truth, а не historical hook path
|
||||||
|
|
||||||
## Конфигурация Query
|
## Конфигурация Query
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -27,11 +35,19 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
## Паттерн hook
|
## Паттерн hook
|
||||||
|
|
||||||
Каждый хук:
|
Для historical hooks и FSD hooks общий принцип один и тот же:
|
||||||
|
|
||||||
1. Вызывает функцию из `api/client.ts`
|
1. Хук вызывает ближайший domain/shared API helper
|
||||||
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
|
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
|
||||||
3. Типизирован через рукописные типы из `api/responses.ts`
|
3. Типизируется через актуальные response types из `shared/api/responses.ts`
|
||||||
|
|
||||||
|
В legacy-слое helper может приходить из historical `api/*`, а в FSD-срезах — из domain API
|
||||||
|
файлов вроде `entities/stock/api/stockApi.ts` или `entities/bond/api/bondApi.ts`.
|
||||||
|
|
||||||
|
Для FSD-срезов domain-specific hooks постепенно переезжают ближе к своим сущностям. Для market
|
||||||
|
pages это уже сделано для `entities/search`, `entities/stock` и `entities/bond`; для portfolio
|
||||||
|
домен уже использует `entities/portfolio`; legacy imports сохраняются только как transitional
|
||||||
|
shim-слой там, где миграция ещё не завершена.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useStock(secid: string) {
|
export function useStock(secid: string) {
|
||||||
|
|||||||
@ -14,6 +14,10 @@ React SPA, собранная с Vite.
|
|||||||
|
|
||||||
## Структура исходников
|
## Структура исходников
|
||||||
|
|
||||||
|
Published-структура ниже описывает primary FSD entrypoints. Historical каталоги `api/`, `context/`,
|
||||||
|
`hooks/`, `components/` ещё присутствуют в кодовой базе, но для уже мигрированных областей они всё
|
||||||
|
чаще играют роль transitional shim-слоя, а не source of truth.
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/frontend/src/
|
apps/frontend/src/
|
||||||
├── main.tsx # Точка входа
|
├── main.tsx # Точка входа
|
||||||
@ -31,51 +35,35 @@ apps/frontend/src/
|
|||||||
│ └── layouts/
|
│ └── layouts/
|
||||||
│ ├── AppLayout.tsx # Шапка + <Outlet/>
|
│ ├── AppLayout.tsx # Шапка + <Outlet/>
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── api/
|
├── shared/
|
||||||
│ ├── auth.ts # re-export shim → entities/session/api
|
│ ├── api/ # shared API client, response types, generated OpenAPI types
|
||||||
│ ├── client.ts # HTTP-клиент (fetch)
|
│ └── ui/ # shared UI primitives without domain logic
|
||||||
│ ├── portfolio.ts # Portfolio API helpers
|
|
||||||
│ ├── responses.ts # Типы ответов (ручные)
|
|
||||||
│ ├── screener.ts # Screener API helpers
|
|
||||||
│ └── types.ts # Типы из openapi-typescript
|
|
||||||
├── context/
|
|
||||||
│ └── AuthContext.tsx # re-export shim → app/providers/SessionProvider
|
|
||||||
├── hooks/
|
|
||||||
│ ├── useAuth.ts # re-export shim → entities/session/model
|
|
||||||
│ ├── usePortfolio.ts
|
|
||||||
│ ├── usePortfolioAnalytics.ts
|
|
||||||
│ ├── usePortfolioMutations.ts
|
|
||||||
│ ├── usePortfolios.ts
|
|
||||||
│ ├── usePositionMutations.ts
|
|
||||||
│ ├── useScreener.ts
|
|
||||||
│ ├── useSearch.ts
|
|
||||||
│ ├── useStock.ts
|
|
||||||
│ ├── useStockCandles.ts
|
|
||||||
│ ├── useStockDividends.ts
|
|
||||||
│ ├── useBond.ts
|
|
||||||
│ └── useBondCandles.ts
|
|
||||||
├── components/
|
|
||||||
│ ├── Layout.tsx # re-export shim → app/layouts
|
|
||||||
│ ├── ProtectedRoute.tsx # re-export shim → app/routing
|
|
||||||
│ ├── SearchBar.tsx
|
|
||||||
│ ├── PriceChart.tsx
|
|
||||||
│ ├── StockDetails.tsx
|
|
||||||
│ ├── BondDetails.tsx
|
|
||||||
│ └── portfolios/
|
|
||||||
├── entities/
|
├── entities/
|
||||||
│ ├── session/ # FSD: auth/session (api, model/context + hook)
|
│ ├── session/ # FSD: auth/session (api, model/context + hook)
|
||||||
|
│ ├── search/ # FSD: market search query slice
|
||||||
|
│ ├── stock/ # FSD: stock api + query hooks
|
||||||
|
│ ├── bond/ # FSD: bond api + query hooks
|
||||||
|
│ ├── portfolio/ # FSD: portfolio api + query hooks
|
||||||
│ ├── broker-account/ # FSD: account slice (api, model, ui)
|
│ ├── broker-account/ # FSD: account slice (api, model, ui)
|
||||||
│ ├── broker-position/ # FSD: position slice (api, model)
|
│ ├── broker-position/ # FSD: position slice (api, model)
|
||||||
│ └── broker-operation/ # FSD: operation slice (api, model)
|
│ └── broker-operation/ # FSD: operation slice (api, model)
|
||||||
├── widgets/
|
├── widgets/
|
||||||
|
│ ├── search-bar/ # FSD: instrument search widget
|
||||||
|
│ ├── price-chart/ # FSD: market chart widget
|
||||||
|
│ ├── stock-details/ # FSD: stock details widget
|
||||||
|
│ ├── bond-details/ # FSD: bond details widget
|
||||||
|
│ ├── dividends-table/ # FSD: stock dividends widget
|
||||||
│ ├── broker-account-card/ # FSD: account card widget
|
│ ├── broker-account-card/ # FSD: account card widget
|
||||||
│ ├── broker-accounts-summary/ # FSD: accounts summary widget
|
│ ├── broker-accounts-summary/ # FSD: accounts summary widget
|
||||||
│ ├── broker-allocation-chart/ # FSD: allocation chart widget
|
│ ├── broker-allocation-chart/ # FSD: allocation chart widget
|
||||||
│ └── broker-operations-table/ # FSD: operations table widget
|
│ └── broker-operations-table/ # FSD: operations table widget
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── HomePage.tsx
|
│ ├── HomePage.tsx # re-export shim → pages/home
|
||||||
│ ├── StockPage.tsx
|
│ ├── StockPage.tsx # re-export shim → pages/stock
|
||||||
│ ├── BondPage.tsx
|
│ ├── BondPage.tsx # re-export shim → pages/bond
|
||||||
|
│ ├── home/ # FSD: home page entrypoint
|
||||||
|
│ ├── stock/ # FSD: stock page entrypoint
|
||||||
|
│ ├── bond/ # FSD: bond page entrypoint
|
||||||
│ ├── LoginPage.tsx
|
│ ├── LoginPage.tsx
|
||||||
│ ├── RegisterPage.tsx
|
│ ├── RegisterPage.tsx
|
||||||
│ ├── ProfilePage.tsx
|
│ ├── ProfilePage.tsx
|
||||||
@ -88,6 +76,10 @@ apps/frontend/src/
|
|||||||
├── test/
|
├── test/
|
||||||
│ ├── handlers.ts # MSW handlers
|
│ ├── handlers.ts # MSW handlers
|
||||||
│ └── test-utils.tsx
|
│ └── test-utils.tsx
|
||||||
|
├── api/ # historical paths + compatibility shims around shared/entities
|
||||||
|
├── context/ # historical compatibility layer
|
||||||
|
├── hooks/ # historical compatibility layer
|
||||||
|
└── components/ # historical compatibility layer
|
||||||
└── styles.css
|
└── styles.css
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -117,6 +109,37 @@ Broker-домен переведён в пилотный FSD срез:
|
|||||||
- `widgets/broker-*` — screen-level композиция
|
- `widgets/broker-*` — screen-level композиция
|
||||||
- `entities/broker-*` — доменные срезы (api, model, ui)
|
- `entities/broker-*` — доменные срезы (api, model, ui)
|
||||||
|
|
||||||
|
### market pages (следующая итерация)
|
||||||
|
|
||||||
|
После broker pilot market-раздел также переведён на FSD 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;
|
||||||
|
- `entities/search` — search query slice;
|
||||||
|
- `entities/stock` и `entities/bond` — query hooks и domain API для market pages.
|
||||||
|
|
||||||
|
При этом orchestration остаётся в page-layer: route params, вычисление диапазонов дат, загрузка
|
||||||
|
данных и loading/not-found состояния не переносятся в widgets.
|
||||||
|
|
||||||
### Остальные домены
|
### Остальные домены
|
||||||
|
|
||||||
`portfolio`, `screener` пока остаются в исторической технической структуре.
|
`screener` в основном остаётся в historical технической структуре. `portfolio` находится в
|
||||||
|
промежуточном состоянии: page-level структура ещё не полностью унифицирована, но domain API/model
|
||||||
|
слой уже вынесен в `entities/portfolio`.
|
||||||
|
|
||||||
|
### Legacy coexistence
|
||||||
|
|
||||||
|
После app/session, broker и market migration в проекте ещё остаются top-level `api/`, `context/`,
|
||||||
|
`hooks/`, `components/` и плоские `pages/*.tsx`. Для уже мигрированных зон они служат главным
|
||||||
|
образом как compatibility shim-слой:
|
||||||
|
|
||||||
|
- `components/SearchBar.tsx` → `widgets/search-bar`
|
||||||
|
- `components/PriceChart.tsx` → `widgets/price-chart`
|
||||||
|
- `components/StockDetails.tsx` → `widgets/stock-details`
|
||||||
|
- `components/BondDetails.tsx` → `widgets/bond-details`
|
||||||
|
- `hooks/useSearch.ts` → `entities/search`
|
||||||
|
- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` → новые page entrypoints
|
||||||
|
|
||||||
|
Published docs описывают primary FSD entrypoints; legacy paths остаются временной поверхностью
|
||||||
|
совместимости.
|
||||||
|
|||||||
@ -1,20 +1,31 @@
|
|||||||
# Маршруты
|
# Маршруты
|
||||||
|
|
||||||
Определены в `apps/frontend/src/routes.tsx`.
|
Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRoutes.tsx`.
|
||||||
|
|
||||||
| Path | Component | Доступ | Описание |
|
| Path | Component | Доступ | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/` | `HomePage` | Public | Главная страница |
|
| `/` | `HomePage` from `pages/home` | Public | Главная страница |
|
||||||
| `/stocks/:secid` | `StockPage` | Public | Страница акции |
|
| `/stocks/:secid` | `StockPage` from `pages/stock` | Public | Страница акции |
|
||||||
| `/bonds/:secid` | `BondPage` | Public | Страница облигации |
|
| `/bonds/:secid` | `BondPage` from `pages/bond` | 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 | Детальная область брокерского счёта |
|
||||||
|
|
||||||
Все страницы обёрнуты в `Layout`, который содержит:
|
Market route entrypoints теперь живут в:
|
||||||
|
|
||||||
|
- `apps/frontend/src/pages/home`
|
||||||
|
- `apps/frontend/src/pages/stock`
|
||||||
|
- `apps/frontend/src/pages/bond`
|
||||||
|
|
||||||
|
Legacy `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` сохранены как shim-файлы,
|
||||||
|
но не являются source of truth для новых импортов.
|
||||||
|
|
||||||
|
Все страницы обёрнуты в `AppLayout`, который содержит:
|
||||||
|
|
||||||
- Шапку с логотипом (ссылка на `/`) и `SearchBar`
|
- Шапку с логотипом (ссылка на `/`) и `SearchBar`
|
||||||
- `<main>` с максимальной шириной 1200px
|
- `<main>` с максимальной шириной 1200px
|
||||||
@ -24,7 +35,7 @@
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<AppLayout />}>
|
||||||
<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 />} />
|
||||||
@ -55,6 +66,27 @@
|
|||||||
</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>
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { SearchBar } from '@/components/SearchBar';
|
import { SearchBar } from '@/widgets/search-bar';
|
||||||
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/HomePage';
|
import { HomePage } from '@/pages/home';
|
||||||
import { StockPage } from '@/pages/StockPage';
|
import { StockPage } from '@/pages/stock';
|
||||||
import { BondPage } from '@/pages/BondPage';
|
import { BondPage } from '@/pages/bond';
|
||||||
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,83 +1 @@
|
|||||||
import type { BondResponse } from '@/shared/api/responses';
|
export { BondDetails } from '@/widgets/bond-details';
|
||||||
|
|
||||||
interface BondDetailsProps {
|
|
||||||
bond: BondResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowStyle: React.CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BondDetails({ bond }: BondDetailsProps) {
|
|
||||||
const md = bond.marketData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
|
|
||||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{bond.isin}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
|
||||||
{md.price?.toFixed(2) ?? '—'}%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Номинал</span>
|
|
||||||
<span>
|
|
||||||
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Дата погашения</span>
|
|
||||||
<span>{bond.matDate}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Купон</span>
|
|
||||||
<span>
|
|
||||||
{md.couponValue ?? '—'} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Период купона</span>
|
|
||||||
<span>{bond.couponPeriod} дней</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Следующий купон</span>
|
|
||||||
<span>{md.nextCouponDate ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>НКД</span>
|
|
||||||
<span>{md.accruedInt?.toFixed(2) ?? '—'} ₽</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Доходность к погашению</span>
|
|
||||||
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Дюрация</span>
|
|
||||||
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Тип</span>
|
|
||||||
<span>{bond.bondType}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>ISIN</span>
|
|
||||||
<span>{bond.isin}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,71 +1 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
export { PriceChart } from '@/widgets/price-chart';
|
||||||
import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts';
|
|
||||||
|
|
||||||
interface PriceChartProps {
|
|
||||||
data: Array<{
|
|
||||||
open: number;
|
|
||||||
high: number;
|
|
||||||
low: number;
|
|
||||||
close: number;
|
|
||||||
begin: string;
|
|
||||||
}>;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriceChart({ data, height = 400 }: PriceChartProps) {
|
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartContainerRef.current) return;
|
|
||||||
|
|
||||||
const chart = createChart(chartContainerRef.current, {
|
|
||||||
layout: {
|
|
||||||
background: { type: ColorType.Solid, color: '#ffffff' },
|
|
||||||
textColor: '#333',
|
|
||||||
},
|
|
||||||
width: chartContainerRef.current.clientWidth,
|
|
||||||
height,
|
|
||||||
grid: {
|
|
||||||
vertLines: { color: '#f0f0f0' },
|
|
||||||
horzLines: { color: '#f0f0f0' },
|
|
||||||
},
|
|
||||||
timeScale: {
|
|
||||||
timeVisible: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const candleSeries = chart.addCandlestickSeries({
|
|
||||||
upColor: '#2e7d32',
|
|
||||||
downColor: '#c62828',
|
|
||||||
borderDownColor: '#c62828',
|
|
||||||
borderUpColor: '#2e7d32',
|
|
||||||
wickDownColor: '#c62828',
|
|
||||||
wickUpColor: '#2e7d32',
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData: CandlestickData[] = data.map((candle) => ({
|
|
||||||
time: (new Date(candle.begin).getTime() / 1000) as Time,
|
|
||||||
open: candle.open,
|
|
||||||
high: candle.high,
|
|
||||||
low: candle.low,
|
|
||||||
close: candle.close,
|
|
||||||
}));
|
|
||||||
|
|
||||||
candleSeries.setData(chartData);
|
|
||||||
chart.timeScale().fitContent();
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
if (chartContainerRef.current) {
|
|
||||||
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
chart.remove();
|
|
||||||
};
|
|
||||||
}, [data, height]);
|
|
||||||
|
|
||||||
return <div ref={chartContainerRef} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,108 +1 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
export { SearchBar } from '@/widgets/search-bar';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useSearch } from '../hooks/useSearch';
|
|
||||||
|
|
||||||
export function SearchBar() {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [debounced, setDebounced] = useState('');
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setTimeout(() => setDebounced(query), 300);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const { data: results, isLoading } = useSearch(debounced);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showResults = open && debounced.length >= 2;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Поиск акций и облигаций..."
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{showResults && (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 4,
|
|
||||||
padding: 0,
|
|
||||||
listStyle: 'none',
|
|
||||||
zIndex: 100,
|
|
||||||
maxHeight: 360,
|
|
||||||
overflowY: 'auto',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
|
|
||||||
{!isLoading && results && results.length === 0 && (
|
|
||||||
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
|
|
||||||
)}
|
|
||||||
{!isLoading &&
|
|
||||||
results?.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.secid}
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
setQuery('');
|
|
||||||
navigate(
|
|
||||||
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: '1px solid #f0f0f0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>{item.shortName}</strong>
|
|
||||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
|
|
||||||
>
|
|
||||||
{item.type === 'share' ? 'Акция' : 'Облигация'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,83 +1 @@
|
|||||||
import type { ShareResponse } from '@/shared/api/responses';
|
export { StockDetails } from '@/widgets/stock-details';
|
||||||
|
|
||||||
interface StockDetailsProps {
|
|
||||||
stock: ShareResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowStyle: React.CSSProperties = {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StockDetails({ stock }: StockDetailsProps) {
|
|
||||||
const md = stock.marketData;
|
|
||||||
const isPositive = (md.change ?? 0) >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
|
|
||||||
{stock.shortName} ({stock.secid})
|
|
||||||
</h2>
|
|
||||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
|
||||||
{stock.name} · {stock.isin}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
|
||||||
{md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 18,
|
|
||||||
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPositive ? '+' : ''}
|
|
||||||
{(md.change ?? 0).toFixed(2)} ({(md.changePercent ?? 0).toFixed(2)}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Открытие</span>
|
|
||||||
<span>{md.open?.toFixed(2) ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Максимум</span>
|
|
||||||
<span>{md.high?.toFixed(2) ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Минимум</span>
|
|
||||||
<span>{md.low?.toFixed(2) ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Объём</span>
|
|
||||||
<span>{md.volume.toLocaleString('ru-RU')}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Капитализация</span>
|
|
||||||
<span>
|
|
||||||
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>ISIN</span>
|
|
||||||
<span>{stock.isin}</span>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<span>Уровень листинга</span>
|
|
||||||
<span>{stock.listLevel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
1
apps/frontend/src/entities/search/index.ts
Normal file
1
apps/frontend/src/entities/search/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useSearch } from './model/useSearch';
|
||||||
72
apps/frontend/src/entities/search/model/useSearch.test.tsx
Normal file
72
apps/frontend/src/entities/search/model/useSearch.test.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { server } from '@/test/server';
|
||||||
|
import { useSearch } from '@/entities/search';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSearch', () => {
|
||||||
|
it('does not fetch when query is empty', () => {
|
||||||
|
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(result.current.isFetching).toBe(false);
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when query is too short', () => {
|
||||||
|
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns search results for valid query', async () => {
|
||||||
|
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isSuccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.data).toBeDefined();
|
||||||
|
expect(result.current.data?.length).toBeGreaterThan(0);
|
||||||
|
expect(result.current.data?.[0].secid).toBe('SBER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no results', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/securities/search`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isSuccess).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error state on network failure', async () => {
|
||||||
|
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/frontend/src/entities/search/model/useSearch.ts
Normal file
16
apps/frontend/src/entities/search/model/useSearch.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { searchSecurities } from '@/shared/api/client';
|
||||||
|
import type { SearchResultItem } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useSearch(query: string) {
|
||||||
|
return useQuery<SearchResultItem[]>({
|
||||||
|
queryKey: ['securities', 'search', query],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await searchSecurities(query);
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: query.length >= 2,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,15 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useSearch } from '@/entities/search';
|
||||||
import { searchSecurities } from '@/shared/api/client';
|
|
||||||
import type { SearchResultItem } from '@/shared/api/responses';
|
|
||||||
|
|
||||||
export function useSearch(query: string) {
|
|
||||||
return useQuery<SearchResultItem[]>({
|
|
||||||
queryKey: ['securities', 'search', query],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await searchSecurities(query);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: query.length >= 2,
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { screen } from '@testing-library/react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../test/server';
|
import { server } from '../test/server';
|
||||||
import { BondPage } from './BondPage';
|
import { BondPage } from '@/pages/bond';
|
||||||
import { renderWithProviders } from '../test/test-utils';
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|||||||
@ -1,33 +1 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
export { BondPage } from '@/pages/bond';
|
||||||
import { useBond, useBondCandles } from '../entities/bond';
|
|
||||||
import { BondDetails } from '../components/BondDetails';
|
|
||||||
import { PriceChart } from '../components/PriceChart';
|
|
||||||
|
|
||||||
export function BondPage() {
|
|
||||||
const { secid } = useParams<{ secid: string }>();
|
|
||||||
const { data: bond, isLoading, error } = useBond(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 } = useBondCandles(secid!, '24h', from, till);
|
|
||||||
|
|
||||||
if (isLoading) return <div>Загрузка...</div>;
|
|
||||||
if (error || !bond) return <div>Инструмент не найден</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<BondDetails bond={bond} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
|
||||||
<PriceChart data={candles ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
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 { HomePage } from './HomePage';
|
import { HomePage } from '@/pages/home';
|
||||||
|
|
||||||
describe('HomePage', () => {
|
describe('HomePage', () => {
|
||||||
it('renders welcome title', () => {
|
it('renders welcome title', () => {
|
||||||
|
|||||||
@ -1,14 +1 @@
|
|||||||
export function HomePage() {
|
export { HomePage } from '@/pages/home';
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', paddingTop: 120 }}>
|
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
|
|
||||||
Анализ акций и облигаций Московской биржи
|
|
||||||
</p>
|
|
||||||
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
|
|
||||||
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
|
|
||||||
Данные задерживаются на 15 минут · Бесплатный API MOEX ISS
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { screen, waitFor } from '@testing-library/react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../test/server';
|
import { server } from '../test/server';
|
||||||
import { StockPage } from './StockPage';
|
import { StockPage } from '@/pages/stock';
|
||||||
import { renderWithProviders } from '../test/test-utils';
|
import { renderWithProviders } from '../test/test-utils';
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|||||||
@ -1,65 +1 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
export { StockPage } from '@/pages/stock';
|
||||||
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
apps/frontend/src/pages/bond/index.ts
Normal file
1
apps/frontend/src/pages/bond/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BondPage } from './ui/BondPage';
|
||||||
33
apps/frontend/src/pages/bond/ui/BondPage.tsx
Normal file
33
apps/frontend/src/pages/bond/ui/BondPage.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useBond, useBondCandles } from '@/entities/bond';
|
||||||
|
import { BondDetails } from '@/widgets/bond-details';
|
||||||
|
import { PriceChart } from '@/widgets/price-chart';
|
||||||
|
|
||||||
|
export function BondPage() {
|
||||||
|
const { secid } = useParams<{ secid: string }>();
|
||||||
|
const { data: bond, isLoading, error } = useBond(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 } = useBondCandles(secid!, '24h', from, till);
|
||||||
|
|
||||||
|
if (isLoading) return <div>Загрузка...</div>;
|
||||||
|
if (error || !bond) return <div>Инструмент не найден</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<BondDetails bond={bond} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||||||
|
<PriceChart data={candles ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/pages/home/index.ts
Normal file
1
apps/frontend/src/pages/home/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { HomePage } from './ui/HomePage';
|
||||||
14
apps/frontend/src/pages/home/ui/HomePage.tsx
Normal file
14
apps/frontend/src/pages/home/ui/HomePage.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export function HomePage() {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', paddingTop: 120 }}>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
|
||||||
|
Анализ акций и облигаций Московской биржи
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
|
||||||
|
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
|
||||||
|
Данные задерживаются на 15 минут · Бесплатный API MOEX ISS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/pages/stock/index.ts
Normal file
1
apps/frontend/src/pages/stock/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { StockPage } from './ui/StockPage';
|
||||||
37
apps/frontend/src/pages/stock/ui/StockPage.tsx
Normal file
37
apps/frontend/src/pages/stock/ui/StockPage.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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/widgets/bond-details/index.ts
Normal file
1
apps/frontend/src/widgets/bond-details/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BondDetails } from './ui/BondDetails';
|
||||||
124
apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx
Normal file
124
apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { BondDetails } from './BondDetails';
|
||||||
|
import { createMockBond } from '@/test/factories';
|
||||||
|
|
||||||
|
describe('BondDetails', () => {
|
||||||
|
it('renders bond details', () => {
|
||||||
|
const bond = createMockBond();
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('RU000A101XU7').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows price as percentage', () => {
|
||||||
|
const bond = createMockBond();
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
expect(screen.getByText('98.50%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders maturity date', () => {
|
||||||
|
const bond = createMockBond();
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
expect(screen.getByText('2027-05-15')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows coupon with percentage', () => {
|
||||||
|
const bond = createMockBond();
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows coupon without percentage when null', () => {
|
||||||
|
const bond = createMockBond({
|
||||||
|
marketData: {
|
||||||
|
...createMockBond().marketData,
|
||||||
|
couponPercent: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for missing next coupon date', () => {
|
||||||
|
const bond = createMockBond({
|
||||||
|
marketData: {
|
||||||
|
...createMockBond().marketData,
|
||||||
|
nextCouponDate: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for null yieldToMaturity', () => {
|
||||||
|
const bond = createMockBond({
|
||||||
|
marketData: {
|
||||||
|
...createMockBond().marketData,
|
||||||
|
yieldToMaturity: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
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', () => {
|
||||||
|
const bond = createMockBond();
|
||||||
|
render(<BondDetails bond={bond} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx
Normal file
83
apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { BondResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
interface BondDetailsProps {
|
||||||
|
bond: BondResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BondDetails({ bond }: BondDetailsProps) {
|
||||||
|
const md = bond.marketData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{bond.isin}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
||||||
|
{md.price?.toFixed(2) ?? '—'}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Номинал</span>
|
||||||
|
<span>
|
||||||
|
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Дата погашения</span>
|
||||||
|
<span>{bond.matDate}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Купон</span>
|
||||||
|
<span>
|
||||||
|
{md.couponValue ?? '—'} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Период купона</span>
|
||||||
|
<span>{bond.couponPeriod} дней</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Следующий купон</span>
|
||||||
|
<span>{md.nextCouponDate ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>НКД</span>
|
||||||
|
<span>{md.accruedInt?.toFixed(2) ?? '—'} ₽</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Доходность к погашению</span>
|
||||||
|
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Дюрация</span>
|
||||||
|
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Тип</span>
|
||||||
|
<span>{bond.bondType}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>ISIN</span>
|
||||||
|
<span>{bond.isin}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/widgets/dividends-table/index.ts
Normal file
1
apps/frontend/src/widgets/dividends-table/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { DividendsTable } from './ui/DividendsTable';
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
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
apps/frontend/src/widgets/price-chart/index.ts
Normal file
1
apps/frontend/src/widgets/price-chart/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { PriceChart } from './ui/PriceChart';
|
||||||
21
apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx
Normal file
21
apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { PriceChart } from './PriceChart';
|
||||||
|
|
||||||
|
describe('PriceChart', () => {
|
||||||
|
it('renders chart container with empty data', () => {
|
||||||
|
const { container } = render(<PriceChart data={[]} />);
|
||||||
|
expect(container.querySelector('div')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with candle data', () => {
|
||||||
|
const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }];
|
||||||
|
const { container } = render(<PriceChart data={data} />);
|
||||||
|
expect(container.querySelector('div')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom height', () => {
|
||||||
|
const { container } = render(<PriceChart data={[]} height={600} />);
|
||||||
|
expect(container.querySelector('div')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx
Normal file
71
apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts';
|
||||||
|
|
||||||
|
interface PriceChartProps {
|
||||||
|
data: Array<{
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
begin: string;
|
||||||
|
}>;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceChart({ data, height = 400 }: PriceChartProps) {
|
||||||
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartContainerRef.current) return;
|
||||||
|
|
||||||
|
const chart = createChart(chartContainerRef.current, {
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: '#ffffff' },
|
||||||
|
textColor: '#333',
|
||||||
|
},
|
||||||
|
width: chartContainerRef.current.clientWidth,
|
||||||
|
height,
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: '#f0f0f0' },
|
||||||
|
horzLines: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
timeVisible: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: '#2e7d32',
|
||||||
|
downColor: '#c62828',
|
||||||
|
borderDownColor: '#c62828',
|
||||||
|
borderUpColor: '#2e7d32',
|
||||||
|
wickDownColor: '#c62828',
|
||||||
|
wickUpColor: '#2e7d32',
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData: CandlestickData[] = data.map((candle) => ({
|
||||||
|
time: (new Date(candle.begin).getTime() / 1000) as Time,
|
||||||
|
open: candle.open,
|
||||||
|
high: candle.high,
|
||||||
|
low: candle.low,
|
||||||
|
close: candle.close,
|
||||||
|
}));
|
||||||
|
|
||||||
|
candleSeries.setData(chartData);
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (chartContainerRef.current) {
|
||||||
|
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
chart.remove();
|
||||||
|
};
|
||||||
|
}, [data, height]);
|
||||||
|
|
||||||
|
return <div ref={chartContainerRef} />;
|
||||||
|
}
|
||||||
1
apps/frontend/src/widgets/search-bar/index.ts
Normal file
1
apps/frontend/src/widgets/search-bar/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { SearchBar } from './ui/SearchBar';
|
||||||
90
apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx
Normal file
90
apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { server } from '@/test/server';
|
||||||
|
import { SearchBar } from '@/widgets/search-bar';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderSearchBar() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<SearchBar />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SearchBar', () => {
|
||||||
|
it('renders search input', () => {
|
||||||
|
renderSearchBar();
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dropdown on focus', async () => {
|
||||||
|
renderSearchBar();
|
||||||
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state while fetching', async () => {
|
||||||
|
server.use(http.get(`${API}/securities/search`, () => new Promise(() => {})));
|
||||||
|
renderSearchBar();
|
||||||
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no results message', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API}/securities/search`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderSearchBar();
|
||||||
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'zzzzz');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides dropdown when clicking outside', async () => {
|
||||||
|
renderSearchBar();
|
||||||
|
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'sber');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Сбер')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(document.body);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx
Normal file
113
apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSearch } from '@/entities/search';
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debounced, setDebounced] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(query), 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const { data: results, isLoading } = useSearch(debounced);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showResults = open && debounced.length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск акций и облигаций..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showResults && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
zIndex: 100,
|
||||||
|
maxHeight: 360,
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
|
||||||
|
{!isLoading && results && results.length === 0 && (
|
||||||
|
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
results?.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.secid}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
navigate(
|
||||||
|
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{item.shortName}</strong>
|
||||||
|
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
|
||||||
|
>
|
||||||
|
{item.type === 'share' ? 'Акция' : 'Облигация'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/widgets/stock-details/index.ts
Normal file
1
apps/frontend/src/widgets/stock-details/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { StockDetails } from './ui/StockDetails';
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StockDetails } from './StockDetails';
|
||||||
|
import { createMockShare } from '@/test/factories';
|
||||||
|
|
||||||
|
describe('StockDetails', () => {
|
||||||
|
it('renders stock details', () => {
|
||||||
|
const stock = createMockShare();
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays positive change in green', () => {
|
||||||
|
const stock = createMockShare({
|
||||||
|
marketData: {
|
||||||
|
...createMockShare().marketData,
|
||||||
|
change: 5,
|
||||||
|
changePercent: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
const changeEl = screen.getByText('+5.00 (2.00%)');
|
||||||
|
expect(changeEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays negative change in red', () => {
|
||||||
|
const stock = createMockShare({
|
||||||
|
marketData: {
|
||||||
|
...createMockShare().marketData,
|
||||||
|
change: -3,
|
||||||
|
changePercent: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
const changeEl = screen.getByText('-3.00 (-1.00%)');
|
||||||
|
expect(changeEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows price formatted', () => {
|
||||||
|
const stock = createMockShare();
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
expect(screen.getByText('289,50')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for null high', () => {
|
||||||
|
const stock = createMockShare({
|
||||||
|
marketData: {
|
||||||
|
...createMockShare().marketData,
|
||||||
|
high: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows capitalization in billions', () => {
|
||||||
|
const stock = createMockShare();
|
||||||
|
render(<StockDetails stock={stock} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx
Normal file
85
apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { ShareResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
interface StockDetailsProps {
|
||||||
|
stock: ShareResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StockDetails({ stock }: StockDetailsProps) {
|
||||||
|
const md = stock.marketData;
|
||||||
|
const change = md.change ?? 0;
|
||||||
|
const changePercent = md.changePercent ?? 0;
|
||||||
|
const isPositive = change >= 0;
|
||||||
|
const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
|
||||||
|
{stock.shortName} ({stock.secid})
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{stock.name} · {stock.isin}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
||||||
|
{md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{changeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Открытие</span>
|
||||||
|
<span>{md.open?.toFixed(2) ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Максимум</span>
|
||||||
|
<span>{md.high?.toFixed(2) ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Минимум</span>
|
||||||
|
<span>{md.low?.toFixed(2) ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Объём</span>
|
||||||
|
<span>{md.volume.toLocaleString('ru-RU')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Капитализация</span>
|
||||||
|
<span>
|
||||||
|
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>ISIN</span>
|
||||||
|
<span>{stock.isin}</span>
|
||||||
|
</div>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>Уровень листинга</span>
|
||||||
|
<span>{stock.listLevel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
docs/features/frontend-fsd-market-pages/plan.md
Normal file
194
docs/features/frontend-fsd-market-pages/plan.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# 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 синхронизированы с новой структурой.
|
||||||
125
docs/features/frontend-fsd-market-pages/spec.md
Normal file
125
docs/features/frontend-fsd-market-pages/spec.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# 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`).
|
||||||
|
- Переписывание стилей или визуального дизайна.
|
||||||
|
- Добавление новой функциональности.
|
||||||
45
docs/features/frontend-fsd-market-pages/tasks.md
Normal file
45
docs/features/frontend-fsd-market-pages/tasks.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 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