feat: add broker portfolio UI
This commit is contained in:
parent
50bff3dbe7
commit
ead40bff6f
@ -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>
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user