Compare commits

..

No commits in common. "49ee36485667e5bb56509ccbda05496ad7ee0271" and "8b202ef7d3c7ac1b3554649d4b64a6c4373ea322" have entirely different histories.

22 changed files with 220 additions and 1052 deletions

View File

@ -9,7 +9,6 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria. - **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений. - **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. - **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
## Git workflow ## Git workflow

View File

@ -15,9 +15,4 @@ export class BrokerPositionQueryDto {
@Min(1) @Min(1)
@Max(100) @Max(100)
limit?: number = 10; limit?: number = 10;
@ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' })
@IsOptional()
@IsString()
type?: string;
} }

View File

@ -11,7 +11,7 @@ describe('portfolio.mapper', () => {
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
}; };
it('combines portfolio totals and cash (positions removed)', () => { it('combines portfolio totals, cash, and enriched positions', () => {
const result = mapBrokerPortfolio({ const result = mapBrokerPortfolio({
account, account,
portfolio: { portfolio: {
@ -44,5 +44,11 @@ describe('portfolio.mapper', () => {
expect(result.totals.shares?.value).toBe(1000); expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500); expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10); expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
}); });
}); });

View File

@ -32,7 +32,6 @@ describe('BrokerOperationSyncService', () => {
category: 'trade', category: 'trade',
description: null, description: null,
state: 'OPERATION_STATE_EXECUTED', state: 'OPERATION_STATE_EXECUTED',
name: null,
instrumentUid: 'uid-1', instrumentUid: 'uid-1',
figi: null, figi: null,
ticker: 'SBER', ticker: 'SBER',
@ -99,7 +98,6 @@ describe('BrokerOperationSyncService', () => {
category: 'trade', category: 'trade',
description: null, description: null,
state: null, state: null,
name: null,
instrumentUid: null, instrumentUid: null,
figi: null, figi: null,
ticker: null, ticker: null,

View File

@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => {
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
}); });
it('fetches portfolio through cache without positions', async () => { it('fetches portfolio and positions through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue({ vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1', id: 'acc-1',
type: 'brokerage', type: 'brokerage',
@ -60,7 +60,6 @@ describe('BrokerPortfolioService', () => {
expect(result.data.account.id).toBe('acc-1'); expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000); expect(result.data.cash[0].value).toBe(1000);
expect('positions' in result.data).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio', 'tbank:portfolio',
['acc-1'], ['acc-1'],
@ -69,237 +68,73 @@ describe('BrokerPortfolioService', () => {
); );
}); });
describe('getPositions', () => { it('returns portfolio when one instrument enrichment request fails', async () => {
function mockAccount() { vi.mocked(accounts.findById).mockResolvedValue({
vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1',
id: 'acc-1', type: 'brokerage',
type: 'brokerage', name: 'Broker',
name: 'Broker', status: 'ACCOUNT_STATUS_OPEN',
status: 'ACCOUNT_STATUS_OPEN', openedAt: null,
openedAt: null, accessLevel: null,
accessLevel: null,
});
}
function mockCache() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
}); });
vi.mocked(cache.getOrFetch).mockImplementation(
it('returns first page of positions', async () => { async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
mockAccount(); data: await fetchFn(),
mockCache(); fromCache: false,
vi.mocked(client.getServiceClient).mockReturnValue({ cachedAt: null,
getPortfolio: vi.fn(), }),
} as any); );
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1', accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [ positions: [
{ {
figi: 'figi-1', figi: 'figi-1',
instrumentUid: 'uid-1', instrumentUid: 'uid-1',
positionUid: 'pos-1', quantity: { units: '1', nano: 0 },
quantity: { units: '10', nano: 0 },
}, },
{ {
figi: 'figi-2', figi: 'figi-2',
instrumentUid: 'uid-2', instrumentUid: 'uid-2',
positionUid: 'pos-2', quantity: { units: '2', nano: 0 },
quantity: { units: '20', nano: 0 },
}, },
], ],
}); })
.mockResolvedValueOnce({
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 1);
expect(result.data.accountId).toBe('acc-1');
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].positionUid).toBe('pos-1');
expect(result.data.hasNext).toBe(true);
expect(result.data.nextCursor).toBe('pos-1');
});
it('paginates using cursor', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1', accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, money: [],
positions: [ blocked: [],
{ securities: [],
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
{
figi: 'f2',
instrumentUid: 'u2',
positionUid: 'p2',
quantity: { units: '20', nano: 0 },
},
{
figi: 'f3',
instrumentUid: 'u3',
positionUid: 'p3',
quantity: { units: '30', nano: 0 },
},
],
}); });
vi.mocked(instruments.findByInstrumentUid)
.mockResolvedValueOnce({
uid: 'uid-1',
figi: 'figi-1',
ticker: 'AAA',
classCode: 'TQBR',
name: 'First share',
instrumentType: 'share',
})
.mockRejectedValueOnce(new Error('instrument lookup failed'));
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', 'p1', 1); const result = await service.getPortfolio('acc-1');
expect(result.data.items).toHaveLength(1); expect(result.data.positions).toHaveLength(2);
expect(result.data.items[0].positionUid).toBe('p2'); expect(result.data.positions[0]).toMatchObject({
expect(result.data.nextCursor).toBe('p2'); instrumentUid: 'uid-1',
expect(result.data.hasNext).toBe(true); ticker: 'AAA',
name: 'First share',
}); });
expect(result.data.positions[1]).toMatchObject({
it('returns last page with hasNext=false', async () => { instrumentUid: 'uid-2',
mockAccount(); ticker: null,
mockCache(); name: null,
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 10);
expect(result.data.items).toHaveLength(1);
expect(result.data.hasNext).toBe(false);
expect(result.data.nextCursor).toBeNull();
});
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await service.getPositions('acc-1', 'some-cursor', 5);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:positions',
['acc-1', 'some-cursor', '5', ''],
expect.any(Function),
'tbankPositionsTtl',
);
});
it('filters by instrument type and caches with type in key', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
instrumentType: 'share',
ticker: 'SBER',
quantity: { units: '10', nano: 0 },
},
{
figi: 'f2',
instrumentUid: 'u2',
positionUid: 'p2',
instrumentType: 'bond',
ticker: 'SU26238RMFS5',
quantity: { units: '5', nano: 0 },
},
{
figi: 'f3',
instrumentUid: 'u3',
positionUid: 'p3',
instrumentType: 'share',
ticker: 'GAZP',
quantity: { units: '3', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 10, 'share');
expect(result.data.items).toHaveLength(2);
expect(result.data.items.map((i) => i.ticker)).toEqual(['SBER', 'GAZP']);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:positions',
['acc-1', '', '10', 'share'],
expect.any(Function),
'tbankPositionsTtl',
);
});
it('returns empty items when type filter matches nothing', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
instrumentType: 'share',
ticker: 'SBER',
quantity: { units: '10', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 10, 'etf');
expect(result.data.items).toHaveLength(0);
expect(result.data.hasNext).toBe(false);
expect(result.data.nextCursor).toBeNull();
}); });
}); });
}); });

View File

@ -66,7 +66,6 @@ export class BrokerPortfolioService {
accountId: string, accountId: string,
cursor?: string, cursor?: string,
limit = 10, limit = 10,
type?: string,
): Promise<{ ): Promise<{
data: BrokerPositionsPage; data: BrokerPositionsPage;
meta: { fromCache: boolean; cachedAt: string | null }; meta: { fromCache: boolean; cachedAt: string | null };
@ -76,7 +75,7 @@ export class BrokerPortfolioService {
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.positions, TBANK_CACHE_KEYS.positions,
[accountId, cursor ?? '', String(limit), type ?? ''], [accountId, cursor ?? '', String(limit)],
async () => { async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const portfolio = await this.tbankClient.callUnary< const portfolio = await this.tbankClient.callUnary<
@ -87,18 +86,11 @@ export class BrokerPortfolioService {
currency: 'RUB', currency: 'RUB',
}); });
const filteredPositions = type const instrumentMap = await this.buildInstrumentMap(portfolio);
? (portfolio.positions ?? []).filter(
(p) => p.instrumentType?.toLowerCase() === type.toLowerCase(),
)
: portfolio.positions;
const filteredPortfolio = { ...portfolio, positions: filteredPositions };
const instrumentMap = await this.buildInstrumentMap(filteredPortfolio);
return mapBrokerPositionsPage({ return mapBrokerPositionsPage({
accountId, accountId,
portfolio: filteredPortfolio, portfolio,
instruments: instrumentMap, instruments: instrumentMap,
cursor, cursor,
limit, limit,

View File

@ -21,7 +21,6 @@ describe('T-Bank configuration', () => {
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443'); expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(5); expect(config.tbank.rateLimitPerSecond).toBe(5);
expect(config.cache.tbankPortfolioTtl).toBe(60); expect(config.cache.tbankPortfolioTtl).toBe(60);
expect(config.cache.tbankPositionsTtl).toBe(60);
}); });
it('reads T-Bank token and TTL overrides from environment', () => { it('reads T-Bank token and TTL overrides from environment', () => {
@ -30,7 +29,6 @@ describe('T-Bank configuration', () => {
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem'; process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
const config = configuration(); const config = configuration();
@ -39,6 +37,5 @@ describe('T-Bank configuration', () => {
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem'); expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
expect(config.tbank.rateLimitPerSecond).toBe(2); expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120); expect(config.cache.tbankAccountsTtl).toBe(120);
expect(config.cache.tbankPositionsTtl).toBe(45);
}); });
}); });

View File

@ -56,7 +56,6 @@ export class TBankController {
accountId, accountId,
query.cursor, query.cursor,
query.limit, query.limit,
query.type,
); );
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }

View File

@ -21,18 +21,18 @@
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.0.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.0.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"msw": "^2.14.6", "msw": "^2.14.6",
"openapi-typescript": "^7.0.0", "openapi-typescript": "^7.0.0",

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations, getBrokerPositions } from './broker'; import { getBrokerOperations } from './broker';
describe('broker api', () => { describe('broker api', () => {
afterEach(() => { afterEach(() => {
@ -24,23 +24,4 @@ describe('broker api', () => {
expect.any(Object), expect.any(Object),
); );
}); });
it('serializes positions query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
expect.any(Object),
);
});
}); });

View File

@ -4,7 +4,6 @@ import type {
BrokerAccount, BrokerAccount,
BrokerOperationsPage, BrokerOperationsPage,
BrokerPortfolio, BrokerPortfolio,
BrokerPositionsPage,
} from './responses'; } from './responses';
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
@ -50,17 +49,3 @@ export function getBrokerOperations(
}, },
); );
} }
export function getBrokerPositions(
accountId: string,
query: { cursor?: string; limit?: number; type?: string } = {},
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
return request<BrokerPositionsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
{
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
type: query.type,
},
);
}

View File

@ -299,6 +299,7 @@ export interface BrokerPortfolio {
}; };
cash: BrokerMoney[]; cash: BrokerMoney[];
blockedCash: BrokerMoney[]; blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string; asOf: string;
} }
@ -313,7 +314,6 @@ export interface BrokerOperation {
type: string; type: string;
category: BrokerOperationCategory; category: BrokerOperationCategory;
description: string | null; description: string | null;
name: string | null;
state: string | null; state: string | null;
instrumentUid: string | null; instrumentUid: string | null;
figi: string | null; figi: string | null;
@ -336,11 +336,3 @@ export interface BrokerOperationsPage {
hasNext: boolean; hasNext: boolean;
asOf: string; asOf: string;
} }
export interface BrokerPositionsPage {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}

View File

@ -1,20 +0,0 @@
export function SkeletonBlock({
width,
height,
borderRadius = 4,
}: {
width?: string | number;
height?: string | number;
borderRadius?: number;
}) {
return (
<div
className="skeleton"
style={{
width: width ?? '100%',
height: height ?? 16,
borderRadius,
}}
/>
);
}

View File

@ -1,25 +0,0 @@
import { SkeletonBlock } from './SkeletonBlock';
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties;
type Column = { width: string };
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j} style={tdStyle}>
<SkeletonBlock height={12} width={col.width} />
</td>
))}
</tr>
))}
</tbody>
);
}

View File

@ -1,18 +0,0 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { getBrokerPositions } from '../api/broker';
import type { BrokerPositionsPage } from '../api/responses';
export function useBrokerPositions(
accountId: string | undefined,
query: { cursor?: string; limit?: number; type?: string } = {},
) {
return useQuery<BrokerPositionsPage>({
queryKey: ['broker', 'positions', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
staleTime: 60_000,
retry: 2,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}

View File

@ -5,10 +5,10 @@ import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
import { BrokerOperationsTable } from './BrokerOperationsTable'; import { BrokerOperationsTable } from './BrokerOperationsTable';
import { BrokerPositionsSection } from './BrokerPositionsSection'; import { BrokerPositionsSection } from './BrokerPositionsSection';
import { SkeletonBlock } from '../../components/SkeletonBlock';
function formatMoney(value: BrokerMoney | null | undefined) { function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-'; if (!value) return '-';
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
currency: value.currency || 'RUB', currency: value.currency || 'RUB',
@ -23,40 +23,7 @@ export function BrokerAccountDetailPage() {
const portfolio = useBrokerPortfolio(accountId); const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
if (portfolio.isLoading) { if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
return (
<div style={{ display: 'grid', gap: 24 }}>
<div style={{ display: 'grid', gap: 12 }}>
<SkeletonBlock height={32} width="60%" />
<SkeletonBlock height={24} width="40%" />
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 16,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
}}
>
<SkeletonBlock height={14} width="40%" />
<div style={{ height: 8 }} />
<SkeletonBlock height={20} width="60%" />
</div>
))}
</div>
</div>
);
}
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>;
} }
@ -64,12 +31,14 @@ export function BrokerAccountDetailPage() {
function handleNextOperationsPage() { function handleNextOperationsPage() {
const nextCursor = operations.data?.nextCursor; const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return; if (!nextCursor || !operations.data?.hasNext) return;
setOperationCursorStack((previous) => [...previous, operationCursor]); setOperationCursorStack((previous) => [...previous, operationCursor]);
setOperationCursor(nextCursor); setOperationCursor(nextCursor);
} }
function handlePreviousOperationsPage() { function handlePreviousOperationsPage() {
if (operationCursorStack.length === 0) return; if (operationCursorStack.length === 0) return;
const nextStack = operationCursorStack.slice(0, -1); const nextStack = operationCursorStack.slice(0, -1);
const previousCursor = operationCursorStack[operationCursorStack.length - 1]; const previousCursor = operationCursorStack[operationCursorStack.length - 1];
setOperationCursorStack(nextStack); setOperationCursorStack(nextStack);
@ -118,7 +87,7 @@ export function BrokerAccountDetailPage() {
))} ))}
</section> </section>
<BrokerPositionsSection accountId={accountId!} /> <BrokerPositionsSection positions={portfolio.data.positions} />
<BrokerOperationsTable <BrokerOperationsTable
isLoading={operations.isLoading} isLoading={operations.isLoading}

View File

@ -1,6 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
import { SkeletonBlock } from '../../components/SkeletonBlock';
const cardStyle = { const cardStyle = {
display: 'block', display: 'block',
@ -16,43 +15,7 @@ const cardStyle = {
export function BrokerAccountsPage() { export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts(); const { data: accounts, isLoading, error } = useBrokerAccounts();
if (isLoading) { if (isLoading) return <p>Загрузка брокерских счетов...</p>;
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
boxShadow: 'var(--shadow)',
}}
>
<SkeletonBlock height={20} width="60%" />
<div style={{ height: 10 }} />
<SkeletonBlock height={12} width="40%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="30%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="50%" />
</div>
))}
</div>
</div>
);
}
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>; if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
return ( return (

View File

@ -6,7 +6,6 @@ import {
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
type BrokerOperationImpact, type BrokerOperationImpact,
} from './brokerDisplay'; } from './brokerDisplay';
import { TableSkeleton } from '../../components/TableSkeleton';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',
@ -51,28 +50,18 @@ function moneyColor(impact: BrokerOperationImpact): string {
} }
function OperationInstrument({ operation }: { operation: BrokerOperation }) { function OperationInstrument({ operation }: { operation: BrokerOperation }) {
const ticker = operation.ticker || operation.description || '-'; const label = operation.ticker || operation.description || '-';
const path = getBrokerInstrumentPath({ const path = getBrokerInstrumentPath({
ticker: operation.ticker, ticker: operation.ticker,
instrumentType: operation.instrumentType, instrumentType: operation.instrumentType,
classCode: operation.classCode, classCode: operation.classCode,
}); });
const name = operation.name || operation.description;
if (!path && !name) return <span>-</span>; if (!path || label === '-') {
if (!path) return <span>{name}</span>; return <span>{label}</span>;
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>; }
return ( return <Link to={path}>{label}</Link>;
<div style={{ display: 'grid', gap: 2 }}>
<Link to={path} style={{ fontWeight: 700 }}>
{ticker}
</Link>
{name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
)}
</div>
);
} }
const pagButtonStyle = { const pagButtonStyle = {
@ -156,30 +145,7 @@ export function BrokerOperationsTable({
</div> </div>
{isLoading ? ( {isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <p>Загрузка операций...</p>
<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>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 ? ( ) : operations.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p> <p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : ( ) : (

View File

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, within } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; 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';
@ -7,8 +7,6 @@ import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts'; import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations'; import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as positionsHook from '../../hooks/useBrokerPositions';
import type { BrokerPosition } from '../../api/responses';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerAccountsPage } from './BrokerAccountsPage';
@ -22,45 +20,6 @@ function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
); );
} }
function createPosition(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,
};
}
/** Spy on useBrokerPositions and return only positions matching query.type . */
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
const type = query.type?.toLowerCase();
const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
return {
data: {
accountId: 'acc-1',
items: filtered,
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
error: null,
} as any;
});
}
describe('Broker pages', () => { describe('Broker pages', () => {
it('renders broker and IIS accounts', () => { it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
@ -107,6 +66,24 @@ describe('Broker pages', () => {
yields: { expectedPercent: 5, daily: null, dailyPercent: null }, yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [], 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', asOf: '2026-06-16T00:00:00.000Z',
}, },
isLoading: false, isLoading: false,
@ -147,17 +124,6 @@ describe('Broker pages', () => {
isLoading: false, isLoading: false,
error: null, error: null,
} as any); } as any);
mockUseBrokerPositions(
createPosition({
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
}),
);
renderWithClient( renderWithClient(
<Routes> <Routes>
@ -185,6 +151,40 @@ describe('Broker pages', () => {
yields: { expectedPercent: 5, daily: null, dailyPercent: null }, yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [], cash: [],
blockedCash: [], 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', asOf: '2026-06-17T00:00:00.000Z',
}, },
isLoading: false, isLoading: false,
@ -201,30 +201,6 @@ describe('Broker pages', () => {
isLoading: false, isLoading: false,
error: null, error: null,
} as any); } as any);
mockUseBrokerPositions(
createPosition({
instrumentUid: 'share-uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
expectedYieldPercent: 20,
}),
createPosition({
instrumentUid: 'bond-uid',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: 2,
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
expectedYieldPercent: 10,
}),
);
renderWithClient( renderWithClient(
<Routes> <Routes>
@ -261,6 +237,7 @@ describe('Broker pages', () => {
yields: { expectedPercent: null, daily: null, dailyPercent: null }, yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [], cash: [],
blockedCash: [], blockedCash: [],
positions: [],
asOf: '2026-06-17T00:00:00.000Z', asOf: '2026-06-17T00:00:00.000Z',
}, },
isLoading: false, isLoading: false,
@ -324,7 +301,6 @@ describe('Broker pages', () => {
isLoading: false, isLoading: false,
error: null, error: null,
} as any); } as any);
mockUseBrokerPositions();
renderWithClient( renderWithClient(
<Routes> <Routes>
@ -358,6 +334,7 @@ describe('Broker pages', () => {
yields: { expectedPercent: null, daily: null, dailyPercent: null }, yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [], cash: [],
blockedCash: [], blockedCash: [],
positions: [],
asOf: '2026-06-17T00:00:00.000Z', asOf: '2026-06-17T00:00:00.000Z',
}, },
isLoading: false, isLoading: false,
@ -434,7 +411,6 @@ describe('Broker pages', () => {
error: null, error: null,
}) as any, }) as any,
); );
mockUseBrokerPositions();
renderWithClient( renderWithClient(
<Routes> <Routes>
@ -445,10 +421,9 @@ describe('Broker pages', () => {
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; // Find pagination buttons by their text content (← and →)
const withinOperations = within(operationsSection); const nextButton = screen.getByRole('button', { name: '→' });
const nextButton = withinOperations.getByRole('button', { name: '→' }); const prevButton = screen.getByRole('button', { name: '←' });
const prevButton = withinOperations.getByRole('button', { name: '←' });
expect(prevButton).toBeDisabled(); expect(prevButton).toBeDisabled();
expect(nextButton).not.toBeDisabled(); expect(nextButton).not.toBeDisabled();
@ -459,11 +434,12 @@ describe('Broker pages', () => {
cursor: 'cursor-page-2', cursor: 'cursor-page-2',
}); });
expect(withinOperations.getByText('2')).toBeInTheDocument(); // Page number is shown as just a number (without "Страница" label)
expect(screen.getByText('2')).toBeInTheDocument();
await user.click(prevButton); await user.click(screen.getByRole('button', { name: '←' }));
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
expect(withinOperations.getByText('1')).toBeInTheDocument(); expect(screen.getByText('1')).toBeInTheDocument();
}); });
}); });

View File

@ -1,25 +1,18 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPosition } from '../../api/responses'; import type { BrokerMoney, BrokerPosition } from '../../api/responses';
import { getBrokerInstrumentPath } from './brokerDisplay'; import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
import { TableSkeleton } from '../../components/TableSkeleton';
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
type BrokerPositionGroupConfig = { type BrokerPositionGroupConfig = {
key: string; key: 'shares' | 'bonds' | 'other';
type?: string;
title: string; title: string;
}; };
const GROUPS: BrokerPositionGroupConfig[] = [ const GROUPS: BrokerPositionGroupConfig[] = [
{ key: 'shares', type: 'share', title: 'Акции' }, { key: 'shares', title: 'Акции' },
{ key: 'bonds', type: 'bond', title: 'Облигации' }, { key: 'bonds', title: 'Облигации' },
{ key: 'etf', type: 'etf', title: 'ETF' }, { key: 'other', title: 'Другие инструменты' },
{ key: 'fund', type: 'fund', title: 'Фонды' },
]; ];
const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
@ -39,26 +32,9 @@ const tdStyle = {
verticalAlign: 'top', verticalAlign: 'top',
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
const pagButtonStyle = {
padding: '6px 14px',
borderRadius: 6,
border: '1px solid #e0e0e0',
background: 'var(--color-surface)',
color: 'var(--color-text)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
lineHeight: 1.4,
} satisfies React.CSSProperties;
const pagButtonDisabledStyle = {
...pagButtonStyle,
opacity: 0.35,
cursor: 'not-allowed',
} satisfies React.CSSProperties;
function formatMoney(value: BrokerMoney | null | undefined) { function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-'; if (!value) return '-';
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
currency: value.currency || 'RUB', currency: value.currency || 'RUB',
@ -89,199 +65,87 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
); );
} }
function PositionGroupTable({ function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
accountId,
group,
}: {
accountId: string;
group: BrokerPositionGroupConfig;
}) {
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
const { data: page, isLoading } = useBrokerPositions(accountId, query);
const rawPositions = page?.items ?? [];
const positions = group.type
? rawPositions
: rawPositions.filter(
(p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
);
const pageNumber = cursorStack.length + 1;
const canGoBack = cursorStack.length > 0;
const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
function handleNext() {
const nextCursor = page?.nextCursor;
if (!nextCursor || !page?.hasNext || !group.type) return;
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
const prev = cursorStack[cursorStack.length - 1];
setCursorStack((prevStack) => prevStack.slice(0, -1));
setCursor(prev);
}
if (!isLoading && positions.length === 0) {
return null;
}
return ( return (
<section> <section>
<div <h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
style={{ <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
display: 'flex', <table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
alignItems: 'center', <thead>
gap: 12, <tr>
justifyContent: 'space-between', <th align="left" style={thStyle}>
marginBottom: 10, Тикер
}} </th>
> <th align="left" style={thStyle}>
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3> Название
{group.type && ( </th>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <th align="right" style={thStyle}>
<button Количество
type="button" </th>
onClick={handlePrevious} <th align="right" style={thStyle}>
disabled={!canGoBack} Цена
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle} </th>
> <th align="right" style={thStyle}>
Стоимость
</button> </th>
<span </tr>
style={{ </thead>
minWidth: 20, <tbody>
textAlign: 'center', {positions.map((position) => (
color: 'var(--color-text-secondary)', <tr
fontSize: 14, key={
fontWeight: 600, position.positionUid || position.instrumentUid || position.ticker || position.figi
}} }
> >
{pageNumber} <td style={tdStyle}>
</span> <PositionTicker position={position} />
<button </td>
type="button" <td style={tdStyle}>
onClick={handleNext} <span style={{ color: 'var(--color-text-secondary)' }}>
disabled={!canGoForward} {position.name || '-'}
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle} </span>
> </td>
<td align="right" style={tdStyle}>
</button> {formatQuantity(position.quantity)}
</div> </td>
)} <td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
{isLoading && (
<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="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
</thead>
<TableSkeleton
rows={4}
columns={[
{ width: '30%' },
{ width: '50%' },
{ width: '20%' },
{ width: '25%' },
{ width: '25%' },
]}
/>
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.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> </section>
); );
} }
type BrokerPositionsSectionProps = { export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) {
accountId: string; 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>
);
}
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
return ( return (
<div style={{ display: 'grid', gap: 20 }}> <section>
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2> <h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
{GROUPS.map((group) => ( <div style={{ display: 'grid', gap: 20 }}>
<PositionGroupTable key={group.key} accountId={accountId} group={group} /> {grouped.map((group) => (
))} <PositionTable key={group.key} title={group.title} positions={group.positions} />
</div> ))}
</div>
</section>
); );
} }

View File

@ -33,20 +33,3 @@ a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
#f0f0f0 50%,
var(--color-bg) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}

View File

@ -1,269 +0,0 @@
# Пагинация позиций, скелетоны, название инструмента в операциях
Дата: 2026-06-17
Статус: черновик
## Контекст
Страница брокерского счёта показывает таблицы позиций (Акции, Облигации, Другие инструменты) и
операций. Сейчас позиции приходят единым списком внутри `GET /portfolio`, что неэффективно при
большом количестве позиций. Также отсутствуют loading-индикаторы (просто текст "Загрузка...").
## Цель
1. Выделить позиции в отдельный paginated endpoint (10 на страницу)
2. Заменить текстовые loading-индикаторы на shimmer-скелетоны
3. Добавить название инструмента в колонку "Инструмент" таблицы операций
4. Добавить визуальный loading-индикатор при переключении страниц таблиц
## Изменения
### 1. Backend: отдельный endpoint для позиций
**Новый endpoint:** `GET /api/v1/broker/accounts/:accountId/positions`
Query params:
- `cursor` — positionUid последней позиции на тек. странице (string, опционально)
- `limit` — размер страницы (number, default 10)
Response:
```ts
interface BrokerPositionsPage {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}
```
**Логика:**
- `broker-portfolio.service.ts` уже делает gRPC вызов `GetPortfolio`, который возвращает все позиции
- Новый метод `getPositions(accountId, cursor?, limit?)` делает тот же gRPC вызов, кэширует полный список,
затем возвращает paginated slice
- Cursor: позиция с `positionUid === cursor` — начало следующей страницы
- Кэширование: `CACHE_POSITIONS_TTL` (60s) — отдельно от портфеля, т.к. цены меняются быстро
- Если `cursor` не указан — возвращается первая страница
**Изменение `BrokerPortfolio`:** убрать `positions` из типа/DTO портфеля.
Фронтенд теперь грузит позиции отдельным запросом.
**Новый файл:** `dto/broker-positions-page-response.dto.ts`
**Изменяемые backend-файлы:**
| Файл | Изменение |
|---|---|
| `types/broker.types.ts` | Добавить `BrokerPositionsPage` тип. Убрать `positions` из `BrokerPortfolio` |
| `dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto` |
| `dto/broker-position-response.dto.ts` | Создать (перенести `BrokerPositionResponseDto` сюда из portfolio) |
| `dto/broker-positions-page-response.dto.ts` | Создать |
| `services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из `getPortfolio()` |
| `mappers/portfolio.mapper.ts` | Разделить маппинг: `mapBrokerPortfolio()` без positions, `mapBrokerPosition()` отдельно |
| `tbank.controller.ts` | Добавить `GET /accounts/:accountId/positions` |
| `tbank.config.ts` | Добавить `CACHE_POSITIONS_TTL` (60s) |
| `operation.mapper.ts` | Добавить `name: item.name ?? null` в `mapOperation()` |
| `types/broker.types.ts` | Добавить `name` в `BrokerOperation` |
| `dto/broker-operation-response.dto.ts` | Добавить `name` |
### 2. Frontend: новый хук и типы для позиций
**Новый хук:** `apps/frontend/src/hooks/useBrokerPositions.ts`
```ts
export function useBrokerPositions(accountId, query = {}) {
return useQuery<BrokerPositionsPage>({
queryKey: ['broker', 'positions', accountId, query],
enabled: Boolean(accountId),
queryFn: () => getBrokerPositions(accountId!, query),
placeholderData: keepPreviousData,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}
```
**Новый API-вызов:** `apps/frontend/src/api/broker.ts`
```ts
export function getBrokerPositions(accountId, query) { ... }
```
**Новые типы в `responses.ts`:**
- `BrokerPositionsPage` — интерфейс с items, nextCursor, hasNext
- `name: string | null` в `BrokerOperation`
- Убрать `positions` из `BrokerPortfolio`
### 3. BrokerPositionsSection с пагинацией
Компонент теперь принимает пропсы для пагинации (как BrokerOperationsTable):
```tsx
interface Props {
page: BrokerPositionsPage | undefined;
isLoading: boolean;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}
```
**Логика:**
- `BrokerPositionsSection` рендерит те же группы (Акции / Облигации / Другие инструменты),
но только для позиций с текущей страницы
- Снизу — кнопки пагинации ← N →
- При `isLoading=true` — показывать 5 shimmer-строк (вместо реальных данных)
- При `isLoading=true` и отсутствии данных (первая загрузка) — показывать
PositionTable skeleton (shimmer-строки для заглушки)
### 4. Shimmer-скелетоны (CSS + компоненты)
**CSS в `styles.css`:**
```css
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#eee 25%,
#f5f5f5 50%,
#eee 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
```
**Компонент `SkeletonBlock`:**
```tsx
function SkeletonBlock({ width, height, borderRadius = 4 }: {
width?: string | number;
height?: string | number;
borderRadius?: number;
}) {
return <div className="skeleton" style={{ width, height, borderRadius }} />;
}
```
**BrokerAccountsPage:**
- Вместо `<p>Загрузка...</p>` — 3 карточки-скелетона в grid
```tsx
{isLoading && (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
{[1,2,3].map(i => (
<div key={i} style={{ padding: 20, background: 'var(--color-surface)', borderRadius: 8 }}>
<SkeletonBlock height={20} width="60%" />
<div style={{ height: 10 }} />
<SkeletonBlock height={12} width="40%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="30%" />
</div>
))}
</div>
)}
```
**BrokerAccountDetailPage:**
- Вместо `<p>Загрузка портфеля...</p>` — shimmer-блоки под header + cash + positions
- Позиции грузятся отдельно через `useBrokerPositions` — свой skeleton
### 5. Название инструмента в операциях
**Изменение `OperationInstrument`:**
```tsx
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
const ticker = operation.ticker;
const path = getBrokerInstrumentPath({ ticker, instrumentType: operation.instrumentType, classCode: operation.classCode });
const name = operation.name || operation.description;
if (!path && !name) return <span>-</span>;
if (!path) return <span>{name}</span>;
return (
<div style={{ display: 'grid', gap: 2 }}>
<Link to={path} style={{ fontWeight: 700 }}>{ticker}</Link>
{name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
)}
</div>
);
}
```
### 6. Loading-индикатор при переключении страниц (shimmer-строки)
**BrokerOperationsTable:**
- При `isLoading=true` и наличии `page` (уже были данные, но грузится новая страница):
показываем 5 shimmer-строк вместо table body
- При `isLoading=true` и отсутствии `page` (первая загрузка):
показываем header таблицы + 5 shimmer-строк
- Используем `keepPreviousData` в TanStack Query, но визуально не показываем старые данные —
показываем shimmer-строки
**BrokerPositionsSection:**
- Аналогичное поведение при переключении страниц позиций
**Компонент `TableSkeleton`:**
```tsx
function TableSkeleton({ rows = 5 }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
<td style={tdStyle}><SkeletonBlock height={12} width="70%" /></td>
<td style={tdStyle}><SkeletonBlock height={12} width="50%" /></td>
<td style={tdStyle}><SkeletonBlock height={12} width="30%" /></td>
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
</tr>
))}
</tbody>
);
}
```
Количество колонок и их ширина зависит от таблицы (operations vs positions).
## Файлы для изменения
### Backend
| Файл | Изменение |
|---|---|
| `apps/backend/src/modules/tbank/types/broker.types.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`. Добавить `name` в `BrokerOperation` |
| `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto`. Вынести `BrokerPositionResponseDto` |
| `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` | Создать (из `BrokerPositionResponseDto`) |
| `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` | Создать |
| `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` | Добавить `name` |
| `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` | Разделить маппинг portfolio/positions |
| `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` | Добавить `name` в mapOperation |
| `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из portfolio |
| `apps/backend/src/modules/tbank/tbank.controller.ts` | Добавить GET /positions endpoint |
| `apps/backend/src/modules/tbank/tbank.config.ts` | Добавить CACHE_POSITIONS_TTL |
### Frontend
| Файл | Изменение |
|---|---|
| `apps/frontend/src/styles.css` | Добавить `@keyframes shimmer` и `.skeleton` |
| `apps/frontend/src/api/responses.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`, `name` в `BrokerOperation` |
| `apps/frontend/src/api/broker.ts` | Добавить `getBrokerPositions()` |
| `apps/frontend/src/hooks/useBrokerPositions.ts` | Создать |
| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Пагинация + shimmer-строки |
| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Shimmer-строки при loading, обновить OperationInstrument |
| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Скелетоны, хук позиций |
| `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` | Скелетоны |
| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты |
## Тестирование
- Backend: обновить `broker-portfolio.service.spec.ts` — убрать positions из portfolio, покрыть getPositions
- Backend: обновить `portfolio.mapper.spec.ts`
- Frontend: `npm run test:frontend` — все тесты должны проходить
- Проверить, что скелетоны отображаются при загрузке
- Проверить, что пагинация позиций работает
- Проверить, что shimmer-строки показываются при переключении страниц
- Проверить, что название инструмента отображается в операциях