# Пагинация позиций, скелетоны, название инструмента в операциях Дата: 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-строки показываются при переключении страниц - Проверить, что название инструмента отображается в операциях