feat: add broker portfolio UI
This commit is contained in:
parent
50bff3dbe7
commit
ead40bff6f
@ -20,6 +20,7 @@ export function Layout() {
|
|||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
gap: 24,
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -34,7 +35,9 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
MoexVibe
|
MoexVibe
|
||||||
</Link>
|
</Link>
|
||||||
|
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/portfolios"
|
to="/portfolios"
|
||||||
style={{
|
style={{
|
||||||
@ -46,6 +49,17 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
Портфели
|
Портфели
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/broker"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Брокер
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/screener"
|
to="/screener"
|
||||||
style={{
|
style={{
|
||||||
@ -58,7 +72,15 @@ export function Layout() {
|
|||||||
Скринер
|
Скринер
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@ -104,7 +126,15 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
179
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal file
179
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal file
50
apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal file
137
apps/frontend/src/pages/broker/BrokerPages.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,8 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
|||||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||||
|
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
||||||
|
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -45,6 +47,22 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/broker"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BrokerAccountsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/broker/:accountId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BrokerAccountDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user