From 49ee36485667e5bb56509ccbda05496ad7ee0271 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 18 Jun 2026 06:02:54 +0300 Subject: [PATCH] feat(broker): per-type positions pagination with independent tables - Add type query param to GET /accounts/:accountId/positions endpoint - Backend filters T-Bank portfolio positions by instrument type before pagination - Each instrument type (share, bond, etf, fund) has its own frontend table with independent cursor-based pagination and skeleton loading - Groups with no positions are automatically hidden - Cache key includes type for correct per-type caching - Remove centralized positions pagination state from BrokerAccountDetailPage - 94 backend tests / 112 frontend tests pass --- AGENTS.md | 1 + .../tbank/dto/broker-position-query.dto.ts | 5 + .../services/broker-portfolio.service.spec.ts | 158 ++++++--- .../services/broker-portfolio.service.ts | 14 +- .../src/modules/tbank/tbank.controller.ts | 1 + apps/frontend/package.json | 10 +- apps/frontend/src/api/broker.ts | 3 +- apps/frontend/src/hooks/useBrokerPositions.ts | 2 +- .../pages/broker/BrokerAccountDetailPage.tsx | 110 +----- .../src/pages/broker/BrokerPages.test.tsx | 173 +++++----- .../pages/broker/BrokerPositionsSection.tsx | 320 +++++++++--------- ...broker-positions-pagination-and-loading.md | 269 +++++++++++++++ 12 files changed, 629 insertions(+), 437 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md diff --git a/AGENTS.md b/AGENTS.md index 89ad694..592192d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr - **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 перед завершением крупных изменений. - **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. +- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере. ## Git workflow diff --git a/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts index 57e4d59..c73fe51 100644 --- a/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts @@ -15,4 +15,9 @@ export class BrokerPositionQueryDto { @Min(1) @Max(100) limit?: number = 10; + + @ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' }) + @IsOptional() + @IsString() + type?: string; } diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index d679856..458a7cd 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -70,14 +70,7 @@ describe('BrokerPortfolioService', () => { }); describe('getPositions', () => { - 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); - }); - - it('returns first page of positions', async () => { + function mockAccount() { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', @@ -86,6 +79,9 @@ describe('BrokerPortfolioService', () => { openedAt: null, accessLevel: null, }); + } + + function mockCache() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), @@ -93,6 +89,18 @@ describe('BrokerPortfolioService', () => { 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); + }); + + it('returns first page of positions', async () => { + mockAccount(); + mockCache(); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); @@ -126,21 +134,8 @@ describe('BrokerPortfolioService', () => { }); it('paginates using cursor', async () => { - vi.mocked(accounts.findById).mockResolvedValue({ - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }); - vi.mocked(cache.getOrFetch).mockImplementation( - async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ - data: await fetchFn(), - fromCache: false, - cachedAt: null, - }), - ); + mockAccount(); + mockCache(); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); @@ -179,21 +174,8 @@ describe('BrokerPortfolioService', () => { }); it('returns last page with hasNext=false', async () => { - vi.mocked(accounts.findById).mockResolvedValue({ - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }); - vi.mocked(cache.getOrFetch).mockImplementation( - async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ - data: await fetchFn(), - fromCache: false, - cachedAt: null, - }), - ); + mockAccount(); + mockCache(); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); @@ -218,22 +200,9 @@ describe('BrokerPortfolioService', () => { expect(result.data.nextCursor).toBeNull(); }); - it('caches positions with cursor/limit in key and tbankPositionsTtl', async () => { - vi.mocked(accounts.findById).mockResolvedValue({ - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }); - vi.mocked(cache.getOrFetch).mockImplementation( - async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ - data: await fetchFn(), - fromCache: false, - cachedAt: null, - }), - ); + it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => { + mockAccount(); + mockCache(); vi.mocked(client.getServiceClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); @@ -248,10 +217,89 @@ describe('BrokerPortfolioService', () => { expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:positions', - ['acc-1', 'some-cursor', '5'], + ['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(); + }); }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index 6c23bd3..0553119 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -66,6 +66,7 @@ export class BrokerPortfolioService { accountId: string, cursor?: string, limit = 10, + type?: string, ): Promise<{ data: BrokerPositionsPage; meta: { fromCache: boolean; cachedAt: string | null }; @@ -75,7 +76,7 @@ export class BrokerPortfolioService { const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.positions, - [accountId, cursor ?? '', String(limit)], + [accountId, cursor ?? '', String(limit), type ?? ''], async () => { const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const portfolio = await this.tbankClient.callUnary< @@ -86,11 +87,18 @@ export class BrokerPortfolioService { currency: 'RUB', }); - const instrumentMap = await this.buildInstrumentMap(portfolio); + const filteredPositions = type + ? (portfolio.positions ?? []).filter( + (p) => p.instrumentType?.toLowerCase() === type.toLowerCase(), + ) + : portfolio.positions; + + const filteredPortfolio = { ...portfolio, positions: filteredPositions }; + const instrumentMap = await this.buildInstrumentMap(filteredPortfolio); return mapBrokerPositionsPage({ accountId, - portfolio, + portfolio: filteredPortfolio, instruments: instrumentMap, cursor, limit, diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index dbe09e2..24a6042 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -56,6 +56,7 @@ export class TBankController { accountId, query.cursor, query.limit, + query.type, ); return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index cb93512..4165e45 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -21,18 +21,18 @@ "react-router-dom": "^6.20.0" }, "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/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.3", "@types/react": "^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", + "eslint": "^8.0.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.1.1", "msw": "^2.14.6", "openapi-typescript": "^7.0.0", diff --git a/apps/frontend/src/api/broker.ts b/apps/frontend/src/api/broker.ts index 419c633..d9c0816 100644 --- a/apps/frontend/src/api/broker.ts +++ b/apps/frontend/src/api/broker.ts @@ -53,13 +53,14 @@ export function getBrokerOperations( export function getBrokerPositions( accountId: string, - query: { cursor?: string; limit?: number } = {}, + query: { cursor?: string; limit?: number; type?: string } = {}, ): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> { return request( `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`, { cursor: query.cursor, limit: query.limit ? String(query.limit) : undefined, + type: query.type, }, ); } diff --git a/apps/frontend/src/hooks/useBrokerPositions.ts b/apps/frontend/src/hooks/useBrokerPositions.ts index fa56ad4..79903ba 100644 --- a/apps/frontend/src/hooks/useBrokerPositions.ts +++ b/apps/frontend/src/hooks/useBrokerPositions.ts @@ -4,7 +4,7 @@ import type { BrokerPositionsPage } from '../api/responses'; export function useBrokerPositions( accountId: string | undefined, - query: { cursor?: string; limit?: number } = {}, + query: { cursor?: string; limit?: number; type?: string } = {}, ) { return useQuery({ queryKey: ['broker', 'positions', accountId, query], diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index f98b871..d43ff7e 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -3,7 +3,6 @@ import { useParams } from 'react-router-dom'; import type { BrokerMoney } from '../../api/responses'; import { useBrokerOperations } from '../../hooks/useBrokerOperations'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; -import { useBrokerPositions } from '../../hooks/useBrokerPositions'; import { BrokerOperationsTable } from './BrokerOperationsTable'; import { BrokerPositionsSection } from './BrokerPositionsSection'; import { SkeletonBlock } from '../../components/SkeletonBlock'; @@ -21,11 +20,8 @@ export function BrokerAccountDetailPage() { const { accountId } = useParams(); const [operationCursor, setOperationCursor] = useState(undefined); const [operationCursorStack, setOperationCursorStack] = useState>([]); - const [positionCursor, setPositionCursor] = useState(undefined); - const [positionCursorStack, setPositionCursorStack] = useState>([]); const portfolio = useBrokerPortfolio(accountId); const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); - const positions = useBrokerPositions(accountId, { limit: 10, cursor: positionCursor }); if (portfolio.isLoading) { return ( @@ -57,87 +53,6 @@ export function BrokerAccountDetailPage() { ))} -
- - - - - - - - - - - - {Array.from({ length: 4 }).map((_, i) => ( - - {Array.from({ length: 5 }).map((_, j) => ( - - ))} - - ))} - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- -
-
); } @@ -161,21 +76,6 @@ export function BrokerAccountDetailPage() { setOperationCursor(previousCursor); } - function handleNextPositionsPage() { - const nextCursor = positions.data?.nextCursor; - if (!nextCursor || !positions.data?.hasNext) return; - setPositionCursorStack((previous) => [...previous, positionCursor]); - setPositionCursor(nextCursor); - } - - function handlePreviousPositionsPage() { - if (positionCursorStack.length === 0) return; - const nextStack = positionCursorStack.slice(0, -1); - const previousCursor = positionCursorStack[positionCursorStack.length - 1]; - setPositionCursorStack(nextStack); - setPositionCursor(previousCursor); - } - return (
@@ -218,15 +118,7 @@ export function BrokerAccountDetailPage() { ))} - 0} - canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)} - onPrevious={handlePreviousPositionsPage} - onNext={handleNextPositionsPage} - /> + ): 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', () => { it('renders broker and IIS accounts', () => { vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ @@ -107,34 +147,17 @@ describe('Broker pages', () => { isLoading: false, error: null, } as any); - vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [ - { - 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, - }, - ], - nextCursor: null, - hasNext: false, - asOf: '2026-06-16T00:00:00.000Z', - }, - isLoading: false, - error: null, - } 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( @@ -178,50 +201,30 @@ describe('Broker pages', () => { isLoading: false, error: null, } as any); - vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [ - { - 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, - }, - ], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - error: null, - } 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( @@ -321,17 +324,7 @@ describe('Broker pages', () => { isLoading: false, error: null, } as any); - vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - error: null, - } as any); + mockUseBrokerPositions(); renderWithClient( @@ -441,17 +434,7 @@ describe('Broker pages', () => { error: null, }) as any, ); - vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - error: null, - } as any); + mockUseBrokerPositions(); renderWithClient( @@ -462,7 +445,6 @@ describe('Broker pages', () => { expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - // Scope pagination queries to the operations section (positions section also has pagination now) const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; const withinOperations = within(operationsSection); const nextButton = withinOperations.getByRole('button', { name: '→' }); @@ -477,7 +459,6 @@ describe('Broker pages', () => { cursor: 'cursor-page-2', }); - // Page number is shown as just a number (without "Страница" label) expect(withinOperations.getByText('2')).toBeInTheDocument(); await user.click(prevButton); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx index 095b364..7dd2189 100644 --- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx @@ -1,19 +1,25 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerPosition } from '../../api/responses'; -import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; +import { getBrokerInstrumentPath } from './brokerDisplay'; import { TableSkeleton } from '../../components/TableSkeleton'; +import { useBrokerPositions } from '../../hooks/useBrokerPositions'; type BrokerPositionGroupConfig = { - key: 'shares' | 'bonds' | 'other'; + key: string; + type?: string; title: string; }; const GROUPS: BrokerPositionGroupConfig[] = [ - { key: 'shares', title: 'Акции' }, - { key: 'bonds', title: 'Облигации' }, - { key: 'other', title: 'Другие инструменты' }, + { key: 'shares', type: 'share', title: 'Акции' }, + { key: 'bonds', type: 'bond', title: 'Облигации' }, + { key: 'etf', type: 'etf', title: 'ETF' }, + { key: 'fund', type: 'fund', title: 'Фонды' }, ]; +const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean)); + const tableStyle = { width: '100%', borderCollapse: 'collapse', @@ -83,89 +89,47 @@ function PositionTicker({ position }: { position: BrokerPosition }) { ); } -function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { - return ( -
-

{title}

-
- - - - - - - - - - - - {positions.map((position) => ( - - - - - - - - ))} - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- - - - {position.name || '-'} - - - {formatQuantity(position.quantity)} - - {formatMoney(position.currentPrice)} - - {formatMoney(position.currentValue)} -
-
-
- ); -} +function PositionGroupTable({ + accountId, + group, +}: { + accountId: string; + group: BrokerPositionGroupConfig; +}) { + const [cursorStack, setCursorStack] = useState>([]); + const [cursor, setCursor] = useState(undefined); -type BrokerPositionsSectionProps = { - page: { items: BrokerPosition[] } | undefined; - isLoading: boolean; - pageNumber: number; - canGoBack: boolean; - canGoForward: boolean; - onPrevious: () => void; - onNext: () => void; -}; + const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor }; + const { data: page, isLoading } = useBrokerPositions(accountId, query); -export function BrokerPositionsSection({ - page, - isLoading, - pageNumber, - canGoBack, - canGoForward, - onPrevious, - onNext, -}: BrokerPositionsSectionProps) { - const positions = page?.items ?? []; + const rawPositions = page?.items ?? []; + const positions = group.type + ? rawPositions + : rawPositions.filter( + (p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()), + ); - const grouped = GROUPS.map((group) => ({ - ...group, - positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), - })).filter((group) => group.positions.length > 0); + 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 (
@@ -175,42 +139,44 @@ export function BrokerPositionsSection({ alignItems: 'center', gap: 12, justifyContent: 'space-between', - marginBottom: 12, + marginBottom: 10, }} > -

Позиции

-
- - - {pageNumber} - - -
+

{group.title}

+ {group.type && ( +
+ + + {pageNumber} + + +
+ )}
- {isLoading && grouped.length === 0 ? ( + {isLoading && (
@@ -244,58 +210,78 @@ export function BrokerPositionsSection({ />
- ) : grouped.length === 0 ? ( -

В портфеле нет позиций

- ) : isLoading ? ( -
-
- {grouped.map((group) => ( -
-

{group.title}

-
- - - - - - - - - - - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
-
-
- ))} -
-
- ) : ( -
- {grouped.map((group) => ( - - ))} + )} + + {!isLoading && positions.length > 0 && ( +
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
)} ); } + +type BrokerPositionsSectionProps = { + accountId: string; +}; + +export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) { + return ( +
+

Позиции

+ {GROUPS.map((group) => ( + + ))} +
+ ); +} diff --git a/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md b/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md new file mode 100644 index 0000000..826ed08 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md @@ -0,0 +1,269 @@ +# Пагинация позиций, скелетоны, название инструмента в операциях + +Дата: 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({ + 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
; +} +``` + +**BrokerAccountsPage:** +- Вместо `

Загрузка...

` — 3 карточки-скелетона в grid +```tsx +{isLoading && ( +
+ {[1,2,3].map(i => ( +
+ +
+ +
+ +
+ ))} +
+)} +``` + +**BrokerAccountDetailPage:** +- Вместо `

Загрузка портфеля...

` — 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 -; + if (!path) return {name}; + + return ( +
+ {ticker} + {name && name !== ticker && ( + {name} + )} +
+ ); +} +``` + +### 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 ( + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + ))} + + ); +} +``` + +Количество колонок и их ширина зависит от таблицы (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-строки показываются при переключении страниц +- Проверить, что название инструмента отображается в операциях