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/config/configuration.ts b/apps/backend/src/config/configuration.ts index cb3472a..c0a6ede 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -32,6 +32,7 @@ export default registerAs('app', () => ({ tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), + tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), }, auth: { diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index d98eb0a..a718486 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; +import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; export class BrokerResponseMetaDto { @@ -36,6 +37,14 @@ export class BrokerOperationsEnvelopeDto { meta!: BrokerResponseMetaDto; } +export class BrokerPositionsEnvelopeDto { + @ApiProperty({ type: BrokerPositionsPageResponseDto }) + data!: BrokerPositionsPageResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} + export class BrokerOperationSyncEnvelopeDto { @ApiProperty({ type: BrokerOperationSyncResponseDto }) data!: BrokerOperationSyncResponseDto; diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts index d6b365d..e2a5afc 100644 --- a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts @@ -28,6 +28,9 @@ export class BrokerOperationResponseDto { @ApiProperty({ nullable: true }) description!: string | null; + @ApiProperty({ nullable: true }) + name!: string | null; + @ApiProperty({ nullable: true }) state!: string | null; diff --git a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts index e86f56c..c06b5cc 100644 --- a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts @@ -2,50 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerMoneyDto } from './broker-money.dto'; -export class BrokerPositionResponseDto { - @ApiProperty({ nullable: true }) - figi!: string | null; - - @ApiProperty({ nullable: true }) - instrumentUid!: string | null; - - @ApiProperty({ nullable: true }) - positionUid!: string | null; - - @ApiProperty({ nullable: true }) - ticker!: string | null; - - @ApiProperty({ nullable: true }) - classCode!: string | null; - - @ApiProperty({ nullable: true }) - instrumentType!: string | null; - - @ApiProperty({ nullable: true }) - name!: string | null; - - @ApiProperty({ nullable: true }) - quantity!: number | null; - - @ApiProperty({ nullable: true }) - blockedLots!: number | null; - - @ApiProperty({ type: BrokerMoneyDto, nullable: true }) - currentPrice!: BrokerMoneyDto | null; - - @ApiProperty({ type: BrokerMoneyDto, nullable: true }) - currentValue!: BrokerMoneyDto | null; - - @ApiProperty({ type: BrokerMoneyDto, nullable: true }) - averagePositionPrice!: BrokerMoneyDto | null; - - @ApiProperty({ nullable: true }) - expectedYieldPercent!: number | null; - - @ApiProperty({ type: BrokerMoneyDto, nullable: true }) - dailyYield!: BrokerMoneyDto | null; -} - export class BrokerPortfolioTotalsDto { @ApiProperty({ type: BrokerMoneyDto, nullable: true }) shares!: BrokerMoneyDto | null; @@ -102,9 +58,6 @@ export class BrokerPortfolioResponseDto { @ApiProperty({ type: [BrokerMoneyDto] }) blockedCash!: BrokerMoneyDto[]; - @ApiProperty({ type: [BrokerPositionResponseDto] }) - positions!: BrokerPositionResponseDto[]; - @ApiProperty() asOf!: string; } 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 new file mode 100644 index 0000000..c73fe51 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class BrokerPositionQueryDto { + @ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @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/dto/broker-position-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts new file mode 100644 index 0000000..655d553 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerMoneyDto } from './broker-money.dto'; + +export class BrokerPositionResponseDto { + @ApiProperty({ nullable: true }) + figi!: string | null; + + @ApiProperty({ nullable: true }) + instrumentUid!: string | null; + + @ApiProperty({ nullable: true }) + positionUid!: string | null; + + @ApiProperty({ nullable: true }) + ticker!: string | null; + + @ApiProperty({ nullable: true }) + classCode!: string | null; + + @ApiProperty({ nullable: true }) + instrumentType!: string | null; + + @ApiProperty({ nullable: true }) + name!: string | null; + + @ApiProperty({ nullable: true }) + quantity!: number | null; + + @ApiProperty({ nullable: true }) + blockedLots!: number | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + currentPrice!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + currentValue!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + averagePositionPrice!: BrokerMoneyDto | null; + + @ApiProperty({ nullable: true }) + expectedYieldPercent!: number | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + dailyYield!: BrokerMoneyDto | null; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts new file mode 100644 index 0000000..71decea --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerPositionResponseDto } from './broker-position-response.dto'; + +export class BrokerPositionsPageResponseDto { + @ApiProperty() + accountId!: string; + + @ApiProperty({ type: [BrokerPositionResponseDto] }) + items!: BrokerPositionResponseDto[]; + + @ApiProperty({ nullable: true }) + nextCursor!: string | null; + + @ApiProperty() + hasNext!: boolean; + + @ApiProperty() + asOf!: string; +} diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts index b7f3e0e..3d9fc4b 100644 --- a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts +++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts @@ -108,6 +108,7 @@ export function mapOperation(item: TBankOperationItem, accountId: string): Broke type, category: categorizeOperationType(type), description: item.description || item.name || null, + name: item.name ?? null, state: item.state ?? null, instrumentUid: item.instrumentUid ?? null, figi: item.figi ?? null, diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts index 945b08e..f9881bf 100644 --- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts @@ -11,7 +11,7 @@ describe('portfolio.mapper', () => { accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', }; - it('combines portfolio totals, cash, and enriched positions', () => { + it('combines portfolio totals and cash (positions removed)', () => { const result = mapBrokerPortfolio({ account, portfolio: { @@ -44,11 +44,5 @@ describe('portfolio.mapper', () => { expect(result.totals.shares?.value).toBe(1000); expect(result.cash[0].value).toBe(500); expect(result.blockedCash[0].value).toBe(10); - expect(result.positions[0]).toMatchObject({ - ticker: 'SBER', - name: 'Sberbank', - quantity: 10, - currentValue: { value: 2500 }, - }); }); }); diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts index cce8974..3b60b4b 100644 --- a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts @@ -3,6 +3,7 @@ import type { BrokerMoney, BrokerPortfolio, BrokerPosition, + BrokerPositionsPage, } from '../types/broker.types'; import type { TBankInstrument, @@ -22,42 +23,58 @@ function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { return value !== null; } +export function mapBrokerPosition(input: { + position: { + figi?: string; + instrumentUid?: string; + positionUid?: string; + ticker?: string; + classCode?: string; + instrumentType?: string; + quantity?: { units?: string | number; nano?: number }; + blockedLots?: { units?: string | number; nano?: number }; + currentPrice?: { currency?: string; units?: string | number; nano?: number }; + averagePositionPrice?: { currency?: string; units?: string | number; nano?: number }; + expectedYield?: { units?: string | number; nano?: number }; + dailyYield?: { currency?: string; units?: string | number; nano?: number }; + }; + instruments: Map>; +}): BrokerPosition { + const quantity = mapQuotationToNumber(input.position.quantity); + const currentPrice = mapMoneyValue(input.position.currentPrice); + const currentValue = + currentPrice && quantity !== null + ? { + ...currentPrice, + units: String(Math.trunc(currentPrice.value * quantity)), + nano: 0, + value: Number((currentPrice.value * quantity).toFixed(9)), + } + : null; + const instrument = + (input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) || + (input.position.positionUid && input.instruments.get(input.position.positionUid)) || + undefined; + + return { + figi: input.position.figi ?? null, + instrumentUid: input.position.instrumentUid ?? null, + positionUid: input.position.positionUid ?? null, + ticker: input.position.ticker || instrument?.ticker || null, + classCode: input.position.classCode || instrument?.classCode || null, + instrumentType: input.position.instrumentType || instrument?.instrumentType || null, + name: instrument?.name ?? null, + quantity, + blockedLots: mapQuotationToNumber(input.position.blockedLots), + currentPrice, + currentValue, + averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice), + expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield), + dailyYield: mapMoneyValue(input.position.dailyYield), + }; +} + export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { - const mappedPositions = (input.portfolio.positions ?? []).map((position) => { - const quantity = mapQuotationToNumber(position.quantity); - const currentPrice = mapMoneyValue(position.currentPrice); - const currentValue = - currentPrice && quantity !== null - ? { - ...currentPrice, - units: String(Math.trunc(currentPrice.value * quantity)), - nano: 0, - value: Number((currentPrice.value * quantity).toFixed(9)), - } - : null; - const instrument = - (position.instrumentUid && input.instruments.get(position.instrumentUid)) || - (position.positionUid && input.instruments.get(position.positionUid)) || - undefined; - - return { - figi: position.figi ?? null, - instrumentUid: position.instrumentUid ?? null, - positionUid: position.positionUid ?? null, - ticker: position.ticker || instrument?.ticker || null, - classCode: position.classCode || instrument?.classCode || null, - instrumentType: position.instrumentType || instrument?.instrumentType || null, - name: instrument?.name ?? null, - quantity, - blockedLots: mapQuotationToNumber(position.blockedLots), - currentPrice, - currentValue, - averagePositionPrice: mapMoneyValue(position.averagePositionPrice), - expectedYieldPercent: mapQuotationToNumber(position.expectedYield), - dailyYield: mapMoneyValue(position.dailyYield), - }; - }); - return { account: input.account, totals: { @@ -78,7 +95,36 @@ export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfo }, cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney), blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney), - positions: mappedPositions, + asOf: new Date().toISOString(), + }; +} + +export function mapBrokerPositionsPage(input: { + accountId: string; + portfolio: TBankPortfolioResponse; + instruments: Map>; + cursor?: string; + limit: number; +}): BrokerPositionsPage { + const allPositions = (input.portfolio.positions ?? []).map((position) => + mapBrokerPosition({ position, instruments: input.instruments }), + ); + + let startIndex = 0; + if (input.cursor) { + const found = allPositions.findIndex((p) => p.positionUid === input.cursor); + startIndex = found >= 0 ? found + 1 : allPositions.length; + } + + const pageItems = allPositions.slice(startIndex, startIndex + input.limit); + const hasNext = startIndex + input.limit < allPositions.length; + const nextCursor = hasNext ? (pageItems[pageItems.length - 1]?.positionUid ?? null) : null; + + return { + accountId: input.accountId, + items: pageItems, + nextCursor, + hasNext, asOf: new Date().toISOString(), }; } diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts index 14bc828..70b5892 100644 --- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -32,6 +32,7 @@ describe('BrokerOperationSyncService', () => { category: 'trade', description: null, state: 'OPERATION_STATE_EXECUTED', + name: null, instrumentUid: 'uid-1', figi: null, ticker: 'SBER', @@ -98,6 +99,7 @@ describe('BrokerOperationSyncService', () => { category: 'trade', description: null, state: null, + name: null, instrumentUid: null, figi: null, ticker: null, 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 682d12f..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 @@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => { await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); }); - it('fetches portfolio and positions through cache', async () => { + it('fetches portfolio through cache without positions', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', @@ -60,6 +60,7 @@ describe('BrokerPortfolioService', () => { expect(result.data.account.id).toBe('acc-1'); expect(result.data.cash[0].value).toBe(1000); + expect('positions' in result.data).toBe(false); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:portfolio', ['acc-1'], @@ -68,73 +69,237 @@ describe('BrokerPortfolioService', () => { ); }); - it('returns portfolio when one instrument enrichment request fails', async () => { - vi.mocked(accounts.findById).mockResolvedValue({ - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, + describe('getPositions', () => { + function mockAccount() { + vi.mocked(accounts.findById).mockResolvedValue({ + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }); + } + + function mockCache() { + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + 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( - async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ - data: await fetchFn(), - fromCache: false, - cachedAt: null, - }), - ); - vi.mocked(client.getServiceClient).mockReturnValue({ - getPortfolio: vi.fn(), - getPositions: vi.fn(), - } as any); - vi.mocked(client.callUnary) - .mockResolvedValueOnce({ + + it('returns first page of positions', 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: [ { figi: 'figi-1', instrumentUid: 'uid-1', - quantity: { units: '1', nano: 0 }, + positionUid: 'pos-1', + quantity: { units: '10', nano: 0 }, }, { figi: 'figi-2', instrumentUid: 'uid-2', - quantity: { units: '2', nano: 0 }, + positionUid: 'pos-2', + quantity: { units: '20', nano: 0 }, }, ], - }) - .mockResolvedValueOnce({ - accountId: 'acc-1', - money: [], - blocked: [], - securities: [], }); - 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 result = await service.getPortfolio('acc-1'); + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPositions('acc-1', undefined, 1); - expect(result.data.positions).toHaveLength(2); - expect(result.data.positions[0]).toMatchObject({ - instrumentUid: 'uid-1', - ticker: 'AAA', - name: 'First share', + 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'); }); - expect(result.data.positions[1]).toMatchObject({ - instrumentUid: 'uid-2', - ticker: null, - name: null, + + 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', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [ + { + 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 }, + }, + ], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPositions('acc-1', 'p1', 1); + + expect(result.data.items).toHaveLength(1); + expect(result.data.items[0].positionUid).toBe('p2'); + expect(result.data.nextCursor).toBe('p2'); + expect(result.data.hasNext).toBe(true); + }); + + it('returns last page with hasNext=false', 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: [ + { + 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(); }); }); }); 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 7ec14a8..0553119 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; -import { mapBrokerPortfolio } from '../mappers/portfolio.mapper'; +import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; -import type { BrokerPortfolio } from '../types/broker.types'; +import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { TBankInstrument, TBankPortfolioResponse, @@ -62,6 +62,57 @@ export class BrokerPortfolioService { }; } + async getPositions( + accountId: string, + cursor?: string, + limit = 10, + type?: string, + ): Promise<{ + data: BrokerPositionsPage; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.positions, + [accountId, cursor ?? '', String(limit), type ?? ''], + async () => { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const portfolio = await this.tbankClient.callUnary< + { accountId: string; currency: string }, + TBankPortfolioResponse + >('OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), { + accountId, + currency: 'RUB', + }); + + 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: filteredPortfolio, + instruments: instrumentMap, + cursor, + limit, + }); + }, + 'tbankPositionsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + private async buildInstrumentMap( portfolio: TBankPortfolioResponse, ): Promise>> { diff --git a/apps/backend/src/modules/tbank/tbank.config.spec.ts b/apps/backend/src/modules/tbank/tbank.config.spec.ts index e3f9a34..5f874f6 100644 --- a/apps/backend/src/modules/tbank/tbank.config.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.config.spec.ts @@ -21,6 +21,7 @@ describe('T-Bank configuration', () => { expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443'); expect(config.tbank.rateLimitPerSecond).toBe(5); expect(config.cache.tbankPortfolioTtl).toBe(60); + expect(config.cache.tbankPositionsTtl).toBe(60); }); it('reads T-Bank token and TTL overrides from environment', () => { @@ -29,6 +30,7 @@ describe('T-Bank configuration', () => { process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem'; process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; + process.env.CACHE_TBANK_POSITIONS_TTL = '45'; const config = configuration(); @@ -37,5 +39,6 @@ describe('T-Bank configuration', () => { expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem'); expect(config.tbank.rateLimitPerSecond).toBe(2); expect(config.cache.tbankAccountsTtl).toBe(120); + expect(config.cache.tbankPositionsTtl).toBe(45); }); }); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 5b3f316..24a6042 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -7,7 +7,9 @@ import { BrokerOperationSyncEnvelopeDto, BrokerOperationsEnvelopeDto, BrokerPortfolioEnvelopeDto, + BrokerPositionsEnvelopeDto, } from './dto/broker-envelope.dto'; +import { BrokerPositionQueryDto } from './dto/broker-position-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto'; import { BrokerAccountsService } from './services/broker-accounts.service'; @@ -43,6 +45,22 @@ export class TBankController { return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } + @Get('accounts/:accountId/positions') + @ApiOperation({ summary: 'Get paginated T-Bank broker account positions' }) + @ApiOkResponse({ type: BrokerPositionsEnvelopeDto }) + async getPositions( + @Param('accountId') accountId: string, + @Query() query: BrokerPositionQueryDto, + ) { + const result = await this.brokerPortfolioService.getPositions( + accountId, + query.cursor, + query.limit, + query.type, + ); + return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); + } + @Get('accounts/:accountId/operations') @ApiOperation({ summary: 'Get paginated T-Bank broker account operations' }) @ApiOkResponse({ type: BrokerOperationsEnvelopeDto }) diff --git a/apps/backend/src/modules/tbank/types/broker.types.ts b/apps/backend/src/modules/tbank/types/broker.types.ts index 113a77e..cd74fa0 100644 --- a/apps/backend/src/modules/tbank/types/broker.types.ts +++ b/apps/backend/src/modules/tbank/types/broker.types.ts @@ -51,7 +51,14 @@ export type BrokerPortfolio = { }; cash: BrokerMoney[]; blockedCash: BrokerMoney[]; - positions: BrokerPosition[]; + asOf: string; +}; + +export type BrokerPositionsPage = { + accountId: string; + items: BrokerPosition[]; + nextCursor: string | null; + hasNext: boolean; asOf: string; }; @@ -66,6 +73,7 @@ export type BrokerOperation = { type: string; category: BrokerOperationCategory; description: string | null; + name: string | null; state: string | null; instrumentUid: string | null; figi: string | null; 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.test.ts b/apps/frontend/src/api/broker.test.ts index 68eca40..c0b9119 100644 --- a/apps/frontend/src/api/broker.test.ts +++ b/apps/frontend/src/api/broker.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getBrokerOperations } from './broker'; +import { getBrokerOperations, getBrokerPositions } from './broker'; describe('broker api', () => { afterEach(() => { @@ -24,4 +24,23 @@ describe('broker api', () => { 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), + ); + }); }); diff --git a/apps/frontend/src/api/broker.ts b/apps/frontend/src/api/broker.ts index 4fe91f3..d9c0816 100644 --- a/apps/frontend/src/api/broker.ts +++ b/apps/frontend/src/api/broker.ts @@ -4,6 +4,7 @@ import type { BrokerAccount, BrokerOperationsPage, BrokerPortfolio, + BrokerPositionsPage, } from './responses'; export type BrokerOperationQuery = { @@ -49,3 +50,17 @@ export function getBrokerOperations( }, ); } + +export function getBrokerPositions( + accountId: string, + 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/api/responses.ts b/apps/frontend/src/api/responses.ts index 3227eec..d69e9f4 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -299,7 +299,6 @@ export interface BrokerPortfolio { }; cash: BrokerMoney[]; blockedCash: BrokerMoney[]; - positions: BrokerPosition[]; asOf: string; } @@ -314,6 +313,7 @@ export interface BrokerOperation { type: string; category: BrokerOperationCategory; description: string | null; + name: string | null; state: string | null; instrumentUid: string | null; figi: string | null; @@ -336,3 +336,11 @@ export interface BrokerOperationsPage { hasNext: boolean; asOf: string; } + +export interface BrokerPositionsPage { + accountId: string; + items: BrokerPosition[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +} diff --git a/apps/frontend/src/components/SkeletonBlock.tsx b/apps/frontend/src/components/SkeletonBlock.tsx new file mode 100644 index 0000000..0419c7f --- /dev/null +++ b/apps/frontend/src/components/SkeletonBlock.tsx @@ -0,0 +1,20 @@ +export function SkeletonBlock({ + width, + height, + borderRadius = 4, +}: { + width?: string | number; + height?: string | number; + borderRadius?: number; +}) { + return ( +
+ ); +} diff --git a/apps/frontend/src/components/TableSkeleton.tsx b/apps/frontend/src/components/TableSkeleton.tsx new file mode 100644 index 0000000..79748f7 --- /dev/null +++ b/apps/frontend/src/components/TableSkeleton.tsx @@ -0,0 +1,25 @@ +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 ( + + {Array.from({ length: rows }).map((_, i) => ( + + {columns.map((col, j) => ( + + + + ))} + + ))} + + ); +} diff --git a/apps/frontend/src/hooks/useBrokerOperations.ts b/apps/frontend/src/hooks/useBrokerOperations.ts index 40945f4..c157c0e 100644 --- a/apps/frontend/src/hooks/useBrokerOperations.ts +++ b/apps/frontend/src/hooks/useBrokerOperations.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker'; import type { BrokerOperationsPage } from '../api/responses'; @@ -12,6 +12,7 @@ export function useBrokerOperations( queryFn: async () => (await getBrokerOperations(accountId!, query)).data, staleTime: 300_000, retry: 2, + placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } diff --git a/apps/frontend/src/hooks/useBrokerPositions.ts b/apps/frontend/src/hooks/useBrokerPositions.ts new file mode 100644 index 0000000..79903ba --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerPositions.ts @@ -0,0 +1,18 @@ +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({ + queryKey: ['broker', 'positions', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPositions(accountId!, query)).data, + staleTime: 60_000, + retry: 2, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index ae2e562..d43ff7e 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -5,10 +5,10 @@ import { useBrokerOperations } from '../../hooks/useBrokerOperations'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; import { BrokerOperationsTable } from './BrokerOperationsTable'; import { BrokerPositionsSection } from './BrokerPositionsSection'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; function formatMoney(value: BrokerMoney | null | undefined) { if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: value.currency || 'RUB', @@ -23,7 +23,40 @@ export function BrokerAccountDetailPage() { const portfolio = useBrokerPortfolio(accountId); const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); - if (portfolio.isLoading) return

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

; + if (portfolio.isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( +
+ +
+ +
+ ))} +
+
+ ); + } + if (portfolio.error || !portfolio.data) { return

Не удалось загрузить портфель

; } @@ -31,14 +64,12 @@ export function BrokerAccountDetailPage() { 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); @@ -87,7 +118,7 @@ export function BrokerAccountDetailPage() { ))} - + Загрузка брокерских счетов...

; + if (isLoading) { + return ( +
+
+

Брокерские счета

+
+
+ {[1, 2, 3].map((i) => ( +
+ +
+ +
+ +
+ +
+ ))} +
+
+ ); + } if (error) return

Не удалось загрузить счета

; return ( diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index b5f56df..89539a5 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -3,10 +3,10 @@ import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../a import { getBrokerInstrumentPath, getBrokerOperationImpact, - getBrokerOperationImpactLabel, getBrokerOperationTypeLabel, type BrokerOperationImpact, } from './brokerDisplay'; +import { TableSkeleton } from '../../components/TableSkeleton'; const tableStyle = { width: '100%', @@ -27,33 +27,14 @@ const tdStyle = { verticalAlign: 'top', } satisfies React.CSSProperties; -const impactStyles: Record = { - 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', { + const formatted = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: value.currency || 'RUB', maximumFractionDigits: 2, }).format(value.value); + return value.value > 0 ? `+${formatted}` : formatted; } function formatDate(value: string | null) { @@ -70,43 +51,48 @@ function moneyColor(impact: BrokerOperationImpact): string { } function OperationInstrument({ operation }: { operation: BrokerOperation }) { - const label = operation.ticker || operation.description || '-'; + const ticker = operation.ticker || operation.description || '-'; const path = getBrokerInstrumentPath({ ticker: operation.ticker, instrumentType: operation.instrumentType, classCode: operation.classCode, }); + const name = operation.name || operation.description; - if (!path || label === '-') { - return {label}; - } - - return {label}; -} - -function OperationType({ operation }: { operation: BrokerOperation }) { - const impact = getBrokerOperationImpact(operation); + if (!path && !name) return -; + if (!path) return {name}; + if (!ticker || ticker === '-') return {name}; return ( -
- {getBrokerOperationTypeLabel(operation)} - - {getBrokerOperationImpactLabel(impact)} - +
+ + {ticker} + + {name && name !== ticker && ( + {name} + )}
); } +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; + export function BrokerOperationsTable({ isLoading, page, @@ -139,20 +125,61 @@ export function BrokerOperationsTable({ >

Операции

- - - Страница {pageNumber} + + {pageNumber} -
{isLoading ? ( -

Загрузка операций...

+
+ + + + + + + + + + +
+ Дата + + Тип + + Инструмент + + Сумма +
+
) : operations.length === 0 ? (

Операций за выбранный период нет

) : ( @@ -182,7 +209,7 @@ export function BrokerOperationsTable({ {formatDate(operation.date)} - + {getBrokerOperationTypeLabel(operation)} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 7335e59..a5c4c6a 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ReactElement } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -7,6 +7,8 @@ import { describe, expect, it, vi } from 'vitest'; import * as accountHook from '../../hooks/useBrokerAccounts'; import * as operationsHook from '../../hooks/useBrokerOperations'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; +import * as positionsHook from '../../hooks/useBrokerPositions'; +import type { BrokerPosition } from '../../api/responses'; import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; @@ -20,6 +22,45 @@ function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { ); } +function createPosition(input: Partial): 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({ @@ -66,24 +107,6 @@ describe('Broker pages', () => { yields: { expectedPercent: 5, daily: null, dailyPercent: null }, cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], blockedCash: [], - positions: [ - { - figi: null, - instrumentUid: 'uid-1', - positionUid: null, - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - name: 'Sberbank', - quantity: 10, - blockedLots: null, - currentPrice: null, - currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, - averagePositionPrice: null, - expectedYieldPercent: null, - dailyYield: null, - }, - ], asOf: '2026-06-16T00:00:00.000Z', }, isLoading: false, @@ -124,6 +147,17 @@ describe('Broker pages', () => { 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( @@ -151,40 +185,6 @@ describe('Broker pages', () => { 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, @@ -201,6 +201,30 @@ describe('Broker pages', () => { 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( @@ -222,7 +246,7 @@ describe('Broker pages', () => { expect(screen.getByText(/900,00/)).toBeInTheDocument(); }); - it('renders broker operations with Russian labels, linked instruments and impact badges', () => { + it('renders broker operations with Russian labels, linked instruments and colored amounts', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: { account: { @@ -237,7 +261,6 @@ describe('Broker pages', () => { yields: { expectedPercent: null, daily: null, dailyPercent: null }, cash: [], blockedCash: [], - positions: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, @@ -301,6 +324,7 @@ describe('Broker pages', () => { isLoading: false, error: null, } as any); + mockUseBrokerPositions(); renderWithClient( @@ -311,8 +335,7 @@ describe('Broker pages', () => { expect(screen.getByText('Выплата купона')).toBeInTheDocument(); expect(screen.getByText('Налог')).toBeInTheDocument(); - expect(screen.getByText('Пополняет')).toBeInTheDocument(); - expect(screen.getByText('Списывает')).toBeInTheDocument(); + expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( 'href', '/bonds/SU26238RMFS5', @@ -335,7 +358,6 @@ describe('Broker pages', () => { yields: { expectedPercent: null, daily: null, dailyPercent: null }, cash: [], blockedCash: [], - positions: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, @@ -412,6 +434,7 @@ describe('Broker pages', () => { error: null, }) as any, ); + mockUseBrokerPositions(); renderWithClient( @@ -421,19 +444,26 @@ describe('Broker pages', () => { ); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - expect(screen.getByText('Страница 1')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Вперед' })); + const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; + const withinOperations = within(operationsSection); + const nextButton = withinOperations.getByRole('button', { name: '→' }); + const prevButton = withinOperations.getByRole('button', { name: '←' }); + expect(prevButton).toBeDisabled(); + expect(nextButton).not.toBeDisabled(); + + await user.click(nextButton); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: 'cursor-page-2', }); - expect(screen.getByText('Страница 2')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Назад' })); + expect(withinOperations.getByText('2')).toBeInTheDocument(); + + await user.click(prevButton); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - expect(screen.getByText('Страница 1')).toBeInTheDocument(); + expect(withinOperations.getByText('1')).toBeInTheDocument(); }); }); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx index 5c58ce5..7dd2189 100644 --- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx @@ -1,18 +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', @@ -32,9 +39,26 @@ const tdStyle = { verticalAlign: 'top', } 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) { if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: value.currency || 'RUB', @@ -65,87 +89,199 @@ 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); -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); + const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor }; + const { data: page, isLoading } = useBrokerPositions(accountId, query); - if (grouped.length === 0) { - return ( -
-

Позиции

-

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

-
- ); + 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 (
-

Позиции

-
- {grouped.map((group) => ( - - ))} +
+

{group.title}

+ {group.type && ( +
+ + + {pageNumber} + + +
+ )}
+ + {isLoading && ( +
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+ )} + + {!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/apps/frontend/src/pages/broker/brokerDisplay.test.ts b/apps/frontend/src/pages/broker/brokerDisplay.test.ts index eabb2f0..8713790 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.test.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.test.ts @@ -3,7 +3,6 @@ import type { BrokerOperation, BrokerPosition } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerOperationImpact, - getBrokerOperationImpactLabel, getBrokerOperationTypeLabel, getBrokerPositionGroup, } from './brokerDisplay'; @@ -191,11 +190,4 @@ describe('broker display helpers', () => { ), ).toBe('adds'); }); - - it('provides Russian impact labels', () => { - expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет'); - expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает'); - expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка'); - expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно'); - }); }); diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts index 716fc3d..e3191e9 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.ts @@ -161,16 +161,3 @@ export function getBrokerOperationImpact( return 'unknown'; } - -export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string { - switch (impact) { - case 'adds': - return 'Пополняет'; - case 'reduces': - return 'Списывает'; - case 'neutral': - return 'Перекладка'; - case 'unknown': - return 'Неясно'; - } -} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 5db8428..b741b67 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -33,3 +33,20 @@ a { color: var(--color-primary); 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; +} diff --git a/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md b/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md new file mode 100644 index 0000000..6e381a3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md @@ -0,0 +1,68 @@ +# Улучшение UI операций и пагинации в брокерском портфеле + +Дата: 2026-06-17 +Статус: черновик + +## Контекст + +Страница брокерского счета (`BrokerAccountDetailPage.tsx`) показывает таблицу операций и таблицы позиций. +Текущая реализация имеет несколько UI-недостатков, описанных ниже. + +Изменения затрагивают только frontend. Бэкенд, OpenAPI-контракт, DTO и codegen не меняются. + +## Изменения + +### 1. Убрать бейджи impact из таблицы "Операции" + +**Проблема:** В колонке "Тип" операции показывается label (напр. "Покупка") и под ним цветной бейдж с текстом "Пополняет", "Списывает", "Перекладка" или "Неясно". Это визуальный шум — пользователю достаточно знать тип операции и сумму. + +**Решение:** Удалить `OperationType`-компонент, который рендерит бейдж. Вместо него в ячейке "Тип" отображать только `getBrokerOperationTypeLabel(operation)`. + +**Удаляемый код:** +- Компонент `OperationType` (строки 87-108) +- Объект `impactStyles` (строки 30-47) +- Импорт `getBrokerOperationImpactLabel` (не используется больше) +- Импорт `type BrokerOperationImpact` не удаляется — он всё ещё используется в сигнатуре `moneyColor(impact: BrokerOperationImpact)` + +**Сохраняется:** +- `getBrokerOperationImpact()` — всё ещё нужна для `moneyColor()` (цвет суммы) +- `moneyColor()` и `formatMoney()` — без изменений + +### 2. Префикс "+" для положительных сумм + +**Проблема:** Отрицательные суммы уже отображаются с минусом ("−11,00 ₽"), а положительные без знака ("90,00 ₽"). Визуально неочевидно, что это приход. + +**Решение:** В функции `formatMoney()` в `BrokerOperationsTable.tsx` добавить префикс `'+'` если `value > 0`. + +Цвет суммы по-прежнему определяется через `moneyColor(impact)`. + +### 3. Пагинация: keepPreviousData и стилизация + +**Проблема (скачок):** При нажатии "Вперед" `isLoading` становится `true` → таблица исчезает, показывается "Загрузка операций..." → затем таблица возвращается с новыми данными. + +**Решение:** Использовать `placeholderData: keepPreviousData` из TanStack Query v5 в `useBrokerOperations.ts`. + +**Проблема (стили кнопок):** Кнопки "Назад" / "Вперед" используют браузерные стили по умолчанию, выглядят неаккуратно. При `disabled` состоянии визуально не отличить от активного. + +**Решение:** Добавить inline-стили для кнопок пагинации с padding, border, background, hover, disabled state. + +### 4. "Другие инструменты" + +Текущая реализация корректна: в эту секцию попадают позиции с `instrumentType !== 'share' && !== 'bond'` (ETF, валюты, фьючерсы и т.д.). Пока таких позиций нет — секция скрыта. Изменений не требуется. + +## Файлы для изменения + +| Файл | Что меняется | +|---|---| +| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Удалить `OperationType`, `impactStyles`. Модифицировать `formatMoney` с "+". Стилизовать кнопки пагинации | +| `apps/frontend/src/hooks/useBrokerOperations.ts` | Добавить `placeholderData: keepPreviousData` | +| `apps/frontend/src/pages/broker/brokerDisplay.ts` | Удалить `getBrokerOperationImpactLabel` (становится dead code). `BrokerOperationImpact` сохраняется — используется в типе возврата `getBrokerOperationImpact` и параметре `moneyColor` | +| `apps/frontend/src/pages/broker/brokerDisplay.test.ts` | Удалить тест `getBrokerOperationImpactLabel` | +| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Убрать проверки бейджей "Пополняет"/"Списывает". Обновить тест пагинации для новых стилей | + +## Тестирование + +- `npm run test:frontend` — существующие тесты должны проходить с учётом изменений +- Проверить, что пагинация не дёргает интерфейс при переключении страниц +- Проверить, что положительные суммы отображаются с "+" +- Проверить, что бейджи impact больше не показываются 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-строки показываются при переключении страниц +- Проверить, что название инструмента отображается в операциях