feat: add broker portfolio UI

This commit is contained in:
Sergey Krylov 2026-06-16 23:05:52 +03:00
parent 50bff3dbe7
commit ead40bff6f
5 changed files with 417 additions and 3 deletions

View File

@ -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
</Link>
<SearchBar />
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
<Link
to="/portfolios"
style={{
@ -46,6 +49,17 @@ export function Layout() {
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
@ -58,7 +72,15 @@ export function Layout() {
Скринер
</Link>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
{isAuthenticated ? (
<>
<Link
@ -104,7 +126,15 @@ export function Layout() {
)}
</div>
</header>
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
<main
style={{
flex: 1,
padding: 'clamp(16px, 4vw, 24px)',
maxWidth: 1200,
width: '100%',
margin: '0 auto',
}}
>
<Outlet />
</main>
</div>

View File

@ -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 <p>Загрузка портфеля...</p>;
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
return (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
<th align="right" style={thStyle}>
Доходность
</th>
</tr>
</thead>
<tbody>
{portfolio.data.positions.map((position) => (
<tr key={position.positionUid || position.instrumentUid || position.ticker}>
<td style={tdStyle}>
<strong>{position.ticker || position.name || position.figi}</strong>
{position.name && (
<div style={{ color: 'var(--color-text-secondary)' }}>{position.name}</div>
)}
</td>
<td align="right" style={tdStyle}>
{position.quantity ?? '-'}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
<td align="right" style={tdStyle}>
{position.expectedYieldPercent ?? '-'}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Операции</h2>
{operations.isLoading ? (
<p>Загрузка операций...</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{(operations.data?.items ?? []).map((operation) => (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>{operation.type}</td>
<td style={tdStyle}>{operation.ticker || operation.description || '-'}</td>
<td align="right" style={tdStyle}>
{formatMoney(operation.payment)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@ -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 <p>Загрузка брокерских счетов...</p>;
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
{(accounts ?? []).length}
</span>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{(accounts ?? []).map((account) => (
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
<span>{account.status}</span>
<span>{account.id}</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
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(<BrokerAccountsPage />);
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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
});
});

View File

@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountDetailPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
);