515 lines
15 KiB
Markdown
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.
|