Sergey Krylov ba0a4cbfef
Some checks failed
CI / ci (pull_request) Failing after 13m15s
CI / ci (push) Failing after 12m58s
docs: mark broker-operations-ui-improvements and broker-positions-pagination as completed
2026-06-24 17:55:22 +03:00

270 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Пагинация позиций, скелетоны, название инструмента в операциях
Дата: 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<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`
```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 <div className="skeleton" style={{ width, height, borderRadius }} />;
}
```
**BrokerAccountsPage:**
- Вместо `<p>Загрузка...</p>` — 3 карточки-скелетона в grid
```tsx
{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`:**
```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 <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`:**
```tsx
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-строки показываются при переключении страниц
- Проверить, что название инструмента отображается в операциях