515 lines
15 KiB
Markdown

# Pagination Loading Overlay — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add overlay + spinner to all broker paginated tables when switching pages
**Architecture:** Use TanStack Query's `isFetching` (any fetch) vs `isLoading` (initial fetch) to show overlay when data exists and a new page is loading. Keep `keepPreviousData` so old data stays visible under the overlay. Add CSS spinner animation.
**Tech Stack:** React, TanStack Query v5, CSS custom properties
---
### Task 1: CSS — spinner animation and overlay styles
**Files:**
- Modify: `apps/frontend/src/styles.css`
- [ ] **Step 1: Add spinner keyframes and loading-spinner class**
Add to `apps/frontend/src/styles.css` at the end:
```css
@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;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend/src/styles.css
git commit -m "style: add loading-spinner and overlay CSS classes"
```
---
### Task 2: PositionGroupTable — overlay on pagination + spinner in buttons
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`
- [ ] **Step 1: Add `isFetching` to the query destructuring**
Line 103 changes from:
```tsx
const { data: page, isLoading } = useBrokerPositions(accountId, query);
```
to:
```tsx
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
```
- [ ] **Step 2: Replace the loading rendering section**
Current (lines 179-213):
```tsx
{isLoading && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
...
</table>
</div>
)}
```
Replace with new rendering logic:
```tsx
{isLoading && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Тикер</th>
<th align="left" style={thStyle}>Название</th>
<th align="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid ||
position.instrumentUid ||
position.ticker ||
position.figi
}
>
<td style={tdStyle}><PositionTicker position={position} /></td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>{formatQuantity(position.quantity)}</td>
<td align="right" style={tdStyle}>{formatMoney(position.currentPrice)}</td>
<td align="right" style={tdStyle}>{formatMoney(position.currentValue)}</td>
</tr>
))}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
</span>
</div>
)}
</div>
)}
```
- [ ] **Step 3: Update pagination buttons to show spinner during fetch**
Replace the button content in lines 167-174 (the "→" button):
```tsx
<button
type="button"
onClick={handleNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
```
Also update the "←" button (lines 148-155):
```tsx
<button
type="button"
onClick={handlePrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
git commit -m "feat: add loading overlay and spinner to PositionGroupTable"
```
---
### Task 3: BrokerOperationsTable — new `isFetching` prop + overlay
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`
- [ ] **Step 1: Add `isFetching` to props interface**
Change the component props destructuring (line 96-113):
```tsx
export function BrokerOperationsTable({
isLoading,
isFetching,
page,
pageNumber,
canGoBack,
canGoForward,
onPrevious,
onNext,
}: {
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}) {
```
- [ ] **Step 2: Replace the loading/empty/data rendering**
Current (lines 158-229):
```tsx
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
```
Replace with:
```tsx
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>Дата</th>
<th align="left" style={thStyle}>Тип</th>
<th align="left" style={thStyle}>Инструмент</th>
<th align="right" style={thStyle}>Сумма</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
</span>
</div>
)}
</div>
)}
```
Note: The empty state check changed from `operations.length === 0` to `operations.length === 0 && !isFetching` — this ensures the overlay shows on top of old data, not the empty message.
- [ ] **Step 3: Update pagination buttons**
Replace line 132-135 (← button):
```tsx
<button
type="button"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
```
Replace lines 147-154 (→ button):
```tsx
<button
type="button"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
```
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable"
```
---
### Task 4: BrokerAccountDetailPage — pass `isFetching` to operations table
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`
- [ ] **Step 1: Add `isFetching` to the BrokerOperationsTable props**
Change the `<BrokerOperationsTable>` call (line 123-131):
```tsx
<BrokerOperationsTable
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
```
- [ ] **Step 2: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
git commit -m "feat: pass isFetching to BrokerOperationsTable"
```
---
### Task 5: Update tests
**Files:**
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
- [ ] **Step 1: Add `isFetching: false` to all existing position mocks**
In `mockUseBrokerPositions` (line 47-61), add `isFetching: false`:
```tsx
return {
data: {
accountId: 'acc-1',
items: filtered,
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
```
- [ ] **Step 2: Add `isFetching: false` to all operations mocks**
Add `isFetching: false` alongside each `isLoading: false` in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433).
For example, line 112 area becomes:
```tsx
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' },
isLoading: false,
isFetching: false,
```
- [ ] **Step 3: Verify tests pass**
```bash
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
```
Expected: All tests PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "test: add isFetching to mock return values"
```
---
### Task 6: Lint and final verification
- [ ] **Step 1: Run lint**
```bash
npm run lint
```
Expected: No errors (or only pre-existing ones).
- [ ] **Step 2: Run full frontend test suite**
```bash
npm run test:frontend
```
Expected: All tests pass.
- [ ] **Step 3: Run typecheck**
```bash
npx tsc -b apps/frontend
```
Expected: No type errors.