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.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() {
}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
);