refactor: extract BrokerPositionTable to widgets layer
This commit is contained in:
parent
943a6aec2d
commit
8e70691bde
@ -1,262 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useBrokerPositions } from '@/entities/broker-position';
|
||||||
import type {
|
|
||||||
BrokerPosition,
|
|
||||||
BrokerPositionsPage as BrokerPositionsPageData,
|
|
||||||
} from '@/shared/api/responses';
|
|
||||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
|
||||||
import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
|
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
||||||
|
|
||||||
const tableStyle = {
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
fontSize: 14,
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const thStyle = {
|
|
||||||
borderBottom: '1px solid #e0e0e0',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontWeight: 600,
|
|
||||||
padding: '10px 8px',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const tdStyle = {
|
|
||||||
borderBottom: '1px solid #eeeeee',
|
|
||||||
padding: '10px 8px',
|
|
||||||
verticalAlign: 'top',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const pagButtonStyle = {
|
|
||||||
padding: '6px 14px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const pagButtonDisabledStyle = {
|
|
||||||
...pagButtonStyle,
|
|
||||||
opacity: 0.35,
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
function formatQuantity(value: number | null | undefined) {
|
|
||||||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
|
||||||
}
|
|
||||||
|
|
||||||
function PositionTicker({ position }: { position: BrokerPosition }) {
|
|
||||||
const label = position.ticker || position.figi || '-';
|
|
||||||
const path = getBrokerInstrumentPath({
|
|
||||||
ticker: position.ticker,
|
|
||||||
instrumentType: position.instrumentType,
|
|
||||||
classCode: position.classCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!path || label === '-') {
|
|
||||||
return <strong>{label}</strong>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={path} style={{ fontWeight: 700 }}>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrokerPositionTable({
|
|
||||||
title,
|
|
||||||
page,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
emptyMessage,
|
|
||||||
pageNumber,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
page: BrokerPositionsPageData | undefined;
|
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
emptyMessage: string;
|
|
||||||
pageNumber: number;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}) {
|
|
||||||
const positions = page?.items ?? [];
|
|
||||||
const canGoBack = pageNumber > 1;
|
|
||||||
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Предыдущая страница"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={!canGoBack || isFetching}
|
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
|
||||||
{isFetching ? (
|
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 20,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pageNumber}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Следующая страница"
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={!canGoForward || isFetching}
|
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
|
||||||
{isFetching ? (
|
|
||||||
<span
|
|
||||||
className="loading-spinner"
|
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
) : positions.length === 0 && !isFetching ? (
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
|
||||||
<table aria-label={`Брокерские позиции: ${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>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrokerPositionsPageProps = {
|
type BrokerPositionsPageProps = {
|
||||||
type: 'share' | 'bond';
|
type: 'share' | 'bond';
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export { BrokerPositionTable } from './ui/BrokerPositionTable';
|
||||||
@ -0,0 +1,234 @@
|
|||||||
|
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
||||||
|
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||||
|
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||||||
|
import { PositionTicker } from './PositionTicker';
|
||||||
|
|
||||||
|
const tableStyle = {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
fontSize: 14,
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const thStyle = {
|
||||||
|
borderBottom: '1px solid #e0e0e0',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '10px 8px',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const tdStyle = {
|
||||||
|
borderBottom: '1px solid #eeeeee',
|
||||||
|
padding: '10px 8px',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const pagButtonStyle = {
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const pagButtonDisabledStyle = {
|
||||||
|
...pagButtonStyle,
|
||||||
|
opacity: 0.35,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
function formatQuantity(value: number | null | undefined) {
|
||||||
|
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerPositionTable({
|
||||||
|
title,
|
||||||
|
page,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
emptyMessage,
|
||||||
|
pageNumber,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
page: BrokerPositionsPageData | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
pageNumber: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
}) {
|
||||||
|
const positions = page?.items ?? [];
|
||||||
|
const canGoBack = pageNumber > 1;
|
||||||
|
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!canGoBack || isFetching}
|
||||||
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<span
|
||||||
|
className="loading-spinner"
|
||||||
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'←'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Следующая страница"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canGoForward || isFetching}
|
||||||
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<span
|
||||||
|
className="loading-spinner"
|
||||||
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'→'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : positions.length === 0 && !isFetching ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table aria-label={`Брокерские позиции: ${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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BrokerPosition } from '@/shared/api/responses';
|
||||||
|
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||||
|
|
||||||
|
export function PositionTicker({ position }: { position: BrokerPosition }) {
|
||||||
|
const label = position.ticker || position.figi || '-';
|
||||||
|
const path = getBrokerInstrumentPath({
|
||||||
|
ticker: position.ticker,
|
||||||
|
instrumentType: position.instrumentType,
|
||||||
|
classCode: position.classCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!path || label === '-') {
|
||||||
|
return <strong>{label}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user