feat: improove ui
All checks were successful
All checks were successful
This commit is contained in:
parent
a8715258ca
commit
bd8d6c11fe
@ -1,7 +1,10 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import type { BrokerMoney } from '../../api/responses';
|
import type { BrokerMoney } from '../../api/responses';
|
||||||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||||
|
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||||
|
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@ -13,41 +16,35 @@ function formatMoney(value: BrokerMoney | null | undefined) {
|
|||||||
}).format(value.value);
|
}).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() {
|
export function BrokerAccountDetailPage() {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
|
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
const portfolio = useBrokerPortfolio(accountId);
|
const portfolio = useBrokerPortfolio(accountId);
|
||||||
const operations = useBrokerOperations(accountId, { limit: 100 });
|
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
||||||
|
|
||||||
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
|
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
|
||||||
if (portfolio.error || !portfolio.data) {
|
if (portfolio.error || !portfolio.data) {
|
||||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNextOperationsPage() {
|
||||||
|
const nextCursor = operations.data?.nextCursor;
|
||||||
|
if (!nextCursor || !operations.data?.hasNext) return;
|
||||||
|
|
||||||
|
setOperationCursorStack((previous) => [...previous, operationCursor]);
|
||||||
|
setOperationCursor(nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviousOperationsPage() {
|
||||||
|
if (operationCursorStack.length === 0) return;
|
||||||
|
|
||||||
|
const nextStack = operationCursorStack.slice(0, -1);
|
||||||
|
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
|
||||||
|
setOperationCursorStack(nextStack);
|
||||||
|
setOperationCursor(previousCursor);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 24 }}>
|
<div style={{ display: 'grid', gap: 24 }}>
|
||||||
<header>
|
<header>
|
||||||
@ -90,90 +87,17 @@ export function BrokerAccountDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<BrokerPositionsSection positions={portfolio.data.positions} />
|
||||||
<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>
|
<BrokerOperationsTable
|
||||||
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Операции</h2>
|
isLoading={operations.isLoading}
|
||||||
{operations.isLoading ? (
|
page={operations.data}
|
||||||
<p>Загрузка операций...</p>
|
pageNumber={operationCursorStack.length + 1}
|
||||||
) : (
|
canGoBack={operationCursorStack.length > 0}
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
|
||||||
<table style={tableStyle}>
|
onPrevious={handlePreviousOperationsPage}
|
||||||
<thead>
|
onNext={handleNextOperationsPage}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
205
apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
Normal file
205
apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
|
||||||
|
import {
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationImpactLabel,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
type BrokerOperationImpact,
|
||||||
|
} from './brokerDisplay';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const impactStyles: Record<BrokerOperationImpact, React.CSSProperties> = {
|
||||||
|
adds: {
|
||||||
|
background: 'rgba(46, 125, 50, 0.1)',
|
||||||
|
color: 'var(--color-positive)',
|
||||||
|
},
|
||||||
|
reduces: {
|
||||||
|
background: 'rgba(198, 40, 40, 0.1)',
|
||||||
|
color: 'var(--color-negative)',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
background: 'rgba(25, 118, 210, 0.1)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
background: 'rgba(102, 102, 102, 0.12)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyColor(impact: BrokerOperationImpact): string {
|
||||||
|
if (impact === 'adds') return 'var(--color-positive)';
|
||||||
|
if (impact === 'reduces') return 'var(--color-negative)';
|
||||||
|
|
||||||
|
return 'var(--color-text)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||||
|
const label = operation.ticker || operation.description || '-';
|
||||||
|
const path = getBrokerInstrumentPath({
|
||||||
|
ticker: operation.ticker,
|
||||||
|
instrumentType: operation.instrumentType,
|
||||||
|
classCode: operation.classCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!path || label === '-') {
|
||||||
|
return <span>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Link to={path}>{label}</Link>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationType({ operation }: { operation: BrokerOperation }) {
|
||||||
|
const impact = getBrokerOperationImpact(operation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
justifySelf: 'start',
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '5px 8px',
|
||||||
|
...impactStyles[impact],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getBrokerOperationImpactLabel(impact)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerOperationsTable({
|
||||||
|
isLoading,
|
||||||
|
page,
|
||||||
|
pageNumber,
|
||||||
|
canGoBack,
|
||||||
|
canGoForward,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
page: BrokerOperationsPage | undefined;
|
||||||
|
pageNumber: number;
|
||||||
|
canGoBack: boolean;
|
||||||
|
canGoForward: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const operations = page?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button type="button" onClick={onPrevious} disabled={!canGoBack}>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
||||||
|
Страница {pageNumber}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onNext} disabled={!canGoForward}>
|
||||||
|
Вперед
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Загрузка операций...</p>
|
||||||
|
) : operations.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</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.map((operation) => {
|
||||||
|
const impact = getBrokerOperationImpact(operation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={operation.cursor || operation.id}>
|
||||||
|
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<OperationType operation={operation} />
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<OperationInstrument operation={operation} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
align="right"
|
||||||
|
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{formatMoney(operation.payment)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { type ReactElement } from 'react';
|
import { type ReactElement } from 'react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
@ -132,6 +133,307 @@ describe('Broker pages', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument();
|
expect(screen.getByText('Покупка')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders broker positions as separate linked stock and bond tables with current price', () => {
|
||||||
|
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: '10000', nano: 0, value: 10000 } },
|
||||||
|
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||||||
|
cash: [],
|
||||||
|
blockedCash: [],
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
positionUid: null,
|
||||||
|
ticker: 'SBER',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
quantity: 10,
|
||||||
|
blockedLots: null,
|
||||||
|
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
||||||
|
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||||||
|
averagePositionPrice: null,
|
||||||
|
expectedYieldPercent: 20,
|
||||||
|
dailyYield: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: 'bond-uid',
|
||||||
|
positionUid: null,
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
name: 'ОФЗ 26238',
|
||||||
|
quantity: 2,
|
||||||
|
blockedLots: null,
|
||||||
|
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
||||||
|
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
||||||
|
averagePositionPrice: null,
|
||||||
|
expectedYieldPercent: 10,
|
||||||
|
dailyYield: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderWithClient(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
||||||
|
</Routes>,
|
||||||
|
['/broker/acc-1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2);
|
||||||
|
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
|
||||||
|
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bonds/SU26238RMFS5',
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/250,00/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/900,00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders broker operations with Russian labels, linked instruments and impact badges', () => {
|
||||||
|
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: '10000', nano: 0, value: 10000 } },
|
||||||
|
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||||
|
cash: [],
|
||||||
|
blockedCash: [],
|
||||||
|
positions: [],
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
cursor: 'op-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: 'op-1',
|
||||||
|
parentOperationId: null,
|
||||||
|
date: '2026-06-17T10:00:00.000Z',
|
||||||
|
category: 'income',
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
description: 'Coupon',
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
instrumentUid: 'bond-uid',
|
||||||
|
figi: null,
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
|
||||||
|
price: null,
|
||||||
|
commission: null,
|
||||||
|
yield: null,
|
||||||
|
accruedInt: null,
|
||||||
|
quantity: 2,
|
||||||
|
quantityDone: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: 'op-2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: 'op-2',
|
||||||
|
parentOperationId: null,
|
||||||
|
date: '2026-06-17T11:00:00.000Z',
|
||||||
|
category: 'tax',
|
||||||
|
type: 'OPERATION_TYPE_TAX',
|
||||||
|
description: 'Tax',
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
instrumentUid: null,
|
||||||
|
figi: null,
|
||||||
|
ticker: null,
|
||||||
|
classCode: null,
|
||||||
|
instrumentType: null,
|
||||||
|
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
|
||||||
|
price: null,
|
||||||
|
commission: null,
|
||||||
|
yield: null,
|
||||||
|
accruedInt: null,
|
||||||
|
quantity: null,
|
||||||
|
quantityDone: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderWithClient(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
||||||
|
</Routes>,
|
||||||
|
['/broker/acc-1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Налог')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Пополняет')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Списывает')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bonds/SU26238RMFS5',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests broker operations by cursor with a page size of 10', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
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: '10000', nano: 0, value: 10000 } },
|
||||||
|
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||||
|
cash: [],
|
||||||
|
blockedCash: [],
|
||||||
|
positions: [],
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
|
||||||
|
(_accountId, query) =>
|
||||||
|
({
|
||||||
|
data:
|
||||||
|
query.cursor === 'cursor-page-2'
|
||||||
|
? {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
cursor: 'op-page-2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: 'op-page-2',
|
||||||
|
parentOperationId: null,
|
||||||
|
date: '2026-06-17T12:00:00.000Z',
|
||||||
|
category: 'trade',
|
||||||
|
type: 'OPERATION_TYPE_SELL',
|
||||||
|
description: 'Sell',
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
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: 1,
|
||||||
|
quantityDone: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
cursor: 'op-page-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: 'op-page-1',
|
||||||
|
parentOperationId: null,
|
||||||
|
date: '2026-06-17T10:00:00.000Z',
|
||||||
|
category: 'trade',
|
||||||
|
type: 'OPERATION_TYPE_BUY',
|
||||||
|
description: 'Buy',
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
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: 1,
|
||||||
|
quantityDone: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: 'cursor-page-2',
|
||||||
|
hasNext: true,
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithClient(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
|
||||||
|
</Routes>,
|
||||||
|
['/broker/acc-1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
|
expect(screen.getByText('Страница 1')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Вперед' }));
|
||||||
|
|
||||||
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
limit: 10,
|
||||||
|
cursor: 'cursor-page-2',
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Страница 2')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Назад' }));
|
||||||
|
|
||||||
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
|
expect(screen.getByText('Страница 1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
151
apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
Normal file
151
apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
||||||
|
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
|
||||||
|
|
||||||
|
type BrokerPositionGroupConfig = {
|
||||||
|
key: 'shares' | 'bonds' | 'other';
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUPS: BrokerPositionGroupConfig[] = [
|
||||||
|
{ key: 'shares', title: 'Акции' },
|
||||||
|
{ key: 'bonds', title: 'Облигации' },
|
||||||
|
{ key: 'other', title: 'Другие инструменты' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 formatQuantity(value: number | null | undefined) {
|
||||||
|
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionTicker({ position }: { position: BrokerPosition }) {
|
||||||
|
const label = position.ticker || position.figi || '-';
|
||||||
|
const path = getBrokerInstrumentPath({
|
||||||
|
ticker: position.ticker,
|
||||||
|
instrumentType: position.instrumentType,
|
||||||
|
classCode: position.classCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!path || label === '-') {
|
||||||
|
return <strong>{label}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
|
||||||
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Тикер
|
||||||
|
</th>
|
||||||
|
<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>
|
||||||
|
{positions.map((position) => (
|
||||||
|
<tr
|
||||||
|
key={
|
||||||
|
position.positionUid || position.instrumentUid || position.ticker || position.figi
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<PositionTicker position={position} />
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
{position.name || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatQuantity(position.quantity)}
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatMoney(position.currentPrice)}
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
|
{formatMoney(position.currentValue)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) {
|
||||||
|
const grouped = GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key),
|
||||||
|
})).filter((group) => group.positions.length > 0);
|
||||||
|
|
||||||
|
if (grouped.length === 0) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
|
||||||
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<PositionTable key={group.key} title={group.title} positions={group.positions} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
apps/frontend/src/pages/broker/brokerDisplay.test.ts
Normal file
201
apps/frontend/src/pages/broker/brokerDisplay.test.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
||||||
|
import {
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationImpactLabel,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
getBrokerPositionGroup,
|
||||||
|
} from './brokerDisplay';
|
||||||
|
|
||||||
|
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
||||||
|
return {
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
positionUid: null,
|
||||||
|
ticker: null,
|
||||||
|
classCode: null,
|
||||||
|
instrumentType: null,
|
||||||
|
name: null,
|
||||||
|
quantity: null,
|
||||||
|
blockedLots: null,
|
||||||
|
currentPrice: null,
|
||||||
|
currentValue: null,
|
||||||
|
averagePositionPrice: null,
|
||||||
|
expectedYieldPercent: null,
|
||||||
|
dailyYield: null,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
||||||
|
return {
|
||||||
|
cursor: null,
|
||||||
|
accountId: 'acc-1',
|
||||||
|
id: null,
|
||||||
|
parentOperationId: null,
|
||||||
|
date: null,
|
||||||
|
type: 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
|
category: 'other',
|
||||||
|
description: null,
|
||||||
|
state: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
figi: null,
|
||||||
|
ticker: null,
|
||||||
|
classCode: null,
|
||||||
|
instrumentType: null,
|
||||||
|
payment: null,
|
||||||
|
price: null,
|
||||||
|
commission: null,
|
||||||
|
yield: null,
|
||||||
|
accruedInt: null,
|
||||||
|
quantity: null,
|
||||||
|
quantityDone: null,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('broker display helpers', () => {
|
||||||
|
it('groups positions by instrument type', () => {
|
||||||
|
expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares');
|
||||||
|
expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds');
|
||||||
|
expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other');
|
||||||
|
expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds stock and bond routes from instrument metadata', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }),
|
||||||
|
).toBe('/stocks/SBER');
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
}),
|
||||||
|
).toBe('/bonds/SU26238RMFS5');
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses class code fallback when instrument type is missing', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }),
|
||||||
|
).toBe('/stocks/SBER');
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }),
|
||||||
|
).toBe('/bonds/RU000A0JX0J2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let class code override a known unsupported or conflicting instrument type', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getBrokerInstrumentPath({
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
}),
|
||||||
|
).toBe('/bonds/SU26238RMFS5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps operation enum values to Russian labels', () => {
|
||||||
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe(
|
||||||
|
'Выплата купона',
|
||||||
|
);
|
||||||
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог');
|
||||||
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка');
|
||||||
|
expect(
|
||||||
|
getBrokerOperationTypeLabel(
|
||||||
|
operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }),
|
||||||
|
),
|
||||||
|
).toBe('Custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies operations by portfolio impact', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
category: 'income',
|
||||||
|
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('adds');
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_TAX',
|
||||||
|
category: 'tax',
|
||||||
|
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('reduces');
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_SELL',
|
||||||
|
category: 'trade',
|
||||||
|
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('neutral');
|
||||||
|
expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe(
|
||||||
|
'unknown',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unknown operation types unclear even when they have non-zero payments', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE',
|
||||||
|
category: 'other',
|
||||||
|
payment: { currency: 'RUB', units: '100', nano: 0, value: 100 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('unknown');
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE',
|
||||||
|
category: 'other',
|
||||||
|
payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies known income operation types as additions even with weak metadata', () => {
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
category: 'other',
|
||||||
|
payment: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('adds');
|
||||||
|
expect(
|
||||||
|
getBrokerOperationImpact(
|
||||||
|
operation({
|
||||||
|
type: 'OPERATION_TYPE_DIVIDEND',
|
||||||
|
category: 'other',
|
||||||
|
payment: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe('adds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides Russian impact labels', () => {
|
||||||
|
expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет');
|
||||||
|
expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает');
|
||||||
|
expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка');
|
||||||
|
expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно');
|
||||||
|
});
|
||||||
|
});
|
||||||
176
apps/frontend/src/pages/broker/brokerDisplay.ts
Normal file
176
apps/frontend/src/pages/broker/brokerDisplay.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
||||||
|
|
||||||
|
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
||||||
|
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
|
||||||
|
|
||||||
|
type BrokerInstrumentLinkInput = {
|
||||||
|
ticker: string | null;
|
||||||
|
instrumentType: string | null;
|
||||||
|
classCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STOCK_CLASS_CODES = new Set(['TQBR']);
|
||||||
|
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']);
|
||||||
|
|
||||||
|
const TRADE_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BUY',
|
||||||
|
'OPERATION_TYPE_BUY_CARD',
|
||||||
|
'OPERATION_TYPE_SELL',
|
||||||
|
'OPERATION_TYPE_SELL_CARD',
|
||||||
|
'OPERATION_TYPE_BUY_MARGIN',
|
||||||
|
'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
'OPERATION_TYPE_DELIVERY_BUY',
|
||||||
|
'OPERATION_TYPE_DELIVERY_SELL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BOND_REPAYMENT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BOND_REPAYMENT',
|
||||||
|
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
|
||||||
|
|
||||||
|
const TAX_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_TAX',
|
||||||
|
'OPERATION_TYPE_BOND_TAX',
|
||||||
|
'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
|
'OPERATION_TYPE_TAX_CORRECTION',
|
||||||
|
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FEE_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_BROKER_FEE',
|
||||||
|
'OPERATION_TYPE_SERVICE_FEE',
|
||||||
|
'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSFER_INPUT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_INPUT',
|
||||||
|
'OPERATION_TYPE_INPUT_SWIFT',
|
||||||
|
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||||
|
'OPERATION_TYPE_INP_MULTI',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TRANSFER_OUTPUT_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_OUTPUT',
|
||||||
|
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||||
|
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||||
|
'OPERATION_TYPE_OUT_MULTI',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SECURITY_TRANSFER_TYPES = new Set([
|
||||||
|
'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
|
'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OPERATION_TYPE_LABELS: Record<string, string> = {
|
||||||
|
OPERATION_TYPE_BUY: 'Покупка',
|
||||||
|
OPERATION_TYPE_BUY_CARD: 'Покупка',
|
||||||
|
OPERATION_TYPE_SELL: 'Продажа',
|
||||||
|
OPERATION_TYPE_SELL_CARD: 'Продажа',
|
||||||
|
OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой',
|
||||||
|
OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой',
|
||||||
|
OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки',
|
||||||
|
OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи',
|
||||||
|
OPERATION_TYPE_COUPON: 'Выплата купона',
|
||||||
|
OPERATION_TYPE_DIVIDEND: 'Дивиденды',
|
||||||
|
OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации',
|
||||||
|
OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации',
|
||||||
|
OPERATION_TYPE_TAX: 'Налог',
|
||||||
|
OPERATION_TYPE_BOND_TAX: 'Налог по облигациям',
|
||||||
|
OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды',
|
||||||
|
OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога',
|
||||||
|
OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону',
|
||||||
|
OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера',
|
||||||
|
OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание',
|
||||||
|
OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу',
|
||||||
|
OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат',
|
||||||
|
OPERATION_TYPE_INPUT: 'Пополнение',
|
||||||
|
OPERATION_TYPE_OUTPUT: 'Вывод средств',
|
||||||
|
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
|
||||||
|
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBrokerPositionGroup(
|
||||||
|
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||||
|
): BrokerPositionGroup {
|
||||||
|
const instrumentType = position.instrumentType?.toLowerCase();
|
||||||
|
|
||||||
|
if (instrumentType === 'share') return 'shares';
|
||||||
|
if (instrumentType === 'bond') return 'bonds';
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null {
|
||||||
|
const ticker = input.ticker?.trim().toUpperCase();
|
||||||
|
if (!ticker) return null;
|
||||||
|
|
||||||
|
const instrumentType = input.instrumentType?.toLowerCase();
|
||||||
|
const classCode = input.classCode?.toUpperCase() ?? null;
|
||||||
|
|
||||||
|
if (instrumentType === 'share') {
|
||||||
|
return `/stocks/${encodeURIComponent(ticker)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instrumentType === 'bond') {
|
||||||
|
return `/bonds/${encodeURIComponent(ticker)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instrumentType) return null;
|
||||||
|
|
||||||
|
if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`;
|
||||||
|
if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperationTypeLabel(
|
||||||
|
operation: Pick<BrokerOperation, 'type' | 'description'>,
|
||||||
|
): string {
|
||||||
|
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
|
||||||
|
if (knownLabel) return knownLabel;
|
||||||
|
if (operation.description) return operation.description;
|
||||||
|
|
||||||
|
return operation.type
|
||||||
|
.replace(/^OPERATION_TYPE_/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperationImpact(
|
||||||
|
operation: Pick<BrokerOperation, 'type' | 'category' | 'payment'>,
|
||||||
|
): BrokerOperationImpact {
|
||||||
|
if (
|
||||||
|
TRADE_TYPES.has(operation.type) ||
|
||||||
|
BOND_REPAYMENT_TYPES.has(operation.type) ||
|
||||||
|
SECURITY_TRANSFER_TYPES.has(operation.type)
|
||||||
|
) {
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INCOME_TYPES.has(operation.type)) return 'adds';
|
||||||
|
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
|
||||||
|
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
|
||||||
|
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
|
||||||
|
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
|
||||||
|
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string {
|
||||||
|
switch (impact) {
|
||||||
|
case 'adds':
|
||||||
|
return 'Пополняет';
|
||||||
|
case 'reduces':
|
||||||
|
return 'Списывает';
|
||||||
|
case 'neutral':
|
||||||
|
return 'Перекладка';
|
||||||
|
case 'unknown':
|
||||||
|
return 'Неясно';
|
||||||
|
}
|
||||||
|
}
|
||||||
3552
docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md
Normal file
3552
docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md
Normal file
File diff suppressed because it is too large
Load Diff
1339
docs/superpowers/plans/2026-06-17-broker-portfolio-display.md
Normal file
1339
docs/superpowers/plans/2026-06-17-broker-portfolio-display.md
Normal file
File diff suppressed because it is too large
Load Diff
165
docs/superpowers/specs/2026-06-17-broker-portfolio-display.md
Normal file
165
docs/superpowers/specs/2026-06-17-broker-portfolio-display.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# Улучшение отображения брокерского портфеля
|
||||||
|
|
||||||
|
Дата: 2026-06-17
|
||||||
|
Статус: согласовано к планированию
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Страница брокерского счета находится в `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`.
|
||||||
|
Сейчас она показывает все позиции одной таблицей, выводит колонку `Доходность`, не показывает отдельную
|
||||||
|
колонку текущей цены, не делает тикеры кликабельными и загружает операции одним запросом с `limit: 100`.
|
||||||
|
|
||||||
|
Данные для страницы уже есть в текущем frontend-контракте:
|
||||||
|
|
||||||
|
- `BrokerPosition.instrumentType`, `ticker`, `name`, `quantity`, `currentPrice`, `currentValue`;
|
||||||
|
- `BrokerOperation.type`, `category`, `description`, `ticker`, `instrumentType`, `payment`, `price`;
|
||||||
|
- `BrokerOperationsPage.nextCursor` и `hasNext` для cursor-пагинации.
|
||||||
|
|
||||||
|
Бэкенд и публичный API для этой задачи менять не нужно.
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Сделать страницу брокерского портфеля легче для чтения: разделить классы инструментов, убрать
|
||||||
|
лишнюю доходность из таблицы позиций, добавить текущую цену, сделать переходы к карточкам инструментов
|
||||||
|
и явно показать финансовый смысл операций.
|
||||||
|
|
||||||
|
## Область изменений
|
||||||
|
|
||||||
|
В рамках задачи меняется только frontend брокерской страницы:
|
||||||
|
|
||||||
|
- `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`;
|
||||||
|
- новые helper-функции и компоненты внутри `apps/frontend/src/pages/broker/`;
|
||||||
|
- тесты в `apps/frontend/src/pages/broker/` и существующий `BrokerPages.test.tsx`.
|
||||||
|
|
||||||
|
`apps/docs`, backend DTO, OpenAPI и codegen не меняются.
|
||||||
|
|
||||||
|
## Позиции
|
||||||
|
|
||||||
|
Позиции брокерского портфеля показываются отдельными секциями:
|
||||||
|
|
||||||
|
- `Акции` для `instrumentType === 'share'`;
|
||||||
|
- `Облигации` для `instrumentType === 'bond'`;
|
||||||
|
- `Другие инструменты`, если в портфеле есть позиции с другим или неизвестным типом.
|
||||||
|
|
||||||
|
Пустые секции не отображаются.
|
||||||
|
|
||||||
|
Таблица каждой секции использует компактные колонки:
|
||||||
|
|
||||||
|
| Колонка | Источник | Поведение |
|
||||||
|
| ---------- | -------------- | ---------------------------------------------------------- |
|
||||||
|
| Тикер | `ticker` | Если известен маршрут инструмента, тикер является ссылкой. |
|
||||||
|
| Название | `name` | Если имени нет, показывается `-`. |
|
||||||
|
| Количество | `quantity` | Если количества нет, показывается `-`. |
|
||||||
|
| Цена | `currentPrice` | Деньги форматируются как `ru-RU` currency. |
|
||||||
|
| Стоимость | `currentValue` | Деньги форматируются как `ru-RU` currency. |
|
||||||
|
|
||||||
|
Колонка `Доходность` удаляется из таблиц позиций брокерского портфеля. Доходность остается доступной
|
||||||
|
в summary над таблицами, где она не смешивается с построчным составом портфеля.
|
||||||
|
|
||||||
|
### Ссылки на инструменты
|
||||||
|
|
||||||
|
Маршрутизация повторяет ручной портфель:
|
||||||
|
|
||||||
|
- акция: `/stocks/:ticker`;
|
||||||
|
- облигация: `/bonds/:ticker`.
|
||||||
|
|
||||||
|
Если `ticker` отсутствует или тип инструмента не поддержан, значение показывается текстом без ссылки.
|
||||||
|
Для неизвестного `instrumentType` допускается fallback по `classCode`, если он однозначно указывает
|
||||||
|
на акцию или облигацию.
|
||||||
|
|
||||||
|
## Операции
|
||||||
|
|
||||||
|
Операции показываются по 10 штук на странице. Страница использует существующий cursor API:
|
||||||
|
|
||||||
|
- первый запрос: `{ limit: 10 }`;
|
||||||
|
- переход вперед: `{ limit: 10, cursor: nextCursor }`;
|
||||||
|
- переход назад: восстановление предыдущего cursor из локального stack в UI.
|
||||||
|
|
||||||
|
UI показывает номер текущей страницы, кнопку `Назад` и кнопку `Вперед`. `Назад` выключена на первой
|
||||||
|
странице. `Вперед` выключена, когда `hasNext === false` или нет `nextCursor`.
|
||||||
|
|
||||||
|
Таблица операций сохраняет основные колонки:
|
||||||
|
|
||||||
|
| Колонка | Источник | Поведение |
|
||||||
|
| ---------- | --------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| Дата | `date` | `toLocaleString('ru-RU')`, при отсутствии `-`. |
|
||||||
|
| Тип | `type`, `description`, `category` | Русскоязычное название и бейдж эффекта. |
|
||||||
|
| Инструмент | `ticker`, `description` | Если известен маршрут инструмента, значение является ссылкой. |
|
||||||
|
| Сумма | `payment` | Форматируется как деньги и окрашивается по эффекту операции. |
|
||||||
|
|
||||||
|
### Русские названия типов
|
||||||
|
|
||||||
|
Для известных T-Bank enum-значений frontend показывает русские названия. Минимальный набор:
|
||||||
|
|
||||||
|
| T-Bank type | Название |
|
||||||
|
| ------------------------------------ | -------------------------- |
|
||||||
|
| `OPERATION_TYPE_BUY` | Покупка |
|
||||||
|
| `OPERATION_TYPE_BUY_CARD` | Покупка |
|
||||||
|
| `OPERATION_TYPE_SELL` | Продажа |
|
||||||
|
| `OPERATION_TYPE_SELL_CARD` | Продажа |
|
||||||
|
| `OPERATION_TYPE_COUPON` | Выплата купона |
|
||||||
|
| `OPERATION_TYPE_DIVIDEND` | Дивиденды |
|
||||||
|
| `OPERATION_TYPE_BOND_REPAYMENT` | Погашение облигации |
|
||||||
|
| `OPERATION_TYPE_BOND_REPAYMENT_FULL` | Полное погашение облигации |
|
||||||
|
| `OPERATION_TYPE_TAX` | Налог |
|
||||||
|
| `OPERATION_TYPE_BOND_TAX` | Налог по облигациям |
|
||||||
|
| `OPERATION_TYPE_DIVIDEND_TAX` | Налог на дивиденды |
|
||||||
|
| `OPERATION_TYPE_BROKER_FEE` | Комиссия брокера |
|
||||||
|
| `OPERATION_TYPE_SERVICE_FEE` | Комиссия за обслуживание |
|
||||||
|
| `OPERATION_TYPE_INPUT` | Пополнение |
|
||||||
|
| `OPERATION_TYPE_OUTPUT` | Вывод средств |
|
||||||
|
| `OPERATION_TYPE_INPUT_SECURITIES` | Зачисление бумаг |
|
||||||
|
| `OPERATION_TYPE_OUTPUT_SECURITIES` | Списание бумаг |
|
||||||
|
|
||||||
|
Если тип неизвестен, но есть `description`, показывается `description`. Если нет и описания,
|
||||||
|
показывается очищенный enum без префикса `OPERATION_TYPE_`.
|
||||||
|
|
||||||
|
### Эффект операции
|
||||||
|
|
||||||
|
Выбранный дизайн: бейдж эффекта в колонке `Тип` плюс цвет суммы.
|
||||||
|
|
||||||
|
Эффект определяется не только знаком суммы, потому что сделки не являются доходом сами по себе:
|
||||||
|
|
||||||
|
- `Пополнение` и доходы вроде купонов или дивидендов получают эффект `Пополняет`;
|
||||||
|
- налоги, комиссии и вывод средств получают эффект `Списывает`;
|
||||||
|
- покупки, продажи, ввод/вывод бумаг и погашение тела облигации получают эффект `Перекладка`;
|
||||||
|
- неизвестные операции получают эффект `Неясно`.
|
||||||
|
|
||||||
|
Цвета используются как вспомогательный признак, а текст бейджа остается основным признаком для
|
||||||
|
доступности:
|
||||||
|
|
||||||
|
- `Пополняет`: зеленый акцент;
|
||||||
|
- `Списывает`: красный акцент;
|
||||||
|
- `Перекладка`: нейтральный или синий акцент;
|
||||||
|
- `Неясно`: приглушенный серый акцент.
|
||||||
|
|
||||||
|
## Ошибки и пустые состояния
|
||||||
|
|
||||||
|
- Если портфель не загрузился, остается текущее сообщение об ошибке портфеля.
|
||||||
|
- Если операции загружаются, показывается `Загрузка операций...`.
|
||||||
|
- Если операций нет, показывается пустое состояние `Операций за выбранный период нет`.
|
||||||
|
- Если у позиции или операции нет тикера, вместо ссылки показывается текстовое значение или `-`.
|
||||||
|
|
||||||
|
## TDD-стратегия
|
||||||
|
|
||||||
|
Сначала пишутся failing tests:
|
||||||
|
|
||||||
|
1. Helper-тесты для группировки позиций, маршрутов инструментов, русских названий операций и эффекта операций.
|
||||||
|
2. Component/page-тесты для раздельных таблиц акций и облигаций, отсутствия колонки `Доходность` и наличия `Цена`.
|
||||||
|
3. Component/page-тесты для кликабельных инструментов в позициях и операциях.
|
||||||
|
4. Component/page-тесты для cursor-пагинации операций по 10 элементов.
|
||||||
|
|
||||||
|
Затем реализуются helper-функции, компоненты таблиц и интеграция в `BrokerAccountDetailPage`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Позиции акций и облигаций отображаются в отдельных таблицах.
|
||||||
|
- Позиции с другим типом отображаются в `Другие инструменты`, если такие позиции есть.
|
||||||
|
- В таблицах позиций нет колонки `Доходность`.
|
||||||
|
- В таблицах позиций есть колонка `Цена`.
|
||||||
|
- Тикеры позиций ведут на `/stocks/:ticker` или `/bonds/:ticker`.
|
||||||
|
- Операции запрашиваются с `limit: 10`.
|
||||||
|
- Пользователь может переходить вперед и назад по cursor-страницам операций.
|
||||||
|
- В операциях колонка `Инструмент` ведет на страницу акции или облигации, если известен тип инструмента.
|
||||||
|
- В операциях колонка `Тип` показывает русскоязычные названия.
|
||||||
|
- В операциях визуально понятно, пополняет операция портфель, списывает средства, является перекладкой или не классифицирована.
|
||||||
Loading…
x
Reference in New Issue
Block a user