diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 29ac2b5..20ed33a 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -20,6 +20,7 @@ export function Layout() { padding: '12px 24px', display: 'flex', alignItems: 'center', + flexWrap: 'wrap', gap: 24, }} > @@ -34,7 +35,9 @@ export function Layout() { > MoexVibe - +
+ +
Портфели + + Брокер + -
+
{isAuthenticated ? ( <> -
+
diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx new file mode 100644 index 0000000..f1759a6 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -0,0 +1,179 @@ +import { useParams } from 'react-router-dom'; +import type { BrokerMoney } from '../../api/responses'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatDate(value: string | null) { + if (!value) return '-'; + + return new Date(value).toLocaleString('ru-RU'); +} + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +export function BrokerAccountDetailPage() { + const { accountId } = useParams(); + const portfolio = useBrokerPortfolio(accountId); + const operations = useBrokerOperations(accountId, { limit: 100 }); + + if (portfolio.isLoading) return

Загрузка портфеля...

; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить портфель

; + } + + return ( +
+
+

+ {portfolio.data.account.name} +

+
+ {formatMoney(portfolio.data.totals.portfolio)} + + День: {formatMoney(portfolio.data.yields.daily)} + + + Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}% + +
+
+ +
+ {portfolio.data.cash.map((money) => ( +
+
+ {money.currency} +
+ {formatMoney(money)} +
+ ))} +
+ +
+

Позиции

+
+ + + + + + + + + + + {portfolio.data.positions.map((position) => ( + + + + + + + ))} + +
+ Инструмент + + Количество + + Стоимость + + Доходность +
+ {position.ticker || position.name || position.figi} + {position.name && ( +
{position.name}
+ )} +
+ {position.quantity ?? '-'} + + {formatMoney(position.currentValue)} + + {position.expectedYieldPercent ?? '-'}% +
+
+
+ +
+

Операции

+ {operations.isLoading ? ( +

Загрузка операций...

+ ) : ( +
+ + + + + + + + + + + {(operations.data?.items ?? []).map((operation) => ( + + + + + + + ))} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)}{operation.type}{operation.ticker || operation.description || '-'} + {formatMoney(operation.payment)} +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx new file mode 100644 index 0000000..0089983 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -0,0 +1,50 @@ +import { Link } from 'react-router-dom'; +import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; + +const cardStyle = { + display: 'block', + padding: 20, + background: 'var(--color-surface)', + border: '1px solid #e0e0e0', + borderRadius: 8, + color: 'var(--color-text)', + textDecoration: 'none', + boxShadow: 'var(--shadow)', +} satisfies React.CSSProperties; + +export function BrokerAccountsPage() { + const { data: accounts, isLoading, error } = useBrokerAccounts(); + + if (isLoading) return

Загрузка брокерских счетов...

; + if (error) return

Не удалось загрузить счета

; + + return ( +
+
+

Брокерские счета

+ + {(accounts ?? []).length} + +
+ +
+ {(accounts ?? []).map((account) => ( + +
{account.name}
+
+ {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'} + {account.status} + {account.id} +
+ + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx new file mode 100644 index 0000000..c4f19f5 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -0,0 +1,137 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { type ReactElement } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import * as accountHook from '../../hooks/useBrokerAccounts'; +import * as operationsHook from '../../hooks/useBrokerOperations'; +import * as portfolioHook from '../../hooks/useBrokerPortfolio'; +import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; +import { BrokerAccountsPage } from './BrokerAccountsPage'; + +function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return render( + + {ui} + , + ); +} + +describe('Broker pages', () => { + it('renders broker and IIS accounts', () => { + vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ + data: [ + { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + { + id: 'acc-2', + type: 'iis', + name: 'IIS', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + ], + isLoading: false, + error: null, + } as any); + + renderWithClient(); + + expect(screen.getByText('Broker')).toBeInTheDocument(); + expect(screen.getByText('IIS')).toBeInTheDocument(); + }); + + it('renders positions and operations for account detail', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], + blockedCash: [], + positions: [ + { + figi: null, + instrumentUid: 'uid-1', + positionUid: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + name: 'Sberbank', + quantity: 10, + blockedLots: null, + currentPrice: null, + currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + }, + ], + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ + { + cursor: 'cursor-1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-16T00:00:00.000Z', + category: 'trade', + type: 'OPERATION_TYPE_BUY', + description: 'Buy', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'uid-1', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 10, + quantityDone: 10, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getAllByText('SBER').length).toBeGreaterThan(0); + expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index c285c4a..fd5ad03 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -10,6 +10,8 @@ import { ProtectedRoute } from './components/ProtectedRoute'; import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; import { ScreenerPage } from './pages/screener/ScreenerPage'; +import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage'; +import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage'; export function AppRoutes() { return ( @@ -45,6 +47,22 @@ export function AppRoutes() { } /> + + + + } + /> + + + + } + /> );