diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index d43ff7e..197def5 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -122,6 +122,7 @@ export function BrokerAccountDetailPage() { 0} diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index 89539a5..62f32b4 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -95,6 +95,7 @@ const pagButtonDisabledStyle = { export function BrokerOperationsTable({ isLoading, + isFetching, page, pageNumber, canGoBack, @@ -103,6 +104,7 @@ export function BrokerOperationsTable({ onNext, }: { isLoading: boolean; + isFetching: boolean; page: BrokerOperationsPage | undefined; pageNumber: number; canGoBack: boolean; @@ -128,10 +130,17 @@ export function BrokerOperationsTable({ - → + {isFetching ? ( + + ) : ( + '→' + )} @@ -180,51 +196,60 @@ export function BrokerOperationsTable({ /> - ) : operations.length === 0 ? ( + ) : operations.length === 0 && !isFetching ? (

Операций за выбранный период нет

) : ( -
- - - - - - - - - - - {operations.map((operation) => { - const impact = getBrokerOperationImpact(operation); - - return ( - - - - - - - ); - })} - -
- Дата - - Тип - - Инструмент - - Сумма -
{formatDate(operation.date)} - {getBrokerOperationTypeLabel(operation)} - - - - {formatMoney(operation.payment)} -
+
+
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + return ( + + + + + + + ); + })} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)} + {getBrokerOperationTypeLabel(operation)} + + + + {formatMoney(operation.payment)} +
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )}
)} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index a5c4c6a..233ee9c 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -56,6 +56,7 @@ function mockUseBrokerPositions(...positions: BrokerPosition[]) { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any; }); @@ -83,6 +84,7 @@ describe('Broker pages', () => { }, ], isLoading: false, + isFetching: false, error: null, } as any); @@ -110,6 +112,7 @@ describe('Broker pages', () => { asOf: '2026-06-16T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ @@ -145,6 +148,7 @@ describe('Broker pages', () => { asOf: '2026-06-16T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); mockUseBrokerPositions( @@ -188,6 +192,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ @@ -199,6 +204,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); mockUseBrokerPositions( @@ -264,6 +270,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ @@ -322,6 +329,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); mockUseBrokerPositions(); @@ -361,6 +369,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, } as any); const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( @@ -431,6 +440,7 @@ describe('Broker pages', () => { asOf: '2026-06-17T00:00:00.000Z', }, isLoading: false, + isFetching: false, error: null, }) as any, ); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx index 7dd2189..5c8cb6c 100644 --- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx @@ -100,7 +100,7 @@ function PositionGroupTable({ const [cursor, setCursor] = useState(undefined); const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor }; - const { data: page, isLoading } = useBrokerPositions(accountId, query); + const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query); const rawPositions = page?.items ?? []; const positions = group.type @@ -148,10 +148,17 @@ function PositionGroupTable({ - → + {isFetching ? ( + + ) : ( + '→' + )}
)} @@ -213,58 +227,68 @@ function PositionGroupTable({ )} {!isLoading && positions.length > 0 && ( -
- - - - - - - - - - - - {positions.map((position) => ( - - - - - - +
+
+
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- - - - {position.name || '-'} - - - {formatQuantity(position.quantity)} - - {formatMoney(position.currentPrice)} - - {formatMoney(position.currentValue)} -
+ + + + + + + - ))} - -
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + {positions.map((position) => ( + + + + + + + {position.name || '-'} + + + + {formatQuantity(position.quantity)} + + + {formatMoney(position.currentPrice)} + + + {formatMoney(position.currentValue)} + + + ))} + + +
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )}
)} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index b741b67..18678e9 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -50,3 +50,33 @@ a { animation: shimmer 1.5s ease-in-out infinite; border-radius: 4px; } + +@keyframes loading-spin { + to { transform: rotate(360deg); } +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--color-bg); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: loading-spin 0.8s linear infinite; +} + +.table-container { + position: relative; +} + +.table-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.65); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + transition: opacity 0.2s ease; + z-index: 1; +} diff --git a/docs/superpowers/specs/2026-06-18-pagination-loading-overlay-design.md b/docs/superpowers/specs/2026-06-18-pagination-loading-overlay-design.md new file mode 100644 index 0000000..86e5c79 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-pagination-loading-overlay-design.md @@ -0,0 +1,116 @@ +# Индикация загрузки при переключении страниц в таблицах брокера + +Дата: 2026-06-18 +Статус: черновик + +## Контекст + +Страница детального просмотра брокерского счёта (`BrokerAccountDetailPage.tsx`) содержит несколько таблиц с пагинацией: + +- **PositionGroupTable** — Акции, Облигации, ETF, Фонды (4 независимые таблицы с курсорной пагинацией) +- **BrokerOperationsTable** — Операции (курсорная пагинация, управляемая из родительского компонента) + +Текущее поведение при переключении страниц: `isLoading === true` → таблица скрывается, показывается `TableSkeleton` (shimmer-строки). Это создаёт визуальный flash: контент исчезает → скелетон → новые данные. При этом `placeholderData: keepPreviousData` уже настроен в хуках, но компоненты его не используют — они проверяют `isLoading`, а не `data`. + +## Цель + +Добавить плавную индикацию загрузки при переключении страниц, чтобы пользователь видел, что данные обновляются, но не терял визуальный контекст. + +## Дизайн (выбран C3) + +### Визуальное поведение + +1. При нажатии «→» (вперед) или «←» (назад): + - Текущее содержимое таблицы **остаётся видимым** (предыдущая страница) + - Поверх таблицы появляется **полупрозрачный overlay** с центрированным спиннером + - Кнопка пагинации показывает спиннер и блокируется +2. Когда новые данные загружены: + - Overlay исчезает с fade-out + - Таблица обновляется новыми данными +3. При первой загрузке (initial load): + - Overlay не используется (нет старых данных для показа) + - Показывается `TableSkeleton` (как сейчас) + +### Как это работает технически + +TanStack Query v5 предоставляет два флага: + +- `isLoading` — true, когда данных **нет** и идёт первый запрос (initial load) +- `isFetching` — true при любом запросе (включая фоновые refetch'и при смене cursor) + +Логика рендеринга для таблиц: + +``` +if isLoading → TableSkeleton (первая загрузка, данных нет) +if isFetching && data → TableLoadingOverlay + старые данные (переключение страниц) +иначе → рендер таблицы с данными +``` + +### Компонент TableLoadingOverlay + +```tsx +interface TableLoadingOverlayProps { + pageNumber?: number; +} + +function TableLoadingOverlay({ pageNumber }: TableLoadingOverlayProps) { + return ( +
+
+ {pageNumber !== undefined && ( + + Загрузка страницы {pageNumber}… + + )} +
+ ); +} +``` + +### Пагинация: спиннер в кнопке + +При `isFetching` кнопка «→» или «←» показывает спиннер вместо стрелки и становится disabled. + +```css +@keyframes loading-spin { + to { transform: rotate(360deg); } +} + +.loading-spinner { + width: 20px; height: 20px; + border: 2px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: loading-spin 0.8s linear infinite; +} +``` + +## Где применяется + +| Компонент | Что меняется | +|---|---| +| `BrokerPositionsSection.tsx` (PositionGroupTable) | Overlay вместо TableSkeleton при isFetching. Спиннер в кнопках пагинации | +| `BrokerOperationsTable.tsx` | Overlay вместо TableSkeleton при isFetching. Спиннер в кнопках пагинации | + +## Файлы для изменения + +| Файл | Изменение | +|---|---| +| `apps/frontend/src/styles.css` | Добавить `@keyframes loading-spin`, `.loading-spinner`, `.table-loading-overlay` | +| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Overlay + спиннер в пагинации. Использовать `isFetching` из хука | +| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Overlay + спиннер в пагинации. Использовать `isFetching` из хука | +| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Прокинуть `isFetching` для операций (из useBrokerOperations) | +| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты для overlay-логики | + +## Тестирование + +- `npm run test:frontend` — существующие тесты проходят с учётом изменений +- Ручная проверка: переключение страниц в Акциях, Облигациях, Операциях — overlay появляется/исчезает +- Ручная проверка: при первой загрузке — skeleton (не overlay) +- Ручная проверка: при быстром переключении (быстрее, чем загрузка) — overlay остаётся, данные не мигают