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