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

12 KiB
Raw Permalink Blame History

Пагинация позиций, скелетоны, название инструмента в операциях

Дата: 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:

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