Compare commits
No commits in common. "af135d5640ab7be07529da54af112c393ffb7ddd" and "b9ace3ed5e15f8a8dc56c18d208e517cb9ae7414" have entirely different histories.
af135d5640
...
b9ace3ed5e
@ -122,7 +122,6 @@ export function BrokerAccountDetailPage() {
|
|||||||
|
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
isLoading={operations.isLoading}
|
isLoading={operations.isLoading}
|
||||||
isFetching={operations.isFetching}
|
|
||||||
page={operations.data}
|
page={operations.data}
|
||||||
pageNumber={operationCursorStack.length + 1}
|
pageNumber={operationCursorStack.length + 1}
|
||||||
canGoBack={operationCursorStack.length > 0}
|
canGoBack={operationCursorStack.length > 0}
|
||||||
|
|||||||
@ -95,7 +95,6 @@ const pagButtonDisabledStyle = {
|
|||||||
|
|
||||||
export function BrokerOperationsTable({
|
export function BrokerOperationsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
|
||||||
page,
|
page,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
@ -104,7 +103,6 @@ export function BrokerOperationsTable({
|
|||||||
onNext,
|
onNext,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
|
||||||
page: BrokerOperationsPage | undefined;
|
page: BrokerOperationsPage | undefined;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
@ -130,17 +128,10 @@ export function BrokerOperationsTable({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
←
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@ -156,17 +147,10 @@ export function BrokerOperationsTable({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
→
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -196,60 +180,51 @@ export function BrokerOperationsTable({
|
|||||||
/>
|
/>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<table style={tableStyle}>
|
||||||
<table style={tableStyle}>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th align="left" style={thStyle}>
|
||||||
<th align="left" style={thStyle}>
|
Дата
|
||||||
Дата
|
</th>
|
||||||
</th>
|
<th align="left" style={thStyle}>
|
||||||
<th align="left" style={thStyle}>
|
Тип
|
||||||
Тип
|
</th>
|
||||||
</th>
|
<th align="left" style={thStyle}>
|
||||||
<th align="left" style={thStyle}>
|
Инструмент
|
||||||
Инструмент
|
</th>
|
||||||
</th>
|
<th align="right" style={thStyle}>
|
||||||
<th align="right" style={thStyle}>
|
Сумма
|
||||||
Сумма
|
</th>
|
||||||
</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{operations.map((operation) => {
|
||||||
{operations.map((operation) => {
|
const impact = getBrokerOperationImpact(operation);
|
||||||
const impact = getBrokerOperationImpact(operation);
|
|
||||||
return (
|
return (
|
||||||
<tr key={operation.cursor || operation.id}>
|
<tr key={operation.cursor || operation.id}>
|
||||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<OperationInstrument operation={operation} />
|
<OperationInstrument operation={operation} />
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
align="right"
|
align="right"
|
||||||
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
|
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
{formatMoney(operation.payment)}
|
{formatMoney(operation.payment)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -56,7 +56,6 @@ function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
@ -84,7 +83,6 @@ describe('Broker pages', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@ -112,7 +110,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-16T00:00:00.000Z',
|
asOf: '2026-06-16T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||||
@ -148,7 +145,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-16T00:00:00.000Z',
|
asOf: '2026-06-16T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
mockUseBrokerPositions(
|
mockUseBrokerPositions(
|
||||||
@ -192,7 +188,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||||
@ -204,7 +199,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
mockUseBrokerPositions(
|
mockUseBrokerPositions(
|
||||||
@ -270,7 +264,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
|
||||||
@ -329,7 +322,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
mockUseBrokerPositions();
|
mockUseBrokerPositions();
|
||||||
@ -369,7 +361,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
|
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
|
||||||
@ -440,7 +431,6 @@ describe('Broker pages', () => {
|
|||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -100,7 +100,7 @@ function PositionGroupTable({
|
|||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
|
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
|
||||||
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
|
const { data: page, isLoading } = useBrokerPositions(accountId, query);
|
||||||
|
|
||||||
const rawPositions = page?.items ?? [];
|
const rawPositions = page?.items ?? [];
|
||||||
const positions = group.type
|
const positions = group.type
|
||||||
@ -148,17 +148,10 @@ function PositionGroupTable({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
←
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@ -174,17 +167,10 @@ function PositionGroupTable({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
→
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -227,68 +213,58 @@ function PositionGroupTable({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && positions.length > 0 && (
|
{!isLoading && positions.length > 0 && (
|
||||||
<div className="table-container">
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||||
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th align="left" style={thStyle}>
|
||||||
<th align="left" style={thStyle}>
|
Тикер
|
||||||
Тикер
|
</th>
|
||||||
</th>
|
<th align="left" style={thStyle}>
|
||||||
<th align="left" style={thStyle}>
|
Название
|
||||||
Название
|
</th>
|
||||||
</th>
|
<th align="right" style={thStyle}>
|
||||||
<th align="right" style={thStyle}>
|
Количество
|
||||||
Количество
|
</th>
|
||||||
</th>
|
<th align="right" style={thStyle}>
|
||||||
<th align="right" style={thStyle}>
|
Цена
|
||||||
Цена
|
</th>
|
||||||
</th>
|
<th align="right" style={thStyle}>
|
||||||
<th align="right" style={thStyle}>
|
Стоимость
|
||||||
Стоимость
|
</th>
|
||||||
</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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{positions.map((position) => (
|
</table>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -50,33 +50,3 @@ a {
|
|||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
border-radius: 4px;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
# Индикация загрузки при переключении страниц в таблицах брокера
|
|
||||||
|
|
||||||
Дата: 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 (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
background: 'rgba(255,255,255,0.65)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
flexDirection: 'column', gap: 12,
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
}}>
|
|
||||||
<div className="loading-spinner" />
|
|
||||||
{pageNumber !== undefined && (
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
|
||||||
Загрузка страницы {pageNumber}…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пагинация: спиннер в кнопке
|
|
||||||
|
|
||||||
При `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 остаётся, данные не мигают
|
|
||||||
Loading…
x
Reference in New Issue
Block a user