Compare commits
24 Commits
52ebe3256d
...
20f0efa083
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f0efa083 | |||
| 8e9fdbe70a | |||
| af36d2e7cf | |||
| ad8ff6a875 | |||
| 946383bde0 | |||
| 9c115f0e16 | |||
| dd727f9fb2 | |||
| 93d18f1218 | |||
| 23a077536c | |||
| d95b2df460 | |||
| 9816bc6b96 | |||
| 5355451e35 | |||
| a9132109da | |||
| df4b778d95 | |||
| 4db95146a7 | |||
| b02ee9cd74 | |||
| 00f01fae4f | |||
| 09213624c3 | |||
| 8e578e7a91 | |||
| 1db1763b36 | |||
| 55cc479abb | |||
| a03e3c0240 | |||
| 9287b97880 | |||
| 3b618d3da0 |
19
AGENTS.md
19
AGENTS.md
@ -40,7 +40,7 @@
|
|||||||
## Обязательный подход к разработке
|
## Обязательный подход к разработке
|
||||||
|
|
||||||
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||||
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||||
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||||
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
|
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
|
||||||
|
|
||||||
@ -204,6 +204,11 @@ docs/
|
|||||||
|
|
||||||
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
|
Для исторической фичи сначала прочитать все имеющиеся артефакты. Отсутствие старого `plan.md` или `tasks.md` само по себе не блокирует maintenance или исправление бага и не требует создавать их задним числом. Для нового расширения такой фичи сначала подготовить недостающие артефакты в объёме текущего изменения.
|
||||||
|
|
||||||
|
Если есть согласованный `plan.md` для многошаговой реализации, агент по умолчанию должен
|
||||||
|
предпочитать `superpowers:subagent-driven-development`. `superpowers:executing-plans` использовать
|
||||||
|
только когда пользователь явно просит inline-исполнение или когда задачи настолько тесно связаны,
|
||||||
|
что разбиение по subagent-циклам ухудшит надёжность и скорость.
|
||||||
|
|
||||||
### Работа с новыми идеями
|
### Работа с новыми идеями
|
||||||
|
|
||||||
Если во время реализации появилась новая идея:
|
Если во время реализации появилась новая идея:
|
||||||
@ -264,6 +269,18 @@ Portfolio Dashboard
|
|||||||
|
|
||||||
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
|
Если информации недостаточно — остановиться и запросить уточнение вместо того, чтобы делать предположения.
|
||||||
|
|
||||||
|
### Pre-flight checklist (обязателен перед реализацией любой фичи)
|
||||||
|
|
||||||
|
Агент не имеет права начать реализацию, пока не выполнены все пункты:
|
||||||
|
|
||||||
|
- [ ] Feature branch создана: `codex/<feature-name>`
|
||||||
|
- [ ] spec.md написана и утверждена пользователем
|
||||||
|
- [ ] plan.md написан и утверждён пользователем
|
||||||
|
- [ ] tasks.md создан с чекбоксами до начала работы
|
||||||
|
- [ ] Все тесты проходят на текущем состоянии
|
||||||
|
|
||||||
|
Нарушение любого пункта = остановиться и вернуться к пропущенному шагу.
|
||||||
|
|
||||||
### Anti-Loop: лимит на итерации
|
### Anti-Loop: лимит на итерации
|
||||||
|
|
||||||
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
|
Если после 3 последовательных неудачных попыток исправить одну и ту же проблему в рамках одной гипотезы симптом не изменился — остановиться и запросить помощь у пользователя.
|
||||||
|
|||||||
69
apps/docs/docs/adr/ADR-013-frontend-fsd-broker-pilot.md
Normal file
69
apps/docs/docs/adr/ADR-013-frontend-fsd-broker-pilot.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# ADR-013: Frontend FSD broker pilot
|
||||||
|
|
||||||
|
**Статус:** Accepted
|
||||||
|
**Дата:** 2026-06-20
|
||||||
|
**Участники решения:** Product Engineer, Tech Lead
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Frontend проекта вырос вокруг технических каталогов (`api`, `hooks`, `components`, `pages`). Для
|
||||||
|
broker-домена это уже приводит к смешению экранной композиции, доменной read-model логики,
|
||||||
|
query-хуков и локальных UI-блоков. В `docs/inbox.md` зафиксировано направление на переход к
|
||||||
|
Feature-Sliced Design, но без big-bang переписывания всего frontend.
|
||||||
|
|
||||||
|
Нужна стратегия, которая:
|
||||||
|
|
||||||
|
- уменьшит локальный техдолг в broker UI;
|
||||||
|
- не развернёт крупный рефакторинг всего frontend сразу;
|
||||||
|
- даст практический эталон для следующих доменов;
|
||||||
|
- не приведёт к ложным абстракциям ради формального следования FSD.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
### Вариант A. Вертикальный broker pilot
|
||||||
|
|
||||||
|
Перенести только broker-домен в FSD-слои, включая pages, widgets, entities и связанные
|
||||||
|
broker-specific query/read-model части. Остальные домены оставить в текущей структуре. Public API
|
||||||
|
зафиксировать сразу, а import guards для всего проекта отложить на следующий шаг.
|
||||||
|
|
||||||
|
### Вариант B. FSD-shell без переноса доменной query-логики
|
||||||
|
|
||||||
|
Создать новые FSD-папки только для экранной композиции, но оставить broker API и hooks в
|
||||||
|
глобальных `src/api` и `src/hooks`.
|
||||||
|
|
||||||
|
### Вариант C. Массовая frontend-миграция
|
||||||
|
|
||||||
|
Одним изменением переносить broker и соседние домены, одновременно нормализуя shared infrastructure
|
||||||
|
и глобальные import boundaries.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Принят вариант A: выполнить вертикальный FSD-пилот только для broker-домена и использовать его как
|
||||||
|
первый эталонный срез новой frontend-архитектуры.
|
||||||
|
|
||||||
|
Решение включает следующие принципы:
|
||||||
|
|
||||||
|
- переход ограничен broker-доменом и не расширяется автоматически на остальные разделы;
|
||||||
|
- page entrypoints остаются тонкими и не содержат тяжёлую доменную композицию;
|
||||||
|
- screen-level блоки выделяются отдельно от route entrypoints;
|
||||||
|
- broker account, broker position и broker operation оформляются как отдельные доменные срезы;
|
||||||
|
- broker-specific query/hooks/read-model логика переносится ближе к соответствующим срезам;
|
||||||
|
- truly shared инфраструктура остаётся в shared-области;
|
||||||
|
- взаимодействие между срезами строится через public API;
|
||||||
|
- жёсткие import guards и глобальные lint-ограничения выносятся в следующий отдельный шаг после
|
||||||
|
стабилизации пилота.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- У frontend появится рабочий пример FSD-структуры, на который можно опираться при переносе других
|
||||||
|
доменов.
|
||||||
|
- Масштаб изменения остаётся управляемым и проверяемым без big-bang миграции.
|
||||||
|
- Некоторое время в проекте будут сосуществовать старая техническая структура и новый FSD-срез.
|
||||||
|
- До отдельного шага с import guards архитектурные границы поддерживаются договорённостью,
|
||||||
|
структурой кода и code review, а не автоматическим правилом.
|
||||||
|
- Документацию frontend потребуется обновить после реализации пилота, чтобы опубликованная
|
||||||
|
структура не расходилась с кодом.
|
||||||
|
- Для поддержки обратной совместимости со старыми импортами `entities/broker-position/index.ts`
|
||||||
|
реэкспортирует отдельные operation-типы (`BROKER_OPERATION_TYPE_OPTIONS`, `isBrokerOperationType`
|
||||||
|
и др.) из `entities/broker-operation`. Это transitional артефакт shim-стратегии, который будет
|
||||||
|
устранён при следующей итерации миграции после отключения старых entrypoints.
|
||||||
@ -14,5 +14,6 @@
|
|||||||
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
||||||
| [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 находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||||
|
|||||||
@ -17,19 +17,31 @@ React SPA, собранная с Vite.
|
|||||||
```
|
```
|
||||||
apps/frontend/src/
|
apps/frontend/src/
|
||||||
├── main.tsx # Точка входа
|
├── main.tsx # Точка входа
|
||||||
├── App.tsx # BrowserRouter
|
├── app/ # FSD app layer
|
||||||
├── routes.tsx # Маршруты
|
│ ├── App.tsx # BrowserRouter → AppRoutes
|
||||||
|
│ ├── index.ts # barrel
|
||||||
|
│ ├── providers/
|
||||||
|
│ │ ├── AppProviders.tsx # QueryClientProvider + SessionProvider
|
||||||
|
│ │ ├── SessionProvider.tsx
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── routing/
|
||||||
|
│ │ ├── AppRoutes.tsx # Все маршруты
|
||||||
|
│ │ ├── ProtectedRoute.tsx
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── layouts/
|
||||||
|
│ ├── AppLayout.tsx # Шапка + <Outlet/>
|
||||||
|
│ └── index.ts
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── auth.ts # Auth API helpers
|
│ ├── auth.ts # re-export shim → entities/session/api
|
||||||
│ ├── client.ts # HTTP-клиент (fetch)
|
│ ├── client.ts # HTTP-клиент (fetch)
|
||||||
│ ├── portfolio.ts # Portfolio API helpers
|
│ ├── portfolio.ts # Portfolio API helpers
|
||||||
│ ├── responses.ts # Типы ответов (ручные)
|
│ ├── responses.ts # Типы ответов (ручные)
|
||||||
│ ├── screener.ts # Screener API helpers
|
│ ├── screener.ts # Screener API helpers
|
||||||
│ └── types.ts # Типы из openapi-typescript
|
│ └── types.ts # Типы из openapi-typescript
|
||||||
├── context/
|
├── context/
|
||||||
│ └── AuthContext.tsx
|
│ └── AuthContext.tsx # re-export shim → app/providers/SessionProvider
|
||||||
├── hooks/
|
├── hooks/
|
||||||
│ ├── useAuth.ts
|
│ ├── useAuth.ts # re-export shim → entities/session/model
|
||||||
│ ├── usePortfolio.ts
|
│ ├── usePortfolio.ts
|
||||||
│ ├── usePortfolioAnalytics.ts
|
│ ├── usePortfolioAnalytics.ts
|
||||||
│ ├── usePortfolioMutations.ts
|
│ ├── usePortfolioMutations.ts
|
||||||
@ -43,13 +55,23 @@ apps/frontend/src/
|
|||||||
│ ├── useBond.ts
|
│ ├── useBond.ts
|
||||||
│ └── useBondCandles.ts
|
│ └── useBondCandles.ts
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Layout.tsx
|
│ ├── Layout.tsx # re-export shim → app/layouts
|
||||||
│ ├── ProtectedRoute.tsx
|
│ ├── ProtectedRoute.tsx # re-export shim → app/routing
|
||||||
│ ├── SearchBar.tsx
|
│ ├── SearchBar.tsx
|
||||||
│ ├── PriceChart.tsx
|
│ ├── PriceChart.tsx
|
||||||
│ ├── StockDetails.tsx
|
│ ├── StockDetails.tsx
|
||||||
│ ├── BondDetails.tsx
|
│ ├── BondDetails.tsx
|
||||||
│ └── portfolios/
|
│ └── portfolios/
|
||||||
|
├── entities/
|
||||||
|
│ ├── session/ # FSD: auth/session (api, model/context + hook)
|
||||||
|
│ ├── broker-account/ # FSD: account slice (api, model, ui)
|
||||||
|
│ ├── broker-position/ # FSD: position slice (api, model)
|
||||||
|
│ └── broker-operation/ # FSD: operation slice (api, model)
|
||||||
|
├── widgets/
|
||||||
|
│ ├── broker-account-card/ # FSD: account card widget
|
||||||
|
│ ├── broker-accounts-summary/ # FSD: accounts summary widget
|
||||||
|
│ ├── broker-allocation-chart/ # FSD: allocation chart widget
|
||||||
|
│ └── broker-operations-table/ # FSD: operations table widget
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── HomePage.tsx
|
│ ├── HomePage.tsx
|
||||||
│ ├── StockPage.tsx
|
│ ├── StockPage.tsx
|
||||||
@ -58,13 +80,43 @@ apps/frontend/src/
|
|||||||
│ ├── RegisterPage.tsx
|
│ ├── RegisterPage.tsx
|
||||||
│ ├── ProfilePage.tsx
|
│ ├── ProfilePage.tsx
|
||||||
│ ├── portfolios/
|
│ ├── portfolios/
|
||||||
│ └── screener/
|
│ ├── screener/
|
||||||
|
│ ├── broker-accounts/ # FSD: page entrypoint
|
||||||
|
│ ├── broker-account/ # FSD: page entrypoint
|
||||||
|
│ ├── broker-positions/ # FSD: page entrypoint
|
||||||
|
│ └── broker-operations/ # FSD: page entrypoint
|
||||||
├── test/
|
├── test/
|
||||||
│ ├── handlers.ts # MSW handlers
|
│ ├── handlers.ts # MSW handlers
|
||||||
│ └── test-utils.tsx
|
│ └── test-utils.tsx
|
||||||
└── styles.css
|
└── styles.css
|
||||||
```
|
```
|
||||||
|
|
||||||
## Точка входа
|
## FSD-миграция
|
||||||
|
|
||||||
`main.tsx` рендерит `App.tsx`, который содержит `BrowserRouter` → `AppRoutes`.
|
### app-слой и session-сущность
|
||||||
|
|
||||||
|
Создан `app/` слой FSD, в который перенесены инфраструктурные модули:
|
||||||
|
|
||||||
|
- `app/providers/` — композиция провайдеров (SessionProvider, QueryClientProvider)
|
||||||
|
- `app/routing/` — маршруты и ProtectedRoute
|
||||||
|
- `app/layouts/` — AppLayout (шапка + Outlet)
|
||||||
|
- `app/App.tsx` — BrowserRouter → AppRoutes
|
||||||
|
|
||||||
|
Домен auth переведён в `entities/session/`:
|
||||||
|
|
||||||
|
- `entities/session/api/` — login, register, logout, refresh, getMe, updateProfile
|
||||||
|
- `entities/session/model/` — SessionContext, useSession
|
||||||
|
|
||||||
|
Старые файлы (`api/auth.ts`, `hooks/useAuth.ts`, `context/AuthContext.tsx`, `routes.tsx`, `components/Layout.tsx`, `components/ProtectedRoute.tsx`, `App.tsx`) сохранены как ре-экспорт шмы для обратной совместимости.
|
||||||
|
|
||||||
|
### broker-домен (пилот)
|
||||||
|
|
||||||
|
Broker-домен переведён в пилотный FSD срез:
|
||||||
|
|
||||||
|
- `pages/broker-*` — тонкие route entrypoints
|
||||||
|
- `widgets/broker-*` — screen-level композиция
|
||||||
|
- `entities/broker-*` — доменные срезы (api, model, ui)
|
||||||
|
|
||||||
|
### Остальные домены
|
||||||
|
|
||||||
|
`portfolio`, `screener` пока остаются в исторической технической структуре.
|
||||||
|
|||||||
@ -1,10 +1 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
export { default } from './app/App';
|
||||||
import { AppRoutes } from './routes';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,52 +1,8 @@
|
|||||||
import { request, setAccessToken } from './client';
|
export {
|
||||||
import type { AuthResponse, UserResponse } from './responses';
|
login,
|
||||||
|
register,
|
||||||
export async function login(email: string, password: string) {
|
refresh,
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
logout,
|
||||||
method: 'POST',
|
getMe,
|
||||||
body: { email, password },
|
updateProfile,
|
||||||
skipAuth: true,
|
} from '../entities/session/api/sessionApi';
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(email: string, password: string, name?: string) {
|
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { email, password, name },
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh() {
|
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
setAccessToken(result.data.accessToken);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
setAccessToken(null);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMe() {
|
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me');
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProfile(data: { name?: string }) {
|
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,232 +1,8 @@
|
|||||||
import type {
|
export {
|
||||||
ApiEnvelope,
|
request,
|
||||||
ApiResponseMeta,
|
setAccessToken,
|
||||||
AuthResponse,
|
getAccessToken,
|
||||||
ShareResponse,
|
setOnUnauthorized,
|
||||||
StockMarketData,
|
getHealth,
|
||||||
DividendItem,
|
searchSecurities,
|
||||||
ShareHistoryItem,
|
} from '../shared/api/client';
|
||||||
BondResponse,
|
|
||||||
BondMarketData,
|
|
||||||
BondHistoryItem,
|
|
||||||
CandleItem,
|
|
||||||
SearchResultItem,
|
|
||||||
HealthResponse,
|
|
||||||
} from './responses';
|
|
||||||
|
|
||||||
const BASE = '';
|
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
|
||||||
let onUnauthorized: (() => void) | null = null;
|
|
||||||
let isRefreshing = false;
|
|
||||||
let refreshPromise: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
export function setAccessToken(token: string | null) {
|
|
||||||
accessToken = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOnUnauthorized(cb: () => void) {
|
|
||||||
onUnauthorized = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokens(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) return false;
|
|
||||||
const json = await res.json();
|
|
||||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
|
||||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
|
||||||
if (
|
|
||||||
envelope.data &&
|
|
||||||
typeof envelope.data === 'object' &&
|
|
||||||
'data' in envelope.data &&
|
|
||||||
'meta' in envelope.data
|
|
||||||
) {
|
|
||||||
return envelope.data as { data: T; meta: ApiResponseMeta };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: envelope.data as T,
|
|
||||||
meta: envelope.meta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUnauthorized(): Promise<boolean> {
|
|
||||||
if (isRefreshing && refreshPromise) {
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
refreshPromise = refreshTokens().then((success) => {
|
|
||||||
isRefreshing = false;
|
|
||||||
refreshPromise = null;
|
|
||||||
return success;
|
|
||||||
});
|
|
||||||
|
|
||||||
return refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function request<T>(
|
|
||||||
path: string,
|
|
||||||
params?: Record<string, string | undefined>,
|
|
||||||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
|
||||||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
|
||||||
if (params) {
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
if (v !== undefined) url.searchParams.set(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (!options?.skipAuth && accessToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
if (options?.body && !(options.body instanceof FormData)) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
headers,
|
|
||||||
credentials: 'include' as RequestCredentials,
|
|
||||||
};
|
|
||||||
if (options?.method) {
|
|
||||||
fetchOptions.method = options.method;
|
|
||||||
}
|
|
||||||
if (options?.body !== undefined) {
|
|
||||||
fetchOptions.body =
|
|
||||||
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = await fetch(url.toString(), fetchOptions);
|
|
||||||
|
|
||||||
if (res.status === 401 && !options?.skipAuth) {
|
|
||||||
const refreshed = await handleUnauthorized();
|
|
||||||
if (refreshed) {
|
|
||||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
|
||||||
} else {
|
|
||||||
accessToken = null;
|
|
||||||
onUnauthorized?.();
|
|
||||||
throw new Error('Сессия истекла');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
return normalizeEnvelope<T>(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
|
||||||
return request<HealthResponse>('/api/v1/health');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchSecurities(
|
|
||||||
q: string,
|
|
||||||
type: 'all' | 'share' | 'bond' = 'all',
|
|
||||||
limit = 20,
|
|
||||||
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
|
||||||
q,
|
|
||||||
type,
|
|
||||||
limit: String(limit),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
|
||||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShareMarketData(
|
|
||||||
secid: string,
|
|
||||||
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
|
||||||
return request<StockMarketData>(
|
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShareDividends(
|
|
||||||
secid: string,
|
|
||||||
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<DividendItem[]>(
|
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShareHistory(
|
|
||||||
secid: string,
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<ShareHistoryItem[]>(
|
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
|
||||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBondMarketData(
|
|
||||||
secid: string,
|
|
||||||
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
|
||||||
return request<BondMarketData>(
|
|
||||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBondHistory(
|
|
||||||
secid: string,
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<BondHistoryItem[]>(
|
|
||||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
|
||||||
{ from, till },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShareCandles(
|
|
||||||
secid: string,
|
|
||||||
interval: '1h' | '24h',
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
|
|
||||||
interval,
|
|
||||||
from,
|
|
||||||
till,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBondCandles(
|
|
||||||
secid: string,
|
|
||||||
interval: '1h' | '24h',
|
|
||||||
from: string,
|
|
||||||
till: string,
|
|
||||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
|
||||||
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
|
|
||||||
interval,
|
|
||||||
from,
|
|
||||||
till,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,93 +1,11 @@
|
|||||||
import { request } from './client';
|
export {
|
||||||
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from './responses';
|
getPortfolios,
|
||||||
|
getPortfolio,
|
||||||
export function getPortfolios(): Promise<{
|
createPortfolio,
|
||||||
data: Portfolio[];
|
updatePortfolio,
|
||||||
meta: { cachedAt: string | null; fromCache: boolean };
|
deletePortfolio,
|
||||||
}> {
|
addPosition,
|
||||||
return request<Portfolio[]>('/api/v1/portfolios');
|
updatePosition,
|
||||||
}
|
removePosition,
|
||||||
|
getPortfolioAnalytics,
|
||||||
export function getPortfolio(
|
} from '../entities/portfolio/api/portfolioApi';
|
||||||
id: number,
|
|
||||||
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPortfolio(data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
currency?: string;
|
|
||||||
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePortfolio(
|
|
||||||
id: number,
|
|
||||||
data: { name?: string; description?: string; currency?: string },
|
|
||||||
): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deletePortfolio(
|
|
||||||
id: number,
|
|
||||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addPosition(
|
|
||||||
portfolioId: number,
|
|
||||||
data: {
|
|
||||||
secid: string;
|
|
||||||
quantity: number;
|
|
||||||
buyPrice?: number;
|
|
||||||
buyDate?: string;
|
|
||||||
notes?: string;
|
|
||||||
tags?: string[];
|
|
||||||
},
|
|
||||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePosition(
|
|
||||||
portfolioId: number,
|
|
||||||
positionId: number,
|
|
||||||
data: {
|
|
||||||
quantity?: number;
|
|
||||||
buyPrice?: number;
|
|
||||||
buyDate?: string;
|
|
||||||
notes?: string;
|
|
||||||
tags?: string[];
|
|
||||||
},
|
|
||||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removePosition(
|
|
||||||
portfolioId: number,
|
|
||||||
positionId: number,
|
|
||||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPortfolioAnalytics(
|
|
||||||
portfolioId: number,
|
|
||||||
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
|
||||||
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,352 +1,32 @@
|
|||||||
export interface ApiResponseMeta {
|
export type {
|
||||||
cachedAt: string | null;
|
ApiResponseMeta,
|
||||||
fromCache: boolean;
|
ApiEnvelope,
|
||||||
}
|
StockMarketData,
|
||||||
|
ShareResponse,
|
||||||
export interface ApiEnvelope<T> {
|
DividendItem,
|
||||||
data: T;
|
ShareHistoryItem,
|
||||||
meta: ApiResponseMeta;
|
BondMarketData,
|
||||||
}
|
BondResponse,
|
||||||
|
BondHistoryItem,
|
||||||
export interface StockMarketData {
|
CandleItem,
|
||||||
price: number | null;
|
SearchResultItem,
|
||||||
change: number | null;
|
HealthResponse,
|
||||||
changePercent: number | null;
|
UserResponse,
|
||||||
open: number | null;
|
AuthResponse,
|
||||||
high: number | null;
|
Portfolio,
|
||||||
low: number | null;
|
PositionWithPrice,
|
||||||
volume: number;
|
PortfolioDetail,
|
||||||
value: number;
|
Position,
|
||||||
issueCapitalization: number | null;
|
PortfolioSummary,
|
||||||
updatedAt: string;
|
AnalyticsResponse,
|
||||||
}
|
ScreenerItem,
|
||||||
|
ScreenerResult,
|
||||||
export interface ShareResponse {
|
BrokerMoney,
|
||||||
secid: string;
|
BrokerAccount,
|
||||||
isin: string;
|
BrokerPosition,
|
||||||
name: string;
|
BrokerPortfolio,
|
||||||
shortName: string;
|
BrokerOperationCategory,
|
||||||
latName: string | null;
|
BrokerOperation,
|
||||||
listLevel: number;
|
BrokerOperationsPage,
|
||||||
issueSize: number;
|
BrokerPositionsPage,
|
||||||
faceValue: number;
|
} from '../shared/api/responses';
|
||||||
faceUnit: string;
|
|
||||||
type: string;
|
|
||||||
marketData: StockMarketData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DividendItem {
|
|
||||||
registryCloseDate: string;
|
|
||||||
value: number;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShareHistoryItem {
|
|
||||||
date: string;
|
|
||||||
open: number;
|
|
||||||
high: number;
|
|
||||||
low: number;
|
|
||||||
close: number;
|
|
||||||
volume: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BondMarketData {
|
|
||||||
price: number | null;
|
|
||||||
yieldToMaturity: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
accruedInt: number | null;
|
|
||||||
couponValue: number | null;
|
|
||||||
couponPercent: number | null;
|
|
||||||
nextCouponDate: string | null;
|
|
||||||
open: number;
|
|
||||||
high: number | null;
|
|
||||||
low: number | null;
|
|
||||||
volume: number;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BondResponse {
|
|
||||||
secid: string;
|
|
||||||
isin: string;
|
|
||||||
name: string;
|
|
||||||
shortName: string;
|
|
||||||
latName: string | null;
|
|
||||||
listLevel: number;
|
|
||||||
issueSize: number;
|
|
||||||
faceValue: number;
|
|
||||||
faceUnit: string;
|
|
||||||
matDate: string;
|
|
||||||
couponValue: number;
|
|
||||||
couponPercent: number | null;
|
|
||||||
couponPeriod: number;
|
|
||||||
nextCoupon: string | null;
|
|
||||||
accruedInt: number;
|
|
||||||
bondType: string;
|
|
||||||
bondSubType: string;
|
|
||||||
offerDate: string | null;
|
|
||||||
buybackDate: string | null;
|
|
||||||
marketData: BondMarketData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BondHistoryItem {
|
|
||||||
date: string;
|
|
||||||
closePrice: number;
|
|
||||||
yieldClose: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandleItem {
|
|
||||||
open: number;
|
|
||||||
high: number;
|
|
||||||
low: number;
|
|
||||||
close: number;
|
|
||||||
volume: number;
|
|
||||||
value: number;
|
|
||||||
begin: string;
|
|
||||||
end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResultItem {
|
|
||||||
secid: string;
|
|
||||||
isin: string;
|
|
||||||
shortName: string;
|
|
||||||
type: 'share' | 'bond';
|
|
||||||
listLevel: number;
|
|
||||||
currency: string | null;
|
|
||||||
price: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthResponse {
|
|
||||||
status: string;
|
|
||||||
timestamp: string;
|
|
||||||
uptime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserResponse {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
|
||||||
user: UserResponse;
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Portfolio {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
currency: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
totalValue: number;
|
|
||||||
positionCount: number;
|
|
||||||
shareCount: number;
|
|
||||||
bondCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PositionWithPrice {
|
|
||||||
id: number;
|
|
||||||
portfolioId: number;
|
|
||||||
secid: string;
|
|
||||||
shortName: string | null;
|
|
||||||
type: 'share' | 'bond';
|
|
||||||
quantity: number;
|
|
||||||
notes: string | null;
|
|
||||||
tags: string[] | null;
|
|
||||||
currentPrice: number | null;
|
|
||||||
buyPrice: number | null;
|
|
||||||
buyDate: string | null;
|
|
||||||
totalCost: number | null;
|
|
||||||
currentValue: number | null;
|
|
||||||
pnl: number | null;
|
|
||||||
pnlPercent: number | null;
|
|
||||||
dividendIncome: number | null;
|
|
||||||
totalReturn: number | null;
|
|
||||||
totalReturnPercent: number | null;
|
|
||||||
weightPercent: number;
|
|
||||||
change?: number | null;
|
|
||||||
changePercent?: number | null;
|
|
||||||
yieldToMaturity?: number | null;
|
|
||||||
duration?: number | null;
|
|
||||||
couponValue?: number | null;
|
|
||||||
couponPercent?: number | null;
|
|
||||||
nextCouponDate?: string | null;
|
|
||||||
matDate?: string | null;
|
|
||||||
accruedInt?: number | null;
|
|
||||||
bid?: number | null;
|
|
||||||
offer?: number | null;
|
|
||||||
couponPeriod?: number | null;
|
|
||||||
bondType?: string | null;
|
|
||||||
offerDate?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortfolioDetail extends Portfolio {
|
|
||||||
positions: PositionWithPrice[];
|
|
||||||
totalValue: number;
|
|
||||||
analytics: PortfolioSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Position {
|
|
||||||
id: number;
|
|
||||||
secid: string;
|
|
||||||
quantity: number;
|
|
||||||
notes: string | null;
|
|
||||||
tags: string[] | null;
|
|
||||||
portfolioId: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortfolioSummary {
|
|
||||||
totalInvested: number;
|
|
||||||
totalValue: number;
|
|
||||||
totalPnl: number;
|
|
||||||
totalPnlPercent: number | null;
|
|
||||||
totalDividends: number;
|
|
||||||
totalReturn: number;
|
|
||||||
totalReturnPercent: number | null;
|
|
||||||
positionCount: number;
|
|
||||||
weightedYield: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsResponse {
|
|
||||||
positions: PositionWithPrice[];
|
|
||||||
summary: PortfolioSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenerItem {
|
|
||||||
secid: string;
|
|
||||||
shortName: string;
|
|
||||||
isin: string;
|
|
||||||
type: 'share' | 'bond';
|
|
||||||
price: number | null;
|
|
||||||
change: number | null;
|
|
||||||
changePercent: number | null;
|
|
||||||
volume: number;
|
|
||||||
listLevel: number;
|
|
||||||
capitalization: number | null;
|
|
||||||
yieldToMaturity: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
couponValue: number | null;
|
|
||||||
couponPercent: number | null;
|
|
||||||
accruedInt: number | null;
|
|
||||||
matDate: string | null;
|
|
||||||
bondType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenerResult {
|
|
||||||
items: ScreenerItem[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerMoney {
|
|
||||||
currency: string;
|
|
||||||
units: string;
|
|
||||||
nano: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerAccount {
|
|
||||||
id: string;
|
|
||||||
type: 'brokerage' | 'iis';
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
openedAt: string | null;
|
|
||||||
accessLevel: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerPosition {
|
|
||||||
figi: string | null;
|
|
||||||
instrumentUid: string | null;
|
|
||||||
positionUid: string | null;
|
|
||||||
ticker: string | null;
|
|
||||||
classCode: string | null;
|
|
||||||
instrumentType: string | null;
|
|
||||||
name: string | null;
|
|
||||||
quantity: number | null;
|
|
||||||
blockedLots: number | null;
|
|
||||||
currentPrice: BrokerMoney | null;
|
|
||||||
currentValue: BrokerMoney | null;
|
|
||||||
averagePositionPrice: BrokerMoney | null;
|
|
||||||
expectedYieldPercent: number | null;
|
|
||||||
dailyYield: BrokerMoney | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerPortfolio {
|
|
||||||
account: BrokerAccount;
|
|
||||||
positionCounts: {
|
|
||||||
shares: number;
|
|
||||||
bonds: number;
|
|
||||||
etf: number;
|
|
||||||
other: number;
|
|
||||||
};
|
|
||||||
totals: {
|
|
||||||
shares: BrokerMoney | null;
|
|
||||||
bonds: BrokerMoney | null;
|
|
||||||
etf: BrokerMoney | null;
|
|
||||||
currencies: BrokerMoney | null;
|
|
||||||
futures: BrokerMoney | null;
|
|
||||||
options: BrokerMoney | null;
|
|
||||||
structuredProducts: BrokerMoney | null;
|
|
||||||
dfa: BrokerMoney | null;
|
|
||||||
portfolio: BrokerMoney | null;
|
|
||||||
};
|
|
||||||
yields: {
|
|
||||||
expectedPercent: number | null;
|
|
||||||
daily: BrokerMoney | null;
|
|
||||||
dailyPercent: number | null;
|
|
||||||
};
|
|
||||||
cash: BrokerMoney[];
|
|
||||||
blockedCash: BrokerMoney[];
|
|
||||||
asOf: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
|
||||||
|
|
||||||
export interface BrokerOperation {
|
|
||||||
cursor: string | null;
|
|
||||||
accountId: string;
|
|
||||||
id: string | null;
|
|
||||||
parentOperationId: string | null;
|
|
||||||
date: string | null;
|
|
||||||
type: string;
|
|
||||||
category: BrokerOperationCategory;
|
|
||||||
description: string | null;
|
|
||||||
name: string | null;
|
|
||||||
state: string | null;
|
|
||||||
instrumentUid: string | null;
|
|
||||||
figi: string | null;
|
|
||||||
ticker: string | null;
|
|
||||||
classCode: string | null;
|
|
||||||
instrumentType: string | null;
|
|
||||||
payment: BrokerMoney | null;
|
|
||||||
price: BrokerMoney | null;
|
|
||||||
commission: BrokerMoney | null;
|
|
||||||
yield: BrokerMoney | null;
|
|
||||||
accruedInt: BrokerMoney | null;
|
|
||||||
quantity: number | null;
|
|
||||||
quantityDone: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerOperationsPage {
|
|
||||||
accountId: string;
|
|
||||||
items: BrokerOperation[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
hasNext: boolean;
|
|
||||||
asOf: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrokerPositionsPage {
|
|
||||||
accountId: string;
|
|
||||||
items: BrokerPosition[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
hasNext: boolean;
|
|
||||||
asOf: string;
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
10
apps/frontend/src/app/App.tsx
Normal file
10
apps/frontend/src/app/App.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AppRoutes } from './routing/AppRoutes';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/app/index.ts
Normal file
1
apps/frontend/src/app/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as App } from './App';
|
||||||
142
apps/frontend/src/app/layouts/AppLayout.tsx
Normal file
142
apps/frontend/src/app/layouts/AppLayout.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { SearchBar } from '@/components/SearchBar';
|
||||||
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const { isAuthenticated, user, logout } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderBottom: '1px solid #e0e0e0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MoexVibe
|
||||||
|
</Link>
|
||||||
|
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/portfolios"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Портфели
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/broker"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Брокер
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/screener"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скринер
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 'clamp(16px, 4vw, 24px)',
|
||||||
|
maxWidth: 1200,
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/frontend/src/app/layouts/index.ts
Normal file
1
apps/frontend/src/app/layouts/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AppLayout } from './AppLayout';
|
||||||
21
apps/frontend/src/app/providers/AppProviders.tsx
Normal file
21
apps/frontend/src/app/providers/AppProviders.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { SessionProvider } from './SessionProvider';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 900_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/frontend/src/app/providers/SessionProvider.test.tsx
Normal file
74
apps/frontend/src/app/providers/SessionProvider.test.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../test/server';
|
||||||
|
import { SessionContext } from '@/entities/session/model/sessionContext';
|
||||||
|
import { SessionProvider } from './SessionProvider';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>{ui}</SessionProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestConsumer() {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) return <div>no context</div>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
||||||
|
<span data-testid="email">{ctx.user?.email ?? ''}</span>
|
||||||
|
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
|
||||||
|
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
|
||||||
|
<button onClick={() => ctx.logout()}>logout</button>
|
||||||
|
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionProvider', () => {
|
||||||
|
it('starts unauthenticated when refresh fails', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores session on mount when refresh succeeds', async () => {
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
||||||
|
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after login', async () => {
|
||||||
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'login' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state after logout', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<TestConsumer />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'logout' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/frontend/src/app/providers/SessionProvider.tsx
Normal file
115
apps/frontend/src/app/providers/SessionProvider.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
import * as sessionApi from '@/entities/session/api/sessionApi';
|
||||||
|
import { SessionContext, type SessionContextValue } from '@/entities/session/model/sessionContext';
|
||||||
|
import { setOnUnauthorized } from '@/shared/api/client';
|
||||||
|
import type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserResponse | null>(null);
|
||||||
|
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
||||||
|
setUser(authData.user);
|
||||||
|
setAccessTokenState(authData.accessToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
setAccessTokenState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (email: string, password: string) => {
|
||||||
|
const result = await sessionApi.login(email, password);
|
||||||
|
updateSession(result);
|
||||||
|
},
|
||||||
|
[updateSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (email: string, password: string, name?: string) => {
|
||||||
|
const result = await sessionApi.register(email, password, name);
|
||||||
|
updateSession(result);
|
||||||
|
},
|
||||||
|
[updateSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await sessionApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore network errors on logout
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
||||||
|
const result = await sessionApi.updateProfile(data);
|
||||||
|
setUser(result);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Try to restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const result = await sessionApi.refresh();
|
||||||
|
if (mounted) {
|
||||||
|
updateSession(result);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No valid session
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Set up auto-logout on unauthorized
|
||||||
|
useEffect(() => {
|
||||||
|
setOnUnauthorized(() => {
|
||||||
|
clearSession();
|
||||||
|
});
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
if (!initialized && isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: SessionContextValue = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateProfile: updateProfileFn,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
2
apps/frontend/src/app/providers/index.ts
Normal file
2
apps/frontend/src/app/providers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { SessionProvider } from './SessionProvider';
|
||||||
|
export { AppProviders } from './AppProviders';
|
||||||
77
apps/frontend/src/app/routing/AppRoutes.tsx
Normal file
77
apps/frontend/src/app/routing/AppRoutes.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { AppLayout } from '../layouts/AppLayout';
|
||||||
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { HomePage } from '@/pages/HomePage';
|
||||||
|
import { StockPage } from '@/pages/StockPage';
|
||||||
|
import { BondPage } from '@/pages/BondPage';
|
||||||
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { RegisterPage } from '@/pages/RegisterPage';
|
||||||
|
import { ProfilePage } from '@/pages/ProfilePage';
|
||||||
|
import { PortfoliosListPage } from '@/pages/portfolios/PortfoliosListPage';
|
||||||
|
import { PortfolioDetailPage } from '@/pages/portfolios/PortfolioDetailPage';
|
||||||
|
import { ScreenerPage } from '@/pages/screener/ScreenerPage';
|
||||||
|
import { BrokerAccountsPage } from '@/pages/broker-accounts';
|
||||||
|
import { BrokerAccountLayout } from '@/entities/broker-account/ui/BrokerAccountLayout';
|
||||||
|
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
|
||||||
|
import { BrokerPositionsPage } from '@/pages/broker-positions';
|
||||||
|
import { BrokerOperationsPage } from '@/pages/broker-operations';
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||||
|
<Route path="/bonds/:secid" element={<BondPage />} />
|
||||||
|
<Route path="/screener" element={<ScreenerPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfoliosListPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfolioDetailPage />
|
||||||
|
</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>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/frontend/src/app/routing/ProtectedRoute.tsx
Normal file
29
apps/frontend/src/app/routing/ProtectedRoute.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useSession } from '@/entities/session/model/useSession';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useSession();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 40,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
2
apps/frontend/src/app/routing/index.ts
Normal file
2
apps/frontend/src/app/routing/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AppRoutes } from './AppRoutes';
|
||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { BondResponse } from '../api/responses';
|
import type { BondResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface BondDetailsProps {
|
interface BondDetailsProps {
|
||||||
bond: BondResponse;
|
bond: BondResponse;
|
||||||
|
|||||||
@ -1,142 +1 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
export { AppLayout as Layout } from '../app/layouts/AppLayout';
|
||||||
import { SearchBar } from './SearchBar';
|
|
||||||
import { useAuth } from '../hooks/useAuth';
|
|
||||||
|
|
||||||
export function Layout() {
|
|
||||||
const { isAuthenticated, user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await logout();
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<header
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderBottom: '1px solid #e0e0e0',
|
|
||||||
padding: '12px 24px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
style={{
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
MoexVibe
|
|
||||||
</Link>
|
|
||||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
|
||||||
<SearchBar />
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/portfolios"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Портфели
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/broker"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Брокер
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/screener"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Скринер
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/profile"
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user?.name || user?.email}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
style={{
|
|
||||||
padding: '6px 16px',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 14,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
style={{
|
|
||||||
padding: '6px 16px',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
textDecoration: 'none',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: 'clamp(16px, 4vw, 24px)',
|
|
||||||
maxWidth: 1200,
|
|
||||||
width: '100%',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,29 +1 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
export { ProtectedRoute } from '../app/routing/ProtectedRoute';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,20 +1 @@
|
|||||||
export function SkeletonBlock({
|
export { SkeletonBlock } from '../shared/ui/SkeletonBlock';
|
||||||
width,
|
|
||||||
height,
|
|
||||||
borderRadius = 4,
|
|
||||||
}: {
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
borderRadius?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="skeleton"
|
|
||||||
style={{
|
|
||||||
width: width ?? '100%',
|
|
||||||
height: height ?? 16,
|
|
||||||
borderRadius,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ShareResponse } from '../api/responses';
|
import type { ShareResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface StockDetailsProps {
|
interface StockDetailsProps {
|
||||||
stock: ShareResponse;
|
stock: ShareResponse;
|
||||||
|
|||||||
@ -1,25 +1 @@
|
|||||||
import { SkeletonBlock } from './SkeletonBlock';
|
export { TableSkeleton } from '../shared/ui/TableSkeleton';
|
||||||
|
|
||||||
const tdStyle = {
|
|
||||||
borderBottom: '1px solid #eeeeee',
|
|
||||||
padding: '10px 8px',
|
|
||||||
verticalAlign: 'top',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
type Column = { width: string };
|
|
||||||
|
|
||||||
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
|
|
||||||
return (
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
{columns.map((col, j) => (
|
|
||||||
<td key={j} style={tdStyle}>
|
|
||||||
<SkeletonBlock height={12} width={col.width} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { PositionWithPrice } from '../../api/responses';
|
import type { PositionWithPrice } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface AllocationChartProps {
|
interface AllocationChartProps {
|
||||||
positions: PositionWithPrice[];
|
positions: PositionWithPrice[];
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { PortfolioSummary } from '../../api/responses';
|
import type { PortfolioSummary } from '@/shared/api/responses';
|
||||||
|
|
||||||
export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
||||||
const formatRub = (val: number | null) =>
|
const formatRub = (val: number | null) =>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { PositionWithPrice } from '../../api/responses';
|
import type { PositionWithPrice } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
position: PositionWithPrice;
|
position: PositionWithPrice;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BondPositionRow } from './BondPositionRow';
|
import { BondPositionRow } from './BondPositionRow';
|
||||||
import type { PositionWithPrice } from '../../api/responses';
|
import type { PositionWithPrice } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
positions: PositionWithPrice[];
|
positions: PositionWithPrice[];
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { Portfolio } from '../../api/responses';
|
import type { Portfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||||
const chipStyle = (bg: string): React.CSSProperties => ({
|
const chipStyle = (bg: string): React.CSSProperties => ({
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Portfolio } from '../../api/responses';
|
import type { Portfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial?: Portfolio;
|
initial?: Portfolio;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AllocationChart } from './AllocationChart';
|
import { AllocationChart } from './AllocationChart';
|
||||||
import type { PortfolioDetail } from '../../api/responses';
|
import type { PortfolioDetail } from '@/shared/api/responses';
|
||||||
|
|
||||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { PositionWithPrice } from '../../api/responses';
|
import type { PositionWithPrice } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
position: PositionWithPrice;
|
position: PositionWithPrice;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { SharePositionRow } from './SharePositionRow';
|
import { SharePositionRow } from './SharePositionRow';
|
||||||
import type { PositionWithPrice } from '../../api/responses';
|
import type { PositionWithPrice } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
positions: PositionWithPrice[];
|
positions: PositionWithPrice[];
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ScreenerResult } from '../../api/responses';
|
import type { ScreenerResult } from '@/shared/api/responses';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: ScreenerResult;
|
result: ScreenerResult;
|
||||||
|
|||||||
@ -1,131 +1,5 @@
|
|||||||
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
export {
|
||||||
import * as authApi from '../api/auth';
|
SessionContext as AuthContext,
|
||||||
import { setOnUnauthorized } from '../api/client';
|
type SessionContextValue as AuthContextValue,
|
||||||
import type { UserResponse } from '../api/responses';
|
} from '@/entities/session/model/sessionContext';
|
||||||
|
export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider';
|
||||||
export interface AuthContextValue {
|
|
||||||
user: UserResponse | null;
|
|
||||||
accessToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [user, setUser] = useState<UserResponse | null>(null);
|
|
||||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
|
||||||
setUser(authData.user);
|
|
||||||
setAccessTokenState(authData.accessToken);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
|
||||||
setUser(null);
|
|
||||||
setAccessTokenState(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
async (email: string, password: string) => {
|
|
||||||
const result = await authApi.login(email, password);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = useCallback(
|
|
||||||
async (email: string, password: string, name?: string) => {
|
|
||||||
const result = await authApi.register(email, password, name);
|
|
||||||
updateSession(result);
|
|
||||||
},
|
|
||||||
[updateSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await authApi.logout();
|
|
||||||
} catch {
|
|
||||||
// ignore network errors on logout
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
}, [clearSession]);
|
|
||||||
|
|
||||||
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
|
||||||
const result = await authApi.updateProfile(data);
|
|
||||||
setUser(result);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Try to restore session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
const result = await authApi.refresh();
|
|
||||||
if (mounted) {
|
|
||||||
updateSession(result);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No valid session
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [updateSession]);
|
|
||||||
|
|
||||||
// Set up auto-logout on unauthorized
|
|
||||||
useEffect(() => {
|
|
||||||
setOnUnauthorized(() => {
|
|
||||||
clearSession();
|
|
||||||
});
|
|
||||||
}, [clearSession]);
|
|
||||||
|
|
||||||
if (!initialized && isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '100vh',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
user,
|
|
||||||
accessToken,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
isLoading,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
updateProfile: updateProfileFn,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
44
apps/frontend/src/entities/bond/api/bondApi.ts
Normal file
44
apps/frontend/src/entities/bond/api/bondApi.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { request } from '@/shared/api/client';
|
||||||
|
import type {
|
||||||
|
ApiResponseMeta,
|
||||||
|
BondResponse,
|
||||||
|
BondMarketData,
|
||||||
|
BondHistoryItem,
|
||||||
|
CandleItem,
|
||||||
|
} from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||||
|
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBondMarketData(
|
||||||
|
secid: string,
|
||||||
|
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
||||||
|
return request<BondMarketData>(
|
||||||
|
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBondHistory(
|
||||||
|
secid: string,
|
||||||
|
from: string,
|
||||||
|
till: string,
|
||||||
|
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<BondHistoryItem[]>(
|
||||||
|
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
||||||
|
{ from, till },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBondCandles(
|
||||||
|
secid: string,
|
||||||
|
interval: '1h' | '24h',
|
||||||
|
from: string,
|
||||||
|
till: string,
|
||||||
|
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
|
||||||
|
interval,
|
||||||
|
from,
|
||||||
|
till,
|
||||||
|
});
|
||||||
|
}
|
||||||
3
apps/frontend/src/entities/bond/index.ts
Normal file
3
apps/frontend/src/entities/bond/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { useBond } from './model/useBond';
|
||||||
|
export { useBondCandles } from './model/useBondCandles';
|
||||||
|
export { getBond, getBondMarketData, getBondHistory, getBondCandles } from './api/bondApi';
|
||||||
14
apps/frontend/src/entities/bond/model/useBond.ts
Normal file
14
apps/frontend/src/entities/bond/model/useBond.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getBond } from '../api/bondApi';
|
||||||
|
import type { BondResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useBond(secid: string) {
|
||||||
|
return useQuery<BondResponse>({
|
||||||
|
queryKey: ['bond', secid],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getBond(secid);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 900_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useBondCandles } from './useBondCandles';
|
import { useBondCandles } from './useBondCandles';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
14
apps/frontend/src/entities/bond/model/useBondCandles.ts
Normal file
14
apps/frontend/src/entities/bond/model/useBondCandles.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getBondCandles } from '../api/bondApi';
|
||||||
|
import type { CandleItem } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||||
|
return useQuery<CandleItem[]>({
|
||||||
|
queryKey: ['bondCandles', secid, interval, from, till],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getBondCandles(secid, interval, from, till);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 3600_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { request } from '@/shared/api/client';
|
||||||
|
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export type BrokerOperationQuery = {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
instrumentId?: string;
|
||||||
|
operationTypes?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBrokerAccounts(): Promise<{
|
||||||
|
data: BrokerAccount[];
|
||||||
|
meta: ApiResponseMeta;
|
||||||
|
}> {
|
||||||
|
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerPortfolio(accountId: string): Promise<{
|
||||||
|
data: BrokerPortfolio;
|
||||||
|
meta: ApiResponseMeta;
|
||||||
|
}> {
|
||||||
|
return request<BrokerPortfolio>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/frontend/src/entities/broker-account/index.ts
Normal file
9
apps/frontend/src/entities/broker-account/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export { useBrokerAccounts } from './model/useBrokerAccounts';
|
||||||
|
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
|
||||||
|
export { useBrokerPortfolio } from './model/useBrokerPortfolio';
|
||||||
|
export { aggregateBrokerAccounts } from './model/brokerAccountsOverview';
|
||||||
|
export {
|
||||||
|
getBrokerAccounts,
|
||||||
|
getBrokerPortfolio,
|
||||||
|
type BrokerOperationQuery,
|
||||||
|
} from './api/brokerAccountApi';
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { BrokerPortfolio } from '../../api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview';
|
||||||
|
|
||||||
function portfolio(
|
function portfolio(
|
||||||
id: string,
|
id: string,
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export interface BrokerCurrencyAllocationSummary {
|
||||||
|
shares: number;
|
||||||
|
bonds: number;
|
||||||
|
etf: number;
|
||||||
|
cash: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokerCurrencyPortfolioSummary {
|
||||||
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
daily: number | null;
|
||||||
|
dailyPercent: number | null;
|
||||||
|
allocation: BrokerCurrencyAllocationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokerCurrencyCashSummary {
|
||||||
|
currency: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokerAccountsAggregate {
|
||||||
|
portfolios: BrokerCurrencyPortfolioSummary[];
|
||||||
|
cash: BrokerCurrencyCashSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableCurrencySummary {
|
||||||
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
daily: number | null;
|
||||||
|
dailyComparable: boolean;
|
||||||
|
allocation: BrokerCurrencyAllocationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyValue(money: BrokerMoney | null | undefined): number {
|
||||||
|
return money?.value ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatBrokerCurrencyValue(value.currency, value.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'RUB',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return `+${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
return `−${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerPercent(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerSignedPercent(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatBrokerPercent(Math.abs(value));
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
return `+${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0) {
|
||||||
|
return `−${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBrokerDate(value: string | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(value).toLocaleDateString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||||||
|
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
||||||
|
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
||||||
|
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
|
||||||
|
|
||||||
|
for (const portfolio of portfolios) {
|
||||||
|
for (const cash of portfolio.cash) {
|
||||||
|
if (!cash.currency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCash = cashSummaries.get(cash.currency);
|
||||||
|
|
||||||
|
if (existingCash) {
|
||||||
|
existingCash.value += cash.value;
|
||||||
|
} else {
|
||||||
|
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMoney = portfolio.totals.portfolio;
|
||||||
|
const currency = totalMoney?.currency;
|
||||||
|
|
||||||
|
if (!totalMoney || !currency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSummary = portfolioSummaries.get(currency);
|
||||||
|
const summary =
|
||||||
|
existingSummary ??
|
||||||
|
({
|
||||||
|
currency,
|
||||||
|
total: 0,
|
||||||
|
daily: 0,
|
||||||
|
dailyComparable: true,
|
||||||
|
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
|
||||||
|
} satisfies MutableCurrencySummary);
|
||||||
|
|
||||||
|
const total = totalMoney.value;
|
||||||
|
const shares = moneyValue(portfolio.totals.shares);
|
||||||
|
const bonds = moneyValue(portfolio.totals.bonds);
|
||||||
|
const etf = moneyValue(portfolio.totals.etf);
|
||||||
|
const cash = moneyValue(portfolio.totals.currencies);
|
||||||
|
const other = Math.max(0, total - shares - bonds - etf - cash);
|
||||||
|
|
||||||
|
summary.total += total;
|
||||||
|
summary.allocation.shares += shares;
|
||||||
|
summary.allocation.bonds += bonds;
|
||||||
|
summary.allocation.etf += etf;
|
||||||
|
summary.allocation.cash += cash;
|
||||||
|
summary.allocation.other += other;
|
||||||
|
|
||||||
|
const dailyMoney = portfolio.yields.daily;
|
||||||
|
const comparableDaily = dailyMoney && dailyMoney.currency === currency;
|
||||||
|
|
||||||
|
if (!comparableDaily) {
|
||||||
|
summary.daily = null;
|
||||||
|
summary.dailyComparable = false;
|
||||||
|
} else if (summary.dailyComparable) {
|
||||||
|
summary.daily = (summary.daily ?? 0) + dailyMoney.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingSummary) {
|
||||||
|
portfolioSummaries.set(currency, summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
|
||||||
|
const daily = summary.dailyComparable ? summary.daily : null;
|
||||||
|
const startOfDay = daily === null ? null : summary.total - daily;
|
||||||
|
const dailyPercent =
|
||||||
|
daily === null || startOfDay === null || startOfDay <= 0
|
||||||
|
? null
|
||||||
|
: (daily / startOfDay) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency: summary.currency,
|
||||||
|
total: summary.total,
|
||||||
|
daily,
|
||||||
|
dailyPercent,
|
||||||
|
allocation: summary.allocation,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
cash: Array.from(cashSummaries.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
||||||
|
|
||||||
|
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
||||||
|
const queries = useQueries({
|
||||||
|
queries: accounts.map((account) => ({
|
||||||
|
queryKey: ['broker', 'portfolio', account.id],
|
||||||
|
queryFn: async (): Promise<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return accounts.map((account, index) => ({ account, query: queries[index] }));
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { BrokerAccount } from '@/shared/api/responses';
|
||||||
|
import { getBrokerAccounts } from '../api/brokerAccountApi';
|
||||||
|
|
||||||
|
export function useBrokerAccounts() {
|
||||||
|
return useQuery<BrokerAccount[]>({
|
||||||
|
queryKey: ['broker', 'accounts'],
|
||||||
|
queryFn: async () => (await getBrokerAccounts()).data,
|
||||||
|
staleTime: 3_600_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
||||||
|
|
||||||
|
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||||
|
return useQuery<BrokerPortfolio>({
|
||||||
|
queryKey: ['broker', 'portfolio', accountId],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||||
|
import { useBrokerPortfolio } from '../model/useBrokerPortfolio';
|
||||||
|
|
||||||
|
export type BrokerAccountContext = {
|
||||||
|
accountId: string;
|
||||||
|
portfolio: ReturnType<typeof useBrokerPortfolio>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useBrokerAccountContext() {
|
||||||
|
return useOutletContext<BrokerAccountContext>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerAccountLayout() {
|
||||||
|
const { accountId = '' } = useParams();
|
||||||
|
const portfolio = useBrokerPortfolio(accountId);
|
||||||
|
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||||
|
const context: BrokerAccountContext = { accountId, portfolio };
|
||||||
|
const linkClassName = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`broker-account__link${isActive ? ' is-active' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-account">
|
||||||
|
<header className="broker-account__header">
|
||||||
|
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="broker-account__workspace">
|
||||||
|
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
|
||||||
|
<NavLink className={linkClassName} end to={basePath}>
|
||||||
|
Обзор
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/shares`}>
|
||||||
|
Акции
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
|
||||||
|
Облигации
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/operations`}>
|
||||||
|
Операции
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="broker-account__content">
|
||||||
|
<Outlet context={context} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';
|
||||||
9
apps/frontend/src/entities/broker-operation/index.ts
Normal file
9
apps/frontend/src/entities/broker-operation/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi';
|
||||||
|
export {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
isBrokerOperationType,
|
||||||
|
type BrokerOperationImpact,
|
||||||
|
} from './model/operationFilters';
|
||||||
|
export { useBrokerOperations } from './model/useBrokerOperations';
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters';
|
||||||
|
|
||||||
|
describe('operationFilters', () => {
|
||||||
|
it('accepts only declared broker operation types', () => {
|
||||||
|
expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true);
|
||||||
|
expect(isBrokerOperationType('unexpected')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps operation type option values unique and labels sorted for the filter', () => {
|
||||||
|
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
|
||||||
|
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
|
||||||
|
|
||||||
|
expect(new Set(values).size).toBe(values.length);
|
||||||
|
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
import type { BrokerOperation } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
|
||||||
|
|
||||||
|
const TRADE_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BUY',
|
||||||
|
'OPERATION_TYPE_BUY_CARD',
|
||||||
|
'OPERATION_TYPE_SELL',
|
||||||
|
'OPERATION_TYPE_SELL_CARD',
|
||||||
|
'OPERATION_TYPE_BUY_MARGIN',
|
||||||
|
'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
'OPERATION_TYPE_DELIVERY_BUY',
|
||||||
|
'OPERATION_TYPE_DELIVERY_SELL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOND_REPAYMENT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BOND_REPAYMENT',
|
||||||
|
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
|
||||||
|
|
||||||
|
const TAX_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_TAX',
|
||||||
|
'OPERATION_TYPE_BOND_TAX',
|
||||||
|
'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
|
'OPERATION_TYPE_TAX_CORRECTION',
|
||||||
|
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FEE_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BROKER_FEE',
|
||||||
|
'OPERATION_TYPE_SERVICE_FEE',
|
||||||
|
'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSFER_INPUT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_INPUT',
|
||||||
|
'OPERATION_TYPE_INPUT_SWIFT',
|
||||||
|
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||||
|
'OPERATION_TYPE_INP_MULTI',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSFER_OUTPUT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_OUTPUT',
|
||||||
|
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||||
|
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||||
|
'OPERATION_TYPE_OUT_MULTI',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SECURITY_TRANSFER_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
|
'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OPERATION_TYPE_LABELS: Record<string, string> = {
|
||||||
|
OPERATION_TYPE_BUY: 'Покупка',
|
||||||
|
OPERATION_TYPE_BUY_CARD: 'Покупка',
|
||||||
|
OPERATION_TYPE_SELL: 'Продажа',
|
||||||
|
OPERATION_TYPE_SELL_CARD: 'Продажа',
|
||||||
|
OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой',
|
||||||
|
OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой',
|
||||||
|
OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки',
|
||||||
|
OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи',
|
||||||
|
OPERATION_TYPE_COUPON: 'Выплата купона',
|
||||||
|
OPERATION_TYPE_DIVIDEND: 'Дивиденды',
|
||||||
|
OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации',
|
||||||
|
OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации',
|
||||||
|
OPERATION_TYPE_TAX: 'Налог',
|
||||||
|
OPERATION_TYPE_BOND_TAX: 'Налог по облигациям',
|
||||||
|
OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды',
|
||||||
|
OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога',
|
||||||
|
OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону',
|
||||||
|
OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера',
|
||||||
|
OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание',
|
||||||
|
OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу',
|
||||||
|
OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат',
|
||||||
|
OPERATION_TYPE_INPUT: 'Пополнение',
|
||||||
|
OPERATION_TYPE_OUTPUT: 'Вывод средств',
|
||||||
|
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
|
||||||
|
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
|
||||||
|
Readonly<{ value: string; label: string }>
|
||||||
|
> = Object.freeze(
|
||||||
|
Object.entries(OPERATION_TYPE_LABELS)
|
||||||
|
.map(([value, label]) => Object.freeze({ value, label }))
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
|
||||||
|
|
||||||
|
export function isBrokerOperationType(value: string | null): value is string {
|
||||||
|
return value !== null && BROKER_OPERATION_TYPES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperationTypeLabel(
|
||||||
|
operation: Pick<BrokerOperation, 'type' | 'description'>,
|
||||||
|
): string {
|
||||||
|
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
|
||||||
|
if (knownLabel) return knownLabel;
|
||||||
|
if (operation.description) return operation.description;
|
||||||
|
|
||||||
|
return operation.type
|
||||||
|
.replace(/^OPERATION_TYPE_/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperationImpact(
|
||||||
|
operation: Pick<BrokerOperation, 'type' | 'category' | 'payment'>,
|
||||||
|
): BrokerOperationImpact {
|
||||||
|
if (
|
||||||
|
TRADE_TYPES.has(operation.type) ||
|
||||||
|
BOND_REPAYMENT_TYPES.has(operation.type) ||
|
||||||
|
SECURITY_TRANSFER_TYPES.has(operation.type)
|
||||||
|
) {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INCOME_TYPES.has(operation.type)) return 'adds';
|
||||||
|
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
|
||||||
|
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
|
||||||
|
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
|
||||||
|
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
|
||||||
|
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getBrokerOperations } from '../api/brokerOperationApi';
|
||||||
|
import { useBrokerOperations } from '../model/useBrokerOperations';
|
||||||
|
|
||||||
|
vi.mock('../api/brokerOperationApi', () => ({
|
||||||
|
getBrokerOperations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper(queryClient?: QueryClient) {
|
||||||
|
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBrokerOperations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns operations page data from API', async () => {
|
||||||
|
vi.mocked(getBrokerOperations).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data?.accountId).toBe('acc-1');
|
||||||
|
expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the broker operations cache key across the account overview and full history pages', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const cachedPage = {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
||||||
|
wrapper: createWrapper(queryClient),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.data).toBe(cachedPage));
|
||||||
|
expect(getBrokerOperations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { BrokerOperationsPage } from '@/shared/api/responses';
|
||||||
|
import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi';
|
||||||
|
|
||||||
|
export function useBrokerOperations(
|
||||||
|
accountId: string | undefined,
|
||||||
|
query: BrokerOperationQuery = {},
|
||||||
|
) {
|
||||||
|
return useQuery<BrokerOperationsPage>({
|
||||||
|
queryKey: ['broker', 'operations', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||||
|
staleTime: 300_000,
|
||||||
|
retry: 2,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import { getBrokerPositions } from '../../../api/broker';
|
||||||
|
|
||||||
|
export { getBrokerPositions };
|
||||||
17
apps/frontend/src/entities/broker-position/index.ts
Normal file
17
apps/frontend/src/entities/broker-position/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export { getBrokerPositions } from './api/brokerPositionApi';
|
||||||
|
export {
|
||||||
|
buildBrokerAllocation,
|
||||||
|
type BrokerAllocationItem,
|
||||||
|
type BrokerAllocationKey,
|
||||||
|
} from './model/brokerAllocation';
|
||||||
|
export {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
getBrokerPositionGroup,
|
||||||
|
isBrokerOperationType,
|
||||||
|
type BrokerOperationImpact,
|
||||||
|
type BrokerPositionGroup,
|
||||||
|
} from './model/brokerDisplay';
|
||||||
|
export { useBrokerPositions } from './model/useBrokerPositions';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { buildBrokerAllocation } from './brokerAllocation';
|
import { buildBrokerAllocation } from './brokerAllocation';
|
||||||
|
|
||||||
function money(value: number): BrokerMoney {
|
function money(value: number): BrokerMoney {
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
|
||||||
|
|
||||||
|
export interface BrokerAllocationItem {
|
||||||
|
key: BrokerAllocationKey;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
percent: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
|
||||||
|
|
||||||
|
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
||||||
|
{ key: 'shares', label: 'Акции', color: '#4969f5' },
|
||||||
|
{ key: 'bonds', label: 'Облигации', color: '#e5a33c' },
|
||||||
|
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' },
|
||||||
|
{ key: 'cash', label: 'Деньги', color: '#7b63cf' },
|
||||||
|
{ key: 'other', label: 'Прочие', color: '#aeb6c5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
|
||||||
|
total: number;
|
||||||
|
sectors: BrokerAllocationItem[];
|
||||||
|
negative: BrokerNegativeAllocationItem[];
|
||||||
|
} {
|
||||||
|
const total = portfolio.totals.portfolio?.value ?? 0;
|
||||||
|
const shares = portfolio.totals.shares?.value ?? 0;
|
||||||
|
const bonds = portfolio.totals.bonds?.value ?? 0;
|
||||||
|
const etf = portfolio.totals.etf?.value ?? 0;
|
||||||
|
const cash = portfolio.totals.currencies?.value ?? 0;
|
||||||
|
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
const negative = ALLOCATION_CONFIG.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
||||||
|
key: Exclude<BrokerAllocationKey, 'other'>;
|
||||||
|
} => item.key !== 'other',
|
||||||
|
)
|
||||||
|
.filter((item) => namedValues[item.key] < 0)
|
||||||
|
.map((item) => ({ ...item, value: namedValues[item.key] }));
|
||||||
|
return { total, sectors: [], negative };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedTotal = shares + bonds + etf + cash;
|
||||||
|
const residual = total - mappedTotal;
|
||||||
|
const residualTolerance =
|
||||||
|
Number.EPSILON *
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.abs(total),
|
||||||
|
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
|
||||||
|
) *
|
||||||
|
8;
|
||||||
|
const values: Record<BrokerAllocationKey, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
other: Math.abs(residual) <= residualTolerance ? 0 : residual,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectors: BrokerAllocationItem[] = [];
|
||||||
|
const negative: BrokerNegativeAllocationItem[] = [];
|
||||||
|
|
||||||
|
for (const item of ALLOCATION_CONFIG) {
|
||||||
|
const value = values[item.key];
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
sectors.push({ ...item, value, percent: (value / total) * 100 });
|
||||||
|
} else if (value < 0) {
|
||||||
|
negative.push({ ...item, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, sectors, negative };
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses';
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import type { BrokerPosition } from '@/shared/api/responses';
|
||||||
|
export {
|
||||||
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
isBrokerOperationType,
|
||||||
|
} from '../../broker-operation/model/operationFilters';
|
||||||
|
export type { BrokerOperationImpact } from '../../broker-operation/model/operationFilters';
|
||||||
|
|
||||||
|
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
||||||
|
|
||||||
|
type BrokerInstrumentLinkInput = {
|
||||||
|
ticker: string | null;
|
||||||
|
instrumentType: string | null;
|
||||||
|
classCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STOCK_CLASS_CODES = new Set(['TQBR']);
|
||||||
|
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']);
|
||||||
|
|
||||||
|
export function getBrokerPositionGroup(
|
||||||
|
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||||
|
): BrokerPositionGroup {
|
||||||
|
const instrumentType = position.instrumentType?.toLowerCase();
|
||||||
|
|
||||||
|
if (instrumentType === 'share') return 'shares';
|
||||||
|
if (instrumentType === 'bond') return 'bonds';
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null {
|
||||||
|
const ticker = input.ticker?.trim().toUpperCase();
|
||||||
|
if (!ticker) return null;
|
||||||
|
|
||||||
|
const instrumentType = input.instrumentType?.toLowerCase();
|
||||||
|
const classCode = input.classCode?.toUpperCase() ?? null;
|
||||||
|
|
||||||
|
if (instrumentType === 'share') {
|
||||||
|
return `/stocks/${encodeURIComponent(ticker)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instrumentType === 'bond') {
|
||||||
|
return `/bonds/${encodeURIComponent(ticker)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instrumentType) return null;
|
||||||
|
|
||||||
|
if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`;
|
||||||
|
if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { BrokerPositionsPage } from '@/shared/api/responses';
|
||||||
|
import { getBrokerPositions } from '../api/brokerPositionApi';
|
||||||
|
|
||||||
|
export function useBrokerPositions(
|
||||||
|
accountId: string | undefined,
|
||||||
|
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||||
|
) {
|
||||||
|
return useQuery<BrokerPositionsPage>({
|
||||||
|
queryKey: ['broker', 'positions', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
98
apps/frontend/src/entities/portfolio/api/portfolioApi.ts
Normal file
98
apps/frontend/src/entities/portfolio/api/portfolioApi.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { request } from '@/shared/api/client';
|
||||||
|
import type {
|
||||||
|
AnalyticsResponse,
|
||||||
|
Portfolio,
|
||||||
|
PortfolioDetail,
|
||||||
|
Position,
|
||||||
|
} from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function getPortfolios(): Promise<{
|
||||||
|
data: Portfolio[];
|
||||||
|
meta: { cachedAt: string | null; fromCache: boolean };
|
||||||
|
}> {
|
||||||
|
return request<Portfolio[]>('/api/v1/portfolios');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortfolio(
|
||||||
|
id: number,
|
||||||
|
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPortfolio(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
currency?: string;
|
||||||
|
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePortfolio(
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; description?: string; currency?: string },
|
||||||
|
): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePortfolio(
|
||||||
|
id: number,
|
||||||
|
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPosition(
|
||||||
|
portfolioId: number,
|
||||||
|
data: {
|
||||||
|
secid: string;
|
||||||
|
quantity: number;
|
||||||
|
buyPrice?: number;
|
||||||
|
buyDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
},
|
||||||
|
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePosition(
|
||||||
|
portfolioId: number,
|
||||||
|
positionId: number,
|
||||||
|
data: {
|
||||||
|
quantity?: number;
|
||||||
|
buyPrice?: number;
|
||||||
|
buyDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
},
|
||||||
|
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePosition(
|
||||||
|
portfolioId: number,
|
||||||
|
positionId: number,
|
||||||
|
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortfolioAnalytics(
|
||||||
|
portfolioId: number,
|
||||||
|
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
|
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
|
||||||
|
}
|
||||||
16
apps/frontend/src/entities/portfolio/index.ts
Normal file
16
apps/frontend/src/entities/portfolio/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export { usePortfolio } from './model/usePortfolio';
|
||||||
|
export { usePortfolios } from './model/usePortfolios';
|
||||||
|
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics';
|
||||||
|
export { usePortfolioMutations } from './model/usePortfolioMutations';
|
||||||
|
export { usePositionMutations } from './model/usePositionMutations';
|
||||||
|
export {
|
||||||
|
getPortfolios,
|
||||||
|
getPortfolio,
|
||||||
|
createPortfolio,
|
||||||
|
updatePortfolio,
|
||||||
|
deletePortfolio,
|
||||||
|
addPosition,
|
||||||
|
updatePosition,
|
||||||
|
removePosition,
|
||||||
|
getPortfolioAnalytics,
|
||||||
|
} from './api/portfolioApi';
|
||||||
17
apps/frontend/src/entities/portfolio/model/usePortfolio.ts
Normal file
17
apps/frontend/src/entities/portfolio/model/usePortfolio.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getPortfolio } from '../api/portfolioApi';
|
||||||
|
import type { PortfolioDetail } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function usePortfolio(id: number) {
|
||||||
|
return useQuery<PortfolioDetail>({
|
||||||
|
queryKey: ['portfolio', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getPortfolio(id);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 900_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getPortfolioAnalytics } from '../api/portfolioApi';
|
||||||
|
import type { AnalyticsResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function usePortfolioAnalytics(portfolioId: number) {
|
||||||
|
return useQuery<AnalyticsResponse>({
|
||||||
|
queryKey: ['portfolio', portfolioId, 'analytics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getPortfolioAnalytics(portfolioId);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 900_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: !!portfolioId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function usePortfolioMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
||||||
|
createPortfolio(data),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
|
navigate(`/portfolios/${res.data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
currency?: string;
|
||||||
|
};
|
||||||
|
}) => updatePortfolio(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => deletePortfolio(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||||
|
navigate('/portfolios');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { create, update, remove };
|
||||||
|
}
|
||||||
16
apps/frontend/src/entities/portfolio/model/usePortfolios.ts
Normal file
16
apps/frontend/src/entities/portfolio/model/usePortfolios.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getPortfolios } from '../api/portfolioApi';
|
||||||
|
import type { Portfolio } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function usePortfolios() {
|
||||||
|
return useQuery<Portfolio[]>({
|
||||||
|
queryKey: ['portfolios'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getPortfolios();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 900_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { addPosition, updatePosition, removePosition } from '../api/portfolioApi';
|
||||||
|
import type { PortfolioDetail } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function usePositionMutations(portfolioId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (data: {
|
||||||
|
secid: string;
|
||||||
|
quantity: number;
|
||||||
|
buyPrice?: number;
|
||||||
|
buyDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}) => addPosition(portfolioId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
positionId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
positionId: number;
|
||||||
|
data: {
|
||||||
|
quantity?: number;
|
||||||
|
buyPrice?: number;
|
||||||
|
buyDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
}) => updatePosition(portfolioId, positionId, data),
|
||||||
|
onMutate: async ({ positionId, data }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
|
||||||
|
const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([
|
||||||
|
'portfolio',
|
||||||
|
portfolioId,
|
||||||
|
]);
|
||||||
|
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
positions: old.positions.map((p: any) =>
|
||||||
|
p.id === positionId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
...(data.quantity !== undefined ? { quantity: data.quantity } : {}),
|
||||||
|
...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}),
|
||||||
|
...(data.buyDate !== undefined ? { buyDate: data.buyDate } : {}),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(['portfolio', portfolioId], context.previous);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { add, update, remove };
|
||||||
|
}
|
||||||
68
apps/frontend/src/entities/session/api/sessionApi.test.ts
Normal file
68
apps/frontend/src/entities/session/api/sessionApi.test.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../test/server';
|
||||||
|
import { setAccessToken, getAccessToken } from '@/shared/api/client';
|
||||||
|
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setAccessToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await login('user@test.com', 'password');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid credentials', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(
|
||||||
|
`${API}/auth/login`,
|
||||||
|
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await register('new@test.com', 'password', 'New User');
|
||||||
|
expect(result.user.email).toBe('user@test.com');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('returns auth data and sets access token', async () => {
|
||||||
|
const result = await refresh();
|
||||||
|
expect(result.accessToken).toBe('mock-access-token');
|
||||||
|
expect(getAccessToken()).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('clears access token', async () => {
|
||||||
|
setAccessToken('test-token');
|
||||||
|
await logout();
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMe', () => {
|
||||||
|
it('returns current user', async () => {
|
||||||
|
const result = await getMe();
|
||||||
|
expect(result.email).toBe('user@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProfile', () => {
|
||||||
|
it('updates and returns user', async () => {
|
||||||
|
const result = await updateProfile({ name: 'Updated' });
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/frontend/src/entities/session/api/sessionApi.ts
Normal file
52
apps/frontend/src/entities/session/api/sessionApi.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { request, setAccessToken } from '@/shared/api/client';
|
||||||
|
import type { AuthResponse, UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email: string, password: string, name?: string) {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password, name },
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
skipAuth: true,
|
||||||
|
});
|
||||||
|
setAccessToken(result.data.accessToken);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
setAccessToken(null);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe() {
|
||||||
|
const result = await request<UserResponse>('/api/v1/auth/me');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(data: { name?: string }) {
|
||||||
|
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
3
apps/frontend/src/entities/session/index.ts
Normal file
3
apps/frontend/src/entities/session/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi';
|
||||||
|
export { SessionContext, type SessionContextValue } from './model/sessionContext';
|
||||||
|
export { useSession } from './model/useSession';
|
||||||
15
apps/frontend/src/entities/session/model/sessionContext.ts
Normal file
15
apps/frontend/src/entities/session/model/sessionContext.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { UserResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export interface SessionContextValue {
|
||||||
|
user: UserResponse | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
63
apps/frontend/src/entities/session/model/useSession.test.tsx
Normal file
63
apps/frontend/src/entities/session/model/useSession.test.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from '../../../context/AuthContext';
|
||||||
|
import { useSession } from './useSession';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSession', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns session context with user after mount', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.user?.email).toBe('user@test.com');
|
||||||
|
expect(result.current.accessToken).toBe('mock-access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides login function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.login).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides logout function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.logout).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides register function', async () => {
|
||||||
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
|
||||||
|
expect(typeof result.current.register).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when used without AuthProvider', () => {
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useSession(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
apps/frontend/src/entities/session/model/useSession.ts
Normal file
10
apps/frontend/src/entities/session/model/useSession.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SessionContext, type SessionContextValue } from './sessionContext';
|
||||||
|
|
||||||
|
export function useSession(): SessionContextValue {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSession must be used within a SessionProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
53
apps/frontend/src/entities/stock/api/stockApi.ts
Normal file
53
apps/frontend/src/entities/stock/api/stockApi.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { request } from '@/shared/api/client';
|
||||||
|
import type {
|
||||||
|
ApiResponseMeta,
|
||||||
|
ShareResponse,
|
||||||
|
StockMarketData,
|
||||||
|
DividendItem,
|
||||||
|
ShareHistoryItem,
|
||||||
|
CandleItem,
|
||||||
|
} from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||||
|
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareMarketData(
|
||||||
|
secid: string,
|
||||||
|
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
||||||
|
return request<StockMarketData>(
|
||||||
|
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareDividends(
|
||||||
|
secid: string,
|
||||||
|
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<DividendItem[]>(
|
||||||
|
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareHistory(
|
||||||
|
secid: string,
|
||||||
|
from: string,
|
||||||
|
till: string,
|
||||||
|
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<ShareHistoryItem[]>(
|
||||||
|
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
||||||
|
{ from, till },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareCandles(
|
||||||
|
secid: string,
|
||||||
|
interval: '1h' | '24h',
|
||||||
|
from: string,
|
||||||
|
till: string,
|
||||||
|
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||||
|
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
|
||||||
|
interval,
|
||||||
|
from,
|
||||||
|
till,
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/frontend/src/entities/stock/index.ts
Normal file
10
apps/frontend/src/entities/stock/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { useStock } from './model/useStock';
|
||||||
|
export { useStockCandles } from './model/useStockCandles';
|
||||||
|
export { useStockDividends } from './model/useStockDividends';
|
||||||
|
export {
|
||||||
|
getShare,
|
||||||
|
getShareMarketData,
|
||||||
|
getShareDividends,
|
||||||
|
getShareHistory,
|
||||||
|
getShareCandles,
|
||||||
|
} from './api/stockApi';
|
||||||
14
apps/frontend/src/entities/stock/model/useStock.ts
Normal file
14
apps/frontend/src/entities/stock/model/useStock.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getShare } from '../api/stockApi';
|
||||||
|
import type { ShareResponse } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useStock(secid: string) {
|
||||||
|
return useQuery<ShareResponse>({
|
||||||
|
queryKey: ['stock', secid],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getShare(secid);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 900_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useStockCandles } from './useStockCandles';
|
import { useStockCandles } from './useStockCandles';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
14
apps/frontend/src/entities/stock/model/useStockCandles.ts
Normal file
14
apps/frontend/src/entities/stock/model/useStockCandles.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getShareCandles } from '../api/stockApi';
|
||||||
|
import type { CandleItem } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||||
|
return useQuery<CandleItem[]>({
|
||||||
|
queryKey: ['stockCandles', secid, interval, from, till],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getShareCandles(secid, interval, from, till);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 3600_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../test/server';
|
import { server } from '../../../test/server';
|
||||||
import { useStockDividends } from './useStockDividends';
|
import { useStockDividends } from './useStockDividends';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
14
apps/frontend/src/entities/stock/model/useStockDividends.ts
Normal file
14
apps/frontend/src/entities/stock/model/useStockDividends.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getShareDividends } from '../api/stockApi';
|
||||||
|
import type { DividendItem } from '@/shared/api/responses';
|
||||||
|
|
||||||
|
export function useStockDividends(secid: string) {
|
||||||
|
return useQuery<DividendItem[]>({
|
||||||
|
queryKey: ['stockDividends', secid],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getShareDividends(secid);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 86400_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -58,6 +58,6 @@ describe('useAuth', () => {
|
|||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}).toThrow('useAuth must be used within an AuthProvider');
|
}).toThrow('useSession must be used within a SessionProvider');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1 @@
|
|||||||
import { useContext } from 'react';
|
export { useSession as useAuth } from '../entities/session/model/useSession';
|
||||||
import { AuthContext, type AuthContextValue } from '../context/AuthContext';
|
|
||||||
|
|
||||||
export function useAuth(): AuthContextValue {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,14 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBond } from '../entities/bond/model/useBond';
|
||||||
import { getBond } from '../api/client';
|
|
||||||
import type { BondResponse } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBond(secid: string) {
|
|
||||||
return useQuery<BondResponse>({
|
|
||||||
queryKey: ['bond', secid],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getBond(secid);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 900_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,14 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBondCandles } from '../entities/bond/model/useBondCandles';
|
||||||
import { getBondCandles } from '../api/client';
|
|
||||||
import type { CandleItem } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
|
||||||
return useQuery<CandleItem[]>({
|
|
||||||
queryKey: ['bondCandles', secid, interval, from, till],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getBondCandles(secid, interval, from, till);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 3600_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getBrokerPortfolio } from '../api/broker';
|
import { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi';
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
||||||
|
|
||||||
vi.mock('../api/broker', () => ({
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
getBrokerPortfolio: vi.fn(),
|
getBrokerPortfolio: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1 @@
|
|||||||
import { useQueries } from '@tanstack/react-query';
|
export { useBrokerAccountPortfolios } from '../entities/broker-account';
|
||||||
import { getBrokerPortfolio } from '../api/broker';
|
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
|
||||||
const queries = useQueries({
|
|
||||||
queries: accounts.map((account) => ({
|
|
||||||
queryKey: ['broker', 'portfolio', account.id],
|
|
||||||
queryFn: async (): Promise<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
return accounts.map((account, index) => ({ account, query: queries[index] }));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { getBrokerAccounts } from '../api/broker';
|
import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
|
||||||
import { useBrokerAccounts } from './useBrokerAccounts';
|
import { useBrokerAccounts } from './useBrokerAccounts';
|
||||||
|
|
||||||
vi.mock('../api/broker', () => ({
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
getBrokerAccounts: vi.fn(),
|
getBrokerAccounts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBrokerAccounts } from '../entities/broker-account';
|
||||||
import { getBrokerAccounts } from '../api/broker';
|
|
||||||
import type { BrokerAccount } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBrokerAccounts() {
|
|
||||||
return useQuery<BrokerAccount[]>({
|
|
||||||
queryKey: ['broker', 'accounts'],
|
|
||||||
queryFn: async () => (await getBrokerAccounts()).data,
|
|
||||||
staleTime: 3_600_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
export { useBrokerOperations } from '../entities/broker-operation';
|
||||||
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
|
|
||||||
import type { BrokerOperationsPage } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBrokerOperations(
|
|
||||||
accountId: string | undefined,
|
|
||||||
query: BrokerOperationQuery = {},
|
|
||||||
) {
|
|
||||||
return useQuery<BrokerOperationsPage>({
|
|
||||||
queryKey: ['broker', 'operations', accountId, query],
|
|
||||||
enabled: Boolean(accountId),
|
|
||||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
|
||||||
staleTime: 300_000,
|
|
||||||
retry: 2,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,14 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBrokerPortfolio } from '../entities/broker-account';
|
||||||
import { getBrokerPortfolio } from '../api/broker';
|
|
||||||
import type { BrokerPortfolio } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
|
||||||
return useQuery<BrokerPortfolio>({
|
|
||||||
queryKey: ['broker', 'portfolio', accountId],
|
|
||||||
enabled: Boolean(accountId),
|
|
||||||
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
export { useBrokerPositions } from '../entities/broker-position';
|
||||||
import { getBrokerPositions } from '../api/broker';
|
|
||||||
import type { BrokerPositionsPage } from '../api/responses';
|
|
||||||
|
|
||||||
export function useBrokerPositions(
|
|
||||||
accountId: string | undefined,
|
|
||||||
query: { cursor?: string; limit?: number; type?: string } = {},
|
|
||||||
) {
|
|
||||||
return useQuery<BrokerPositionsPage>({
|
|
||||||
queryKey: ['broker', 'positions', accountId, query],
|
|
||||||
enabled: Boolean(accountId),
|
|
||||||
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: 2,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { usePortfolio } from '../entities/portfolio/model/usePortfolio';
|
||||||
import { getPortfolio } from '../api/portfolio';
|
|
||||||
import type { PortfolioDetail } from '../api/responses';
|
|
||||||
|
|
||||||
export function usePortfolio(id: number) {
|
|
||||||
return useQuery<PortfolioDetail>({
|
|
||||||
queryKey: ['portfolio', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getPortfolio(id);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 900_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics';
|
||||||
import { getPortfolioAnalytics } from '../api/portfolio';
|
|
||||||
import type { AnalyticsResponse } from '../api/responses';
|
|
||||||
|
|
||||||
export function usePortfolioAnalytics(portfolioId: number) {
|
|
||||||
return useQuery<AnalyticsResponse>({
|
|
||||||
queryKey: ['portfolio', portfolioId, 'analytics'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getPortfolioAnalytics(portfolioId);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 900_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
enabled: !!portfolioId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,45 +1 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations';
|
||||||
import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolio';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export function usePortfolioMutations() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const create = useMutation({
|
|
||||||
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
|
||||||
createPortfolio(data),
|
|
||||||
onSuccess: (res) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
|
||||||
navigate(`/portfolios/${res.data.id}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const update = useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
data: {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
currency?: string;
|
|
||||||
};
|
|
||||||
}) => updatePortfolio(id, data),
|
|
||||||
onSuccess: (_, { id }) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const remove = useMutation({
|
|
||||||
mutationFn: (id: number) => deletePortfolio(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
|
||||||
navigate('/portfolios');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { create, update, remove };
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user