From d16078fce9afd8852db7d4f47385382941d09db1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:37:52 +0300 Subject: [PATCH 01/22] docs: add broker operations UI improvements spec --- ...06-17-broker-operations-ui-improvements.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md 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..7c02599 --- /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` (не используется больше) + +**Сохраняется:** +- `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 больше не показываются -- 2.47.2 From 4682586002f1320c38d0ef8c64c8fc7f6e4b1476 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:41:17 +0300 Subject: [PATCH 02/22] refactor: remove unused getBrokerOperationImpactLabel helper --- .../frontend/src/pages/broker/brokerDisplay.test.ts | 8 -------- apps/frontend/src/pages/broker/brokerDisplay.ts | 13 ------------- 2 files changed, 21 deletions(-) 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 'Неясно'; - } -} -- 2.47.2 From 8e2151fc34f70c34b37599d93278a16e163cd7a0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:43:43 +0300 Subject: [PATCH 03/22] test: update broker page tests for new UI expectations --- .../src/pages/broker/BrokerPages.test.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 7335e59..a86b0b6 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -222,7 +222,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: { @@ -311,8 +311,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', @@ -421,19 +420,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: 'Вперед' })); + // Find pagination buttons by their text content (← and →) + const nextButton = screen.getByRole('button', { name: '→' }); + const prevButton = screen.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: 'Назад' })); + // Page number is shown as just a number (without "Страница" label) + expect(screen.getByText('2')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '←' })); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - expect(screen.getByText('Страница 1')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); }); }); -- 2.47.2 From 2e0c0e7df85977790e488929f94bdb28491dfe55 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:45:07 +0300 Subject: [PATCH 04/22] feat: remove impact badges, add + prefix, style pagination buttons --- .../pages/broker/BrokerOperationsTable.tsx | 94 +++++++++---------- 1 file changed, 43 insertions(+), 51 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index b5f56df..d330029 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -3,9 +3,7 @@ import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../a import { getBrokerInstrumentPath, getBrokerOperationImpact, - getBrokerOperationImpactLabel, getBrokerOperationTypeLabel, - type BrokerOperationImpact, } from './brokerDisplay'; const tableStyle = { @@ -27,33 +25,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) { @@ -84,28 +63,23 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) { return {label}; } -function OperationType({ operation }: { operation: BrokerOperation }) { - const impact = getBrokerOperationImpact(operation); +const pagButtonStyle: React.CSSProperties = { + 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, +}; - return ( -
- {getBrokerOperationTypeLabel(operation)} - - {getBrokerOperationImpactLabel(impact)} - -
- ); -} +const pagButtonDisabledStyle: React.CSSProperties = { + ...pagButtonStyle, + opacity: 0.35, + cursor: 'not-allowed', +}; export function BrokerOperationsTable({ isLoading, @@ -139,14 +113,32 @@ export function BrokerOperationsTable({ >

Операции

- - - Страница {pageNumber} + + {pageNumber} -
@@ -182,7 +174,7 @@ export function BrokerOperationsTable({ {formatDate(operation.date)} - + {getBrokerOperationTypeLabel(operation)} -- 2.47.2 From a8bce856d96e25fa5991da8bad7f7ddc345b035a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:47:34 +0300 Subject: [PATCH 05/22] fix: add back BrokerOperationImpact type import for moneyColor --- apps/frontend/src/pages/broker/BrokerOperationsTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index d330029..5705739 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -4,6 +4,7 @@ import { getBrokerInstrumentPath, getBrokerOperationImpact, getBrokerOperationTypeLabel, + type BrokerOperationImpact, } from './brokerDisplay'; const tableStyle = { -- 2.47.2 From cc8ff0a0881ff9592d8c971ab7a4e1f3f059215d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:48:00 +0300 Subject: [PATCH 06/22] feat: add keepPreviousData for smooth pagination --- apps/frontend/src/hooks/useBrokerOperations.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, }); } -- 2.47.2 From 0517501415d425f8bd55ba8a4277e27be149dc2a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:50:36 +0300 Subject: [PATCH 07/22] chore: address review feedback - consistent style, spec accuracy, test regex --- apps/frontend/src/pages/broker/BrokerOperationsTable.tsx | 8 ++++---- apps/frontend/src/pages/broker/BrokerPages.test.tsx | 2 +- .../specs/2026-06-17-broker-operations-ui-improvements.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index 5705739..1a3c612 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -64,7 +64,7 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) { return {label}; } -const pagButtonStyle: React.CSSProperties = { +const pagButtonStyle = { padding: '6px 14px', borderRadius: 6, border: '1px solid #e0e0e0', @@ -74,13 +74,13 @@ const pagButtonStyle: React.CSSProperties = { fontWeight: 600, cursor: 'pointer', lineHeight: 1.4, -}; +} satisfies React.CSSProperties; -const pagButtonDisabledStyle: React.CSSProperties = { +const pagButtonDisabledStyle = { ...pagButtonStyle, opacity: 0.35, cursor: 'not-allowed', -}; +} satisfies React.CSSProperties; export function BrokerOperationsTable({ isLoading, diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index a86b0b6..67c22e6 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -311,7 +311,7 @@ describe('Broker pages', () => { expect(screen.getByText('Выплата купона')).toBeInTheDocument(); expect(screen.getByText('Налог')).toBeInTheDocument(); - expect(screen.getByText(/\+120,00\s*[₽Р]/)).toBeInTheDocument(); + expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( 'href', '/bonds/SU26238RMFS5', 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 index 7c02599..6e381a3 100644 --- a/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md +++ b/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md @@ -22,7 +22,7 @@ - Компонент `OperationType` (строки 87-108) - Объект `impactStyles` (строки 30-47) - Импорт `getBrokerOperationImpactLabel` (не используется больше) -- Импорт `type BrokerOperationImpact` (не используется больше) +- Импорт `type BrokerOperationImpact` не удаляется — он всё ещё используется в сигнатуре `moneyColor(impact: BrokerOperationImpact)` **Сохраняется:** - `getBrokerOperationImpact()` — всё ещё нужна для `moneyColor()` (цвет суммы) -- 2.47.2 From 953c7c29a67e0281da05c58b8f158678a7d6d262 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:30:48 +0300 Subject: [PATCH 08/22] feat(tbank): add positions page types/DTOs and operation name field --- .../modules/tbank/dto/broker-envelope.dto.ts | 9 ++++ .../dto/broker-operation-response.dto.ts | 3 ++ .../dto/broker-portfolio-response.dto.ts | 47 ------------------- .../tbank/dto/broker-position-response.dto.ts | 46 ++++++++++++++++++ .../dto/broker-positions-page-response.dto.ts | 19 ++++++++ .../src/modules/tbank/types/broker.types.ts | 10 +++- 6 files changed, 86 insertions(+), 48 deletions(-) create mode 100644 apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts create mode 100644 apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts 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-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/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; -- 2.47.2 From 05f1e792c5f14ef41c1daa789613c3cb2690bf16 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:34:02 +0300 Subject: [PATCH 09/22] feat(tbank): extract mapBrokerPosition, add mapBrokerPositionsPage, add name to operation --- .../modules/tbank/mappers/operation.mapper.ts | 1 + .../modules/tbank/mappers/portfolio.mapper.ts | 118 ++++++++++++------ 2 files changed, 83 insertions(+), 36 deletions(-) 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.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(), }; } -- 2.47.2 From bb7fef8deba5252771fd8175ad3a204a93b790d0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:36:32 +0300 Subject: [PATCH 10/22] feat(tbank): add getPositions() method to BrokerPortfolioService --- .../services/broker-portfolio.service.ts | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) 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..feeae59 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,49 @@ export class BrokerPortfolioService { }; } + async getPositions( + accountId: string, + cursor?: string, + limit = 10, + ): 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], + 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 instrumentMap = await this.buildInstrumentMap(portfolio); + + return mapBrokerPositionsPage({ + accountId, + portfolio, + instruments: instrumentMap, + cursor, + limit, + }); + }, + 'tbankPositionsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + private async buildInstrumentMap( portfolio: TBankPortfolioResponse, ): Promise>> { -- 2.47.2 From 6b3bdd2aececf9e127550e5a13aa08e83376fdc5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:38:51 +0300 Subject: [PATCH 11/22] fix(tbank): include cursor/limit in positions cache key, add tbankPositionsTtl config --- apps/backend/src/config/configuration.ts | 1 + .../src/modules/tbank/services/broker-portfolio.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index feeae59..6c23bd3 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -75,7 +75,7 @@ export class BrokerPortfolioService { const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.positions, - [accountId], + [accountId, cursor ?? '', String(limit)], async () => { const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; const portfolio = await this.tbankClient.callUnary< -- 2.47.2 From 8b202ef7d3c7ac1b3554649d4b64a6c4373ea322 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:39:30 +0300 Subject: [PATCH 12/22] feat(tbank): add GET /positions endpoint with cursor pagination --- .../tbank/dto/broker-position-query.dto.ts | 18 ++++++++++++++++++ .../src/modules/tbank/tbank.controller.ts | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts 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..57e4d59 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-position-query.dto.ts @@ -0,0 +1,18 @@ +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; +} diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 5b3f316..dbe09e2 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,21 @@ 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, + ); + 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 }) -- 2.47.2 From 4b87eccba4fa37ab3d6f7e9b7bd13347e96848a7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:41:32 +0300 Subject: [PATCH 13/22] test(tbank): update portfolio tests, add getPositions tests, fix operation fixture name --- .../tbank/mappers/portfolio.mapper.spec.ts | 8 +- .../broker-operation-sync.service.spec.ts | 2 + .../services/broker-portfolio.service.spec.ts | 219 ++++++++++++++---- .../src/modules/tbank/tbank.config.spec.ts | 3 + 4 files changed, 174 insertions(+), 58 deletions(-) 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/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..d679856 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,189 @@ 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', () => { + 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 () => { + 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, + }), + ); + 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 () => { + 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, + }), + ); + 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 () => { + 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, + }), + ); + 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 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, + }), + ); + 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', + ); }); }); }); 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); }); }); -- 2.47.2 From 33199a8749dd70edfa8940345182fe8ea99b39b6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:42:49 +0300 Subject: [PATCH 14/22] feat(frontend): add shimmer animation and .skeleton CSS class --- apps/frontend/src/styles.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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; +} -- 2.47.2 From 608e5216746b207c493eaea3b0e5aa3393659371 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:43:50 +0300 Subject: [PATCH 15/22] feat(frontend): add BrokerPositionsPage types, API, and hook --- apps/frontend/src/api/broker.test.ts | 21 ++++++++++++++++++- apps/frontend/src/api/broker.ts | 14 +++++++++++++ apps/frontend/src/api/responses.ts | 10 ++++++++- apps/frontend/src/hooks/useBrokerPositions.ts | 18 ++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/hooks/useBrokerPositions.ts 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..419c633 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,16 @@ export function getBrokerOperations( }, ); } + +export function getBrokerPositions( + accountId: string, + query: { cursor?: string; limit?: number } = {}, +): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`, + { + cursor: query.cursor, + limit: query.limit ? String(query.limit) : undefined, + }, + ); +} 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/hooks/useBrokerPositions.ts b/apps/frontend/src/hooks/useBrokerPositions.ts new file mode 100644 index 0000000..fa56ad4 --- /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 } = {}, +) { + 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, + }); +} -- 2.47.2 From d7b5a376a03a73bc730f925813d5951ef65f5ef8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:44:15 +0300 Subject: [PATCH 16/22] feat(frontend): add SkeletonBlock and TableSkeleton components --- .../frontend/src/components/SkeletonBlock.tsx | 20 +++++++++++++++ .../frontend/src/components/TableSkeleton.tsx | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 apps/frontend/src/components/SkeletonBlock.tsx create mode 100644 apps/frontend/src/components/TableSkeleton.tsx 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) => ( + + + + ))} + + ))} + + ); +} -- 2.47.2 From e0c9d97dedce47a91da3127823ff444ac89555f8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:45:27 +0300 Subject: [PATCH 17/22] feat(frontend): add pagination and skeleton to BrokerPositionsSection --- .../pages/broker/BrokerPositionsSection.tsx | 182 ++++++++++++++++-- 1 file changed, 166 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx index 5c58ce5..095b364 100644 --- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerPosition } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; +import { TableSkeleton } from '../../components/TableSkeleton'; type BrokerPositionGroupConfig = { key: 'shares' | 'bonds' | 'other'; @@ -32,9 +33,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', @@ -123,29 +141,161 @@ function PositionTable({ title, positions }: { title: string; positions: BrokerP ); } -export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) { +type BrokerPositionsSectionProps = { + page: { items: BrokerPosition[] } | undefined; + isLoading: boolean; + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; +}; + +export function BrokerPositionsSection({ + page, + isLoading, + pageNumber, + canGoBack, + canGoForward, + onPrevious, + onNext, +}: BrokerPositionsSectionProps) { + const positions = page?.items ?? []; + const grouped = GROUPS.map((group) => ({ ...group, positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), })).filter((group) => group.positions.length > 0); - if (grouped.length === 0) { - return ( -
-

Позиции

-

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

-
- ); - } - return (
-

Позиции

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

Позиции

+
+ + + {pageNumber} + + +
+ + {isLoading && grouped.length === 0 ? ( +
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+ ) : grouped.length === 0 ? ( +

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

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

{group.title}

+
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+
+ ))} +
+
+ ) : ( +
+ {grouped.map((group) => ( + + ))} +
+ )}
); } -- 2.47.2 From 2670097a5e99da7749767b391178d2b674daeb18 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:46:06 +0300 Subject: [PATCH 18/22] feat(frontend): add shimmer loading and instrument name in operations table --- .../pages/broker/BrokerOperationsTable.tsx | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index 1a3c612..89539a5 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -6,6 +6,7 @@ import { getBrokerOperationTypeLabel, type BrokerOperationImpact, } from './brokerDisplay'; +import { TableSkeleton } from '../../components/TableSkeleton'; const tableStyle = { width: '100%', @@ -50,18 +51,28 @@ 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}; - } + if (!path && !name) return -; + if (!path) return {name}; + if (!ticker || ticker === '-') return {name}; - return {label}; + return ( +
+ + {ticker} + + {name && name !== ticker && ( + {name} + )} +
+ ); } const pagButtonStyle = { @@ -145,7 +156,30 @@ export function BrokerOperationsTable({
{isLoading ? ( -

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

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

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

) : ( -- 2.47.2 From 28579321a92e2216d8d91776c76fad551be6af7f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:46:48 +0300 Subject: [PATCH 19/22] feat(frontend): add positions hook and skeleton loading to account detail page --- .../pages/broker/BrokerAccountDetailPage.tsx | 149 +++++++++++++++++- 1 file changed, 144 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index ae2e562..f98b871 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -3,12 +3,13 @@ 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'; function formatMoney(value: BrokerMoney | null | undefined) { if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: value.currency || 'RUB', @@ -20,10 +21,127 @@ 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 ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( +
+ +
+ +
+ ))} +
+
+ + + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + ))} + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ +
+
+
+ ); + } - if (portfolio.isLoading) return

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

; if (portfolio.error || !portfolio.data) { return

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

; } @@ -31,20 +149,33 @@ 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); 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 (
@@ -87,7 +218,15 @@ export function BrokerAccountDetailPage() { ))} - + 0} + canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)} + onPrevious={handlePreviousPositionsPage} + onNext={handleNextPositionsPage} + /> Date: Wed, 17 Jun 2026 14:47:10 +0300 Subject: [PATCH 20/22] feat(frontend): add skeleton cards to broker accounts page --- .../src/pages/broker/BrokerAccountsPage.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx index 0089983..fd8ca3d 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; const cardStyle = { display: 'block', @@ -15,7 +16,43 @@ const cardStyle = { export function BrokerAccountsPage() { const { data: accounts, isLoading, error } = useBrokerAccounts(); - if (isLoading) return

Загрузка брокерских счетов...

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

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

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

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

; return ( -- 2.47.2 From 5ccd4212591737906010812a827ec70d4ea35138 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 14:50:35 +0300 Subject: [PATCH 21/22] test(frontend): update broker tests for positions hook and removal from portfolio --- .../src/pages/broker/BrokerPages.test.tsx | 117 ++++++++++++------ 1 file changed, 80 insertions(+), 37 deletions(-) diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 67c22e6..1be64b6 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,7 @@ 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 { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; @@ -66,24 +67,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 +107,34 @@ 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); renderWithClient( @@ -151,7 +162,26 @@ describe('Broker pages', () => { yields: { expectedPercent: 5, daily: null, dailyPercent: null }, cash: [], blockedCash: [], - positions: [ + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ { figi: null, instrumentUid: 'share-uid', @@ -185,15 +215,6 @@ describe('Broker pages', () => { dailyYield: null, }, ], - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - error: null, - } as any); - vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z', @@ -237,7 +258,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 +321,17 @@ 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); renderWithClient( @@ -334,7 +365,6 @@ describe('Broker pages', () => { yields: { expectedPercent: null, daily: null, dailyPercent: null }, cash: [], blockedCash: [], - positions: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, @@ -411,6 +441,17 @@ 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); renderWithClient( @@ -421,9 +462,11 @@ describe('Broker pages', () => { expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - // Find pagination buttons by their text content (← and →) - const nextButton = screen.getByRole('button', { name: '→' }); - const prevButton = screen.getByRole('button', { name: '←' }); + // 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: '→' }); + const prevButton = withinOperations.getByRole('button', { name: '←' }); expect(prevButton).toBeDisabled(); expect(nextButton).not.toBeDisabled(); @@ -435,11 +478,11 @@ describe('Broker pages', () => { }); // Page number is shown as just a number (without "Страница" label) - expect(screen.getByText('2')).toBeInTheDocument(); + expect(withinOperations.getByText('2')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: '←' })); + await user.click(prevButton); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - expect(screen.getByText('1')).toBeInTheDocument(); + expect(withinOperations.getByText('1')).toBeInTheDocument(); }); }); -- 2.47.2 From 49ee36485667e5bb56509ccbda05496ad7ee0271 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 18 Jun 2026 06:02:54 +0300 Subject: [PATCH 22/22] 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-строки показываются при переключении страниц +- Проверить, что название инструмента отображается в операциях -- 2.47.2