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