Compare commits
No commits in common. "6c768ef6a9ac3a6506e649ba2328712392b604b8" and "9a94f4e3a9e40e55af0d8f144873b252300a95f1" have entirely different histories.
6c768ef6a9
...
9a94f4e3a9
@ -1,7 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Heading, Text } from '@moex-vibe/design-system';
|
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
@ -34,9 +32,7 @@ export function BrokerOperationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const history = operations.error ? (
|
const history = operations.error ? (
|
||||||
<Text component="p" tone="negative" role="alert">
|
<p role="alert">Не удалось загрузить историю операций</p>
|
||||||
Не удалось загрузить историю операций
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
title="История операций"
|
title="История операций"
|
||||||
@ -57,15 +53,13 @@ export function BrokerOperationsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-labelledby="broker-operations-heading">
|
<section aria-labelledby="broker-operations-heading">
|
||||||
<Box
|
<div className="broker-operations__toolbar">
|
||||||
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
|
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
|
||||||
>
|
|
||||||
<Heading level={2} id="broker-operations-heading">
|
|
||||||
Операции
|
Операции
|
||||||
</Heading>
|
</h2>
|
||||||
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
|
<label>
|
||||||
<Text variant="label">Тип операции</Text>
|
<span>Тип операции</span>
|
||||||
<select value={selectedType} onChange={handleTypeChange}>
|
<select value={selectedType} onChange={handleTypeChange}>
|
||||||
<option value="">Все операции</option>
|
<option value="">Все операции</option>
|
||||||
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
||||||
@ -74,9 +68,9 @@ export function BrokerOperationsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Box>
|
</label>
|
||||||
</Box>
|
</div>
|
||||||
{history}
|
{history}
|
||||||
</Box>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import { Heading, Text } from '@moex-vibe/design-system';
|
|
||||||
import { useBrokerPositions } from '@/entities/broker-position';
|
import { useBrokerPositions } from '@/entities/broker-position';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
||||||
@ -17,14 +15,14 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
|||||||
|
|
||||||
if (positions.error) {
|
if (positions.error) {
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-labelledby={`broker-${type}-heading`}>
|
<section aria-labelledby={`broker-${type}-heading`}>
|
||||||
<Heading level={2} id={`broker-${type}-heading`}>
|
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</h2>
|
||||||
<Text component="p" tone="negative" role="alert">
|
<p role="alert">
|
||||||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
apps/frontend/src/shared/ui/SkeletonBlock.tsx
Normal file
20
apps/frontend/src/shared/ui/SkeletonBlock.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function SkeletonBlock({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius = 4,
|
||||||
|
}: {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
borderRadius?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{
|
||||||
|
width: width ?? '100%',
|
||||||
|
height: height ?? 16,
|
||||||
|
borderRadius,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from '@moex-vibe/design-system';
|
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||||
|
|
||||||
const tdStyle = {
|
const tdStyle = {
|
||||||
borderBottom: '1px solid #eeeeee',
|
borderBottom: '1px solid #eeeeee',
|
||||||
@ -15,7 +15,7 @@ export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: C
|
|||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{columns.map((col, j) => (
|
{columns.map((col, j) => (
|
||||||
<td key={j} style={tdStyle}>
|
<td key={j} style={tdStyle}>
|
||||||
<Skeleton height={12} width={col.width} shape="text" />
|
<SkeletonBlock height={12} width={col.width} />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
export { SkeletonBlock } from './SkeletonBlock';
|
||||||
export { TableSkeleton } from './TableSkeleton';
|
export { TableSkeleton } from './TableSkeleton';
|
||||||
|
|||||||
@ -16,6 +16,12 @@
|
|||||||
--color-negative: #c62828;
|
--color-negative: #c62828;
|
||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
|
--broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%);
|
||||||
|
--broker-overview-panel: rgba(15, 51, 36, 0.93);
|
||||||
|
--broker-overview-panel-soft: rgba(255, 255, 255, 0.09);
|
||||||
|
--broker-overview-border: rgba(21, 61, 43, 0.12);
|
||||||
|
--broker-overview-accent: #98c484;
|
||||||
|
--broker-overview-gold: #d7b268;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-cell {
|
.pnl-cell {
|
||||||
@ -88,6 +94,47 @@ a {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.broker-allocation ul {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation svg {
|
||||||
|
width: 160px;
|
||||||
|
max-width: 40%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation figcaption {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation__swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-allocation__negative {
|
||||||
|
color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.loading-spinner,
|
.loading-spinner,
|
||||||
.skeleton {
|
.skeleton {
|
||||||
@ -96,4 +143,42 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.broker-operations__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-operations__toolbar label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-operations__toolbar select {
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d8d8d8;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-operations__toolbar select:focus-visible {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.broker-operations__toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.broker-operations__toolbar select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Text } from '@moex-vibe/design-system';
|
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '@/entities/broker-position';
|
||||||
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
|
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
@ -28,7 +26,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="figure" sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
<figure className="broker-allocation">
|
||||||
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||||||
<title>Структура брокерского портфеля</title>
|
<title>Структура брокерского портфеля</title>
|
||||||
{arcs.map((sector) => (
|
{arcs.map((sector) => (
|
||||||
@ -46,50 +44,40 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<Box component="figcaption" sx={{ display: 'grid', gap: 1.5 }}>
|
<figcaption>
|
||||||
{sectors.length === 0 ? (
|
{sectors.length === 0 ? (
|
||||||
<Text tone="muted">Нет данных для распределения</Text>
|
<p>Нет данных для распределения</p>
|
||||||
) : (
|
) : (
|
||||||
<Box component="ul" sx={{ listStyle: 'none', display: 'grid', gap: 1 }}>
|
<ul>
|
||||||
{sectors.map((sector) => (
|
{sectors.map((sector) => (
|
||||||
<Box
|
<li key={sector.key}>
|
||||||
component="li"
|
<span
|
||||||
key={sector.key}
|
className="broker-allocation__swatch"
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 2,
|
|
||||||
height: 2,
|
|
||||||
borderRadius: 0.25,
|
|
||||||
background: sector.color,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
style={{ background: sector.color }}
|
||||||
/>
|
/>
|
||||||
<Box component="span">
|
<span>
|
||||||
{sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
|
{sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
|
||||||
{sector.percent.toFixed(1)}%
|
{sector.percent.toFixed(1)}%
|
||||||
</Box>
|
</span>
|
||||||
</Box>
|
</li>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{negative.length > 0 && (
|
{negative.length > 0 && (
|
||||||
<Box
|
<ul
|
||||||
component="ul"
|
className="broker-allocation__negative"
|
||||||
aria-label="Отрицательные значения распределения"
|
aria-label="Отрицательные значения распределения"
|
||||||
sx={{ listStyle: 'none', display: 'grid', gap: 1 }}
|
|
||||||
>
|
>
|
||||||
{negative.map((item) => (
|
{negative.map((item) => (
|
||||||
<Box component="li" key={item.key}>
|
<li key={item.key}>
|
||||||
{item.label}: отрицательное значение{' '}
|
{item.label}: отрицательное значение{' '}
|
||||||
{formatBrokerCurrencyValue(currency, item.value)}
|
{formatBrokerCurrencyValue(currency, item.value)}
|
||||||
</Box>
|
</li>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</figcaption>
|
||||||
</Box>
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses';
|
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
|
||||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||||
import {
|
import {
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
@ -12,33 +10,42 @@ import {
|
|||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||||
import { formatBrokerSignedMoney } from '@/shared/lib/formatters';
|
import { formatBrokerSignedMoney } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
const tableSx = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
} as const;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
const thSx = {
|
const thStyle = {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid #e0e0e0',
|
||||||
borderColor: 'divider',
|
color: 'var(--color-text-secondary)',
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
p: 1,
|
padding: '10px 8px',
|
||||||
textAlign: 'left',
|
} satisfies React.CSSProperties;
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tdSx = {
|
const tdStyle = {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid #eeeeee',
|
||||||
borderColor: 'divider',
|
padding: '10px 8px',
|
||||||
p: 1,
|
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
textAlign: 'left',
|
} satisfies React.CSSProperties;
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tdSxRight = {
|
const pagButtonStyle = {
|
||||||
...tdSx,
|
padding: '6px 14px',
|
||||||
textAlign: 'right',
|
borderRadius: 6,
|
||||||
} as const;
|
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 formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@ -46,10 +53,11 @@ function formatDate(value: string | null) {
|
|||||||
return new Date(value).toLocaleString('ru-RU');
|
return new Date(value).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
function operationTone(impact: BrokerOperationImpact) {
|
function moneyColor(impact: BrokerOperationImpact): string {
|
||||||
if (impact === 'adds') return 'positive' as const;
|
if (impact === 'adds') return 'var(--color-positive)';
|
||||||
if (impact === 'reduces') return 'negative' as const;
|
if (impact === 'reduces') return 'var(--color-negative)';
|
||||||
return 'primary' as const;
|
|
||||||
|
return 'var(--color-text)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||||
@ -61,23 +69,19 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
|||||||
});
|
});
|
||||||
const name = operation.name || operation.description;
|
const name = operation.name || operation.description;
|
||||||
|
|
||||||
if (!path && !name) return <Text>-</Text>;
|
if (!path && !name) return <span>-</span>;
|
||||||
if (!path) return <Text>{name}</Text>;
|
if (!path) return <span>{name}</span>;
|
||||||
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
<Link to={path}>
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
{ticker}
|
||||||
{ticker}
|
|
||||||
</Box>
|
|
||||||
</Link>
|
</Link>
|
||||||
{name && name !== ticker && (
|
{name && name !== ticker && (
|
||||||
<Text variant="caption" tone="secondary">
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,156 +103,146 @@ export function BrokerOperationsTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-busy={isFetching}>
|
<section aria-busy={isFetching}>
|
||||||
<Box
|
<div
|
||||||
sx={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 12,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
mb: 1.5,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Heading level={2}>{title}</Heading>
|
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||||||
{headerAction}
|
{headerAction}
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
size="small"
|
|
||||||
aria-label="Предыдущая страница"
|
aria-label="Предыдущая страница"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
{isFetching ? (
|
||||||
</Button>
|
<span
|
||||||
<Box
|
className="loading-spinner"
|
||||||
sx={{
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'←'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
minWidth: 20,
|
minWidth: 20,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'text.secondary',
|
color: 'var(--color-text-secondary)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pageNumber}
|
{pageNumber}
|
||||||
</Box>
|
</span>
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
size="small"
|
|
||||||
aria-label="Следующая страница"
|
aria-label="Следующая страница"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
{isFetching ? (
|
||||||
</Button>
|
<span
|
||||||
</Box>
|
className="loading-spinner"
|
||||||
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'→'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<Box component="table" sx={tableSx}>
|
<table style={tableStyle}>
|
||||||
<Box component="thead">
|
<thead>
|
||||||
<Box component="tr">
|
<tr>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Дата
|
Дата
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Тип
|
Тип
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Инструмент
|
Инструмент
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
<th align="right" style={thStyle}>
|
||||||
Сумма
|
Сумма
|
||||||
</Box>
|
</th>
|
||||||
</Box>
|
</tr>
|
||||||
</Box>
|
</thead>
|
||||||
<TableSkeleton
|
<TableSkeleton
|
||||||
rows={5}
|
rows={5}
|
||||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</table>
|
||||||
</Box>
|
</div>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 && !isFetching ? (
|
||||||
<Text component="p" tone="muted">
|
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||||
{emptyMessage}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ position: 'relative' }}>
|
<div className="table-container">
|
||||||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<Box component="table" sx={tableSx}>
|
<table style={tableStyle}>
|
||||||
<Box component="thead">
|
<thead>
|
||||||
<Box component="tr">
|
<tr>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Дата
|
Дата
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Тип
|
Тип
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx}>
|
<th align="left" style={thStyle}>
|
||||||
Инструмент
|
Инструмент
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
<th align="right" style={thStyle}>
|
||||||
Сумма
|
Сумма
|
||||||
</Box>
|
</th>
|
||||||
</Box>
|
</tr>
|
||||||
</Box>
|
</thead>
|
||||||
<Box component="tbody">
|
<tbody>
|
||||||
{operations.map((operation) => {
|
{operations.map((operation) => {
|
||||||
const impact = getBrokerOperationImpact(operation);
|
const impact = getBrokerOperationImpact(operation);
|
||||||
return (
|
return (
|
||||||
<Box component="tr" key={operation.cursor || operation.id}>
|
<tr key={operation.cursor || operation.id}>
|
||||||
<Box component="td" sx={tdSx}>
|
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||||
{formatDate(operation.date)}
|
<td style={tdStyle}>
|
||||||
</Box>
|
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
||||||
<Box component="td" sx={tdSx}>
|
</td>
|
||||||
<Text>{getBrokerOperationTypeLabel(operation)}</Text>
|
<td style={tdStyle}>
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={tdSx}>
|
|
||||||
<OperationInstrument operation={operation} />
|
<OperationInstrument operation={operation} />
|
||||||
</Box>
|
</td>
|
||||||
<Box
|
<td
|
||||||
component="td"
|
|
||||||
sx={{
|
|
||||||
...tdSxRight,
|
|
||||||
color:
|
|
||||||
operationTone(impact) === 'positive'
|
|
||||||
? 'success.main'
|
|
||||||
: operationTone(impact) === 'negative'
|
|
||||||
? 'error.main'
|
|
||||||
: 'text.primary',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
align="right"
|
align="right"
|
||||||
|
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
{formatBrokerSignedMoney(operation.payment)}
|
{formatBrokerSignedMoney(operation.payment)}
|
||||||
</Box>
|
</td>
|
||||||
</Box>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</tbody>
|
||||||
</Box>
|
</table>
|
||||||
</Box>
|
</div>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<Box
|
<div className="table-loading-overlay" role="status">
|
||||||
sx={{
|
<div className="loading-spinner" aria-hidden="true" />
|
||||||
position: 'absolute',
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: 'rgba(255,255,255,0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="loading-spinner" />
|
|
||||||
<Text variant="caption" tone="secondary">
|
|
||||||
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||||
</Text>
|
</span>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,36 +1,44 @@
|
|||||||
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
||||||
import { Box } from '@mui/material';
|
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||||
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
|
||||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||||||
import { PositionTicker } from './PositionTicker';
|
import { PositionTicker } from './PositionTicker';
|
||||||
|
|
||||||
const tableSx = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
} as const;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
const thSx = {
|
const thStyle = {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid #e0e0e0',
|
||||||
borderColor: 'divider',
|
color: 'var(--color-text-secondary)',
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
p: 1,
|
padding: '10px 8px',
|
||||||
textAlign: 'left',
|
} satisfies React.CSSProperties;
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tdSx = {
|
const tdStyle = {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid #eeeeee',
|
||||||
borderColor: 'divider',
|
padding: '10px 8px',
|
||||||
p: 1,
|
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
textAlign: 'right',
|
} satisfies React.CSSProperties;
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tdSxLeft = {
|
const pagButtonStyle = {
|
||||||
...tdSx,
|
padding: '6px 14px',
|
||||||
textAlign: 'left',
|
borderRadius: 6,
|
||||||
} as const;
|
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) {
|
function formatQuantity(value: number | null | undefined) {
|
||||||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||||
@ -61,127 +69,127 @@ export function BrokerPositionTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||||
<Box
|
<div
|
||||||
sx={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 12,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
mb: 1.5,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Heading level={2} id={`broker-${title.toLowerCase()}-heading`}>
|
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</h2>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
size="small"
|
|
||||||
aria-label="Предыдущая страница"
|
aria-label="Предыдущая страница"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
{isFetching ? (
|
||||||
</Button>
|
<span
|
||||||
<Box
|
className="loading-spinner"
|
||||||
sx={{
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'←'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
minWidth: 20,
|
minWidth: 20,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'text.secondary',
|
color: 'var(--color-text-secondary)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pageNumber}
|
{pageNumber}
|
||||||
</Box>
|
</span>
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
size="small"
|
|
||||||
aria-label="Следующая страница"
|
aria-label="Следующая страница"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
{isFetching ? (
|
||||||
</Button>
|
<span
|
||||||
</Box>
|
className="loading-spinner"
|
||||||
</Box>
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'→'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<Box component="table" sx={tableSx}>
|
<table style={tableStyle}>
|
||||||
<Box component="thead">
|
<thead>
|
||||||
<Box component="tr">
|
<tr>
|
||||||
<Box component="th" sx={thSx} align="left">
|
<th align="left" style={thStyle}>
|
||||||
Тикер
|
Тикер
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="left">
|
<th align="left" style={thStyle}>
|
||||||
Название
|
Название
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Количество
|
Количество
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Цена
|
Цена
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Стоимость
|
Стоимость
|
||||||
</Box>
|
</th>
|
||||||
</Box>
|
</tr>
|
||||||
</Box>
|
</thead>
|
||||||
<Box component="tbody">
|
<TableSkeleton
|
||||||
{[1, 2, 3, 4].map((row) => (
|
rows={4}
|
||||||
<Box component="tr" key={row}>
|
columns={[
|
||||||
<Box component="td" sx={tdSxLeft}>
|
{ width: '30%' },
|
||||||
<Skeleton height={12} width="30%" shape="text" />
|
{ width: '50%' },
|
||||||
</Box>
|
{ width: '20%' },
|
||||||
<Box component="td" sx={tdSxLeft}>
|
{ width: '25%' },
|
||||||
<Skeleton height={12} width="50%" shape="text" />
|
{ width: '25%' },
|
||||||
</Box>
|
]}
|
||||||
<Box component="td" sx={tdSx}>
|
/>
|
||||||
<Skeleton height={12} width="20%" shape="text" />
|
</table>
|
||||||
</Box>
|
</div>
|
||||||
<Box component="td" sx={tdSx}>
|
|
||||||
<Skeleton height={12} width="25%" shape="text" />
|
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={tdSx}>
|
|
||||||
<Skeleton height={12} width="25%" shape="text" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : positions.length === 0 && !isFetching ? (
|
) : positions.length === 0 && !isFetching ? (
|
||||||
<Text component="p" tone="secondary">
|
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||||
{emptyMessage}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ position: 'relative' }}>
|
<div className="table-container">
|
||||||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<Box component="table" sx={tableSx} aria-label={`Брокерские позиции: ${title}`}>
|
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||||
<Box component="thead">
|
<thead>
|
||||||
<Box component="tr">
|
<tr>
|
||||||
<Box component="th" sx={thSx} align="left">
|
<th align="left" style={thStyle}>
|
||||||
Тикер
|
Тикер
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="left">
|
<th align="left" style={thStyle}>
|
||||||
Название
|
Название
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Количество
|
Количество
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Цена
|
Цена
|
||||||
</Box>
|
</th>
|
||||||
<Box component="th" sx={thSx} align="right">
|
<th align="right" style={thStyle}>
|
||||||
Стоимость
|
Стоимость
|
||||||
</Box>
|
</th>
|
||||||
</Box>
|
</tr>
|
||||||
</Box>
|
</thead>
|
||||||
<Box component="tbody">
|
<tbody>
|
||||||
{positions.map((position) => (
|
{positions.map((position) => (
|
||||||
<Box
|
<tr
|
||||||
component="tr"
|
|
||||||
key={
|
key={
|
||||||
position.positionUid ||
|
position.positionUid ||
|
||||||
position.instrumentUid ||
|
position.instrumentUid ||
|
||||||
@ -189,45 +197,37 @@ export function BrokerPositionTable({
|
|||||||
position.figi
|
position.figi
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box component="td" sx={tdSxLeft}>
|
<td style={tdStyle}>
|
||||||
<PositionTicker position={position} />
|
<PositionTicker position={position} />
|
||||||
</Box>
|
</td>
|
||||||
<Box component="td" sx={tdSxLeft}>
|
<td style={tdStyle}>
|
||||||
<Text tone="secondary">{position.name || '-'}</Text>
|
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
</Box>
|
{position.name || '-'}
|
||||||
<Box component="td" sx={tdSx}>
|
</span>
|
||||||
|
</td>
|
||||||
|
<td align="right" style={tdStyle}>
|
||||||
{formatQuantity(position.quantity)}
|
{formatQuantity(position.quantity)}
|
||||||
</Box>
|
</td>
|
||||||
<Box component="td" sx={tdSx}>
|
<td align="right" style={tdStyle}>
|
||||||
{formatMoney(position.currentPrice)}
|
{formatMoney(position.currentPrice)}
|
||||||
</Box>
|
</td>
|
||||||
<Box component="td" sx={tdSx}>
|
<td align="right" style={tdStyle}>
|
||||||
{formatMoney(position.currentValue)}
|
{formatMoney(position.currentValue)}
|
||||||
</Box>
|
</td>
|
||||||
</Box>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</tbody>
|
||||||
</Box>
|
</table>
|
||||||
</Box>
|
</div>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<Box
|
<div className="table-loading-overlay">
|
||||||
sx={{
|
<div className="loading-spinner" />
|
||||||
position: 'absolute',
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: 'rgba(255,255,255,0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="loading-spinner" />
|
|
||||||
<Text variant="caption" tone="secondary">
|
|
||||||
Загрузка страницы {pageNumber}…
|
Загрузка страницы {pageNumber}…
|
||||||
</Text>
|
</span>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import type { BrokerPosition } from '@/shared/api/responses';
|
import type { BrokerPosition } from '@/shared/api/responses';
|
||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||||
|
|
||||||
@ -12,18 +11,12 @@ export function PositionTicker({ position }: { position: BrokerPosition }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!path || label === '-') {
|
if (!path || label === '-') {
|
||||||
return (
|
return <strong>{label}</strong>;
|
||||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={path}>
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
{label}
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Дизайн-системный рефакторинг страниц брокерского счёта
|
# Дизайн-системный рефакторинг страниц брокерского счёта
|
||||||
|
|
||||||
Дата: 2026-06-21
|
Дата: 2026-06-21
|
||||||
Статус: реализовано
|
Статус: Draft
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
@ -55,31 +55,15 @@ Epic `Брокерский портфель` уже покрыт функцио
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [x] `BrokerAccountLayout` не содержит CSS-классов из `styles.css`; используется `<Heading>`, `<Text>`, `<Box>`.
|
- [ ] `BrokerAccountLayout` не содержит CSS-классов из `styles.css`; используется `<Heading>`, `<Text>`, `<Box>`.
|
||||||
- [x] `BrokerAccountOverviewPage` и содержащиеся в нём `BrokerSummary`, `BrokerAssetCards` переведены на DS-компоненты.
|
- [ ] `BrokerAccountOverviewPage` и содержащиеся в нём `BrokerSummary`, `BrokerAssetCards` переведены на DS-компоненты.
|
||||||
- [x] `BrokerOverviewSkeleton` использует DS `<Skeleton>`.
|
- [ ] `BrokerOverviewSkeleton` использует DS `<Skeleton>`.
|
||||||
- [x] `BrokerAllocationChart` обёртки (`<figure>`, `<figcaption>`) переведены на `<Box>`; текст легенды — на `<Text>`.
|
- [ ] `BrokerAllocationChart` обёртки (`<figure>`, `<figcaption>`) переведены на `<Box>`; текст легенды — на `<Text>`.
|
||||||
- [x] `BrokerPositionTable` и `BrokerOperationsTable` используют `<Box>` для layout, `<Heading>` для заголовков, `<Button>` или `<Box>` для пагинации, пустые и loading-состояния — через DS.
|
- [ ] `BrokerPositionTable` и `BrokerOperationsTable` используют `<Box>` для layout, `<Heading>` для заголовков, `<Button>` или `<Box>` для пагинации, пустые и loading-состояния — через DS.
|
||||||
- [x] `TableSkeleton` переведён на DS `<Skeleton>`.
|
- [ ] `TableSkeleton` переведён на DS `<Skeleton>`.
|
||||||
- [x] `SkeletonBlock` удалён после замены всех потребителей.
|
- [ ] `SkeletonBlock` удалён после замены всех потребителей.
|
||||||
- [x] `BrokerPositionsPage` и `BrokerOperationsPage` переведены на `<Box>`, `<Heading>`, `<Text>`.
|
- [ ] `BrokerPositionsPage` и `BrokerOperationsPage` переведены на `<Box>`, `<Heading>`, `<Text>`.
|
||||||
- [x] `PositionTicker` — `<Link>` с `<Box component="span" sx={{ fontWeight: 700 }}>`.
|
- [ ] `PositionTicker` — `<Link>` с `<Text>` (или оставляет `fontWeight: 700` если без `sx`).
|
||||||
- [x] ESLint allowlist не нарушен (`no-restricted-imports` разрешает `Box`, `Stack`, `Grid` из `@mui/material` barrel).
|
- [ ] ESLint allowlist не нарушен (`no-restricted-imports` разрешает `Box`, `Stack`, `Grid` из `@mui/material` barrel).
|
||||||
- [x] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят.
|
- [ ] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят.
|
||||||
- [x] CSS-классы, оставшиеся без потребителей, удалены из `styles.css` (но сохраняются градиенты hero-секций).
|
- [ ] CSS-классы, оставшиеся без потребителей, удалены из `styles.css` (но сохраняются градиенты hero-секций).
|
||||||
|
|
||||||
## Результаты реализации
|
|
||||||
|
|
||||||
Все 12 задач плана выполнены и закоммичены в ветку `codex/broker-accounts-page` (13 коммитов). Ключевые изменения:
|
|
||||||
|
|
||||||
- **Полностью мигрированы на DS:** `BrokerAccountLayout`, `BrokerAccountOverviewPage`, `BrokerSummary`, `BrokerAssetCards`, `BrokerOverviewSkeleton`, `BrokerAllocationChart`, `BrokerPositionTable`, `BrokerOperationsTable`, `BrokerPositionsPage`, `BrokerOperationsPage`, `PositionTicker`.
|
|
||||||
- **Удалён legacy:** `SkeletonBlock` удалён из `shared/ui/SkeletonBlock.tsx` и `shared/ui/index.ts`.
|
|
||||||
- **Очистка CSS:** все `broker-account__*`, `broker-overview__*`, `broker-allocation__*`, `broker-operations__toolbar*` классы и переменные удалены из `styles.css`. CSS уменьшился с 274 до 99 строк.
|
|
||||||
- Верификация: 111 тестов PASS, lint чистый, build проходит без ошибок.
|
|
||||||
|
|
||||||
### Отклонения от плана
|
|
||||||
|
|
||||||
- `PositionTicker`: использован `<Box component="span">` вместо DS `<Text>` с `sx`, так как DS `Text` не поддерживает кастомный `sx`.
|
|
||||||
- `TableSkeleton`: сохранён как shared компонент (вместо удаления), так как используется `BrokerOperationsTable`.
|
|
||||||
- `loading-spinner`: оставлен как глобальный CSS-класс (генерируется через `<Box className="loading-spinner">`). DS `Skeleton shape="circular"` использован для inline-спиннеров в пагинации.
|
|
||||||
- `TableSkeleton` type signature остался без изменений (все уже импортируют `Skeleton` из DS, не `SkeletonBlock`).
|
|
||||||
|
|||||||
@ -8,94 +8,93 @@
|
|||||||
|
|
||||||
## Task 1: BrokerAccountLayout
|
## Task 1: BrokerAccountLayout
|
||||||
|
|
||||||
- [x] Заменить `div.broker-account` на `<Box component="div">`
|
- [ ] Заменить `div.broker-account` на `<Box component="div">`
|
||||||
- [x] Перевести `header` + `h1` на `<Box>` + `<Heading level={1}>`
|
- [ ] Перевести `header` + `h1` на `<Box>` + `<Heading level={1}>`
|
||||||
- [x] Перевести `nav` на `<Box component="nav">` + `<NavLink>` (Box component={NavLink} с `aria-current="page"`)
|
- [ ] Перевести `nav` на `<Box component="nav">` + `<NavLink>` (оставить className callback)
|
||||||
- [x] Перевести `div.broker-account__workspace` на grid `<Box>`
|
- [ ] Перевести `div.broker-account__workspace` на grid `<Box>`
|
||||||
- [x] Перевести `div.broker-account__content` на `<Box>`
|
- [ ] Перевести `div.broker-account__content` на `<Box>`
|
||||||
- [x] Удалить соответствующие CSS-selectors из `styles.css`
|
- [ ] Удалить соответствующие CSS-selectors из `styles.css`
|
||||||
|
|
||||||
## Task 2: BrokerAccountOverviewPage + BrokerSummary + BrokerAssetCards
|
## Task 2: BrokerAccountOverviewPage + BrokerSummary + BrokerAssetCards
|
||||||
|
|
||||||
- [x] Перевести `BrokerSummary` на `<Box>` + `<Text>`
|
- [ ] Перевести `BrokerSummary` на `<Box>` + `<Text>`
|
||||||
- [x] Перевести eyebrow/label на `<Text variant="label" tone="secondary">`
|
- [ ] Перевести eyebrow/label на `<Text variant="label" tone="secondary">`
|
||||||
- [x] Перевести `<strong>` значений на `<Box component="span" sx={{ fontWeight: 700 }}>`
|
- [ ] Перевести `<strong>` значений на `<Text sx={{ fontWeight: 700 }}>`
|
||||||
- [x] Перевести `<ul>` cash на `<Box component="ul">`
|
- [ ] Перевести `<ul>` cash на `<Box component="ul">`
|
||||||
- [x] Перевести `BrokerAssetCards` на `<Box>` + `<Link>` + `<Text>`
|
- [ ] Перевести `BrokerAssetCards` на `<Box>` + `<Link>` + `<Text>`
|
||||||
- [x] Перевести `BrokerAccountOverviewPage` container на `<Box component="div">`
|
- [ ] Перевести `BrokerAccountOverviewPage` container на `<Box component="div">`
|
||||||
- [x] Перевести `<p role="alert">` на `<Text component="p" tone="negative">`
|
- [ ] Оставить hero-gradient без изменений (Out of scope)
|
||||||
- [x] Оставить hero-gradient без изменений (Out of scope)
|
|
||||||
|
|
||||||
## Task 3: BrokerOverviewSkeleton
|
## Task 3: BrokerOverviewSkeleton
|
||||||
|
|
||||||
- [x] Заменить импорт `SkeletonBlock` на DS `<Skeleton>`
|
- [ ] Заменить импорт `SkeletonBlock` на DS `<Skeleton>`
|
||||||
- [x] Перевести контейнеры на `<Box>`
|
- [ ] Перевести контейнеры на `<Box>`
|
||||||
- [x] Сделать circular skeleton для портфельной карточки
|
- [ ] Сделать circular skeleton для портфельной карточки
|
||||||
- [x] Сделать text skeleton для текстовых placeholder'ей
|
- [ ] Сделать text skeleton для текстовых placeholder'ей
|
||||||
|
|
||||||
## Task 4: BrokerAllocationChart (wrap only)
|
## Task 4: BrokerAllocationChart (wrap only)
|
||||||
|
|
||||||
- [x] Перевести `<figure>` на `<Box component="figure">`
|
- [ ] Перевести `<figure>` на `<Box component="figure">`
|
||||||
- [x] Перевести `<figcaption>` на `<Box component="figcaption">`
|
- [ ] Перевести `<figcaption>` на `<Box component="figcaption">`
|
||||||
- [x] Перевести `<p>` пустого состояния на `<Text tone="muted">`
|
- [ ] Перевести `<p>` пустого состояния на `<Text tone="muted">`
|
||||||
- [x] Перевести `<ul>` / `<li>` легенды на `<Box component="ul">` + `<Box component="li">`
|
- [ ] Перевести `<ul>` / `<li>` легенды на `<Box component="ul">` + `<Box component="li">`
|
||||||
- [x] Перевести swatch на `<Box>` с inline-цветом в `sx`
|
- [ ] Перевести swatch на `<Box>` с inline-цветом в `sx`
|
||||||
- [x] Перевести тексты легенды на `<Box component="span">`
|
- [ ] Перевести тексты легенды на `<Text>` / `<Box component="span">`
|
||||||
- [x] Перевести negative-список на `<Box component="ul">`
|
- [ ] Перевести negative-список на `<Box component="ul">`
|
||||||
- [x] Удалить CSS для `.broker-allocation*` в `styles.css`
|
- [ ] Удалить CSS для `.broker-allocation*` (кроме inline SVG) в `styles.css`
|
||||||
|
|
||||||
## Task 5: BrokerPositionTable + PositionTicker
|
## Task 5: BrokerPositionTable + PositionTicker
|
||||||
|
|
||||||
- [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
- [ ] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
||||||
- [x] Перевести `<h2>` на `<Heading level={2}>`
|
- [ ] Перевести `<h2>` на `<Heading level={2}>` с кастомной `sx`
|
||||||
- [x] Перевести alert-тексты на `<Text tone="negative">`
|
- [ ] Перевести alert-тексты на `<Text tone="negative">`
|
||||||
- [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
- [ ] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
||||||
- [x] Перевести `loading-overlay` на `<Box>`
|
- [ ] Перевести `loading-overlay` на `<Box>`
|
||||||
- [x] Перевести `PositionTicker` ссылки на `<Box component="span" sx={{ fontWeight: 700 }}>`
|
- [ ] Перевести `PositionTicker` ссылки на `<Link>` + `<Text sx={{ fontWeight: 700 }}>`
|
||||||
- [x] Сохранить `loading-spinner` (глобальный класс, пока без замены)
|
- [ ] Сохранить `loading-spinner` (глобальный класс, пока без замены)
|
||||||
|
|
||||||
## Task 6: TableSkeleton (DS Skeleton)
|
## Task 6: TableSkeleton (DS Skeleton)
|
||||||
|
|
||||||
- [x] Убрать импорт `SkeletonBlock`
|
- [ ] Убрать импорт `SkeletonBlock`
|
||||||
- [x] Заменить на `<Skeleton height={12} width={col.width} shape="text" />`
|
- [ ] Заменить на `<Skeleton height={12} width={col.width} shape="text" />`
|
||||||
|
- [ ] Сделать прогон тестов, убедиться что нет предупреждений
|
||||||
|
|
||||||
## Task 7: BrokerOperationsTable
|
## Task 7: BrokerOperationsTable
|
||||||
|
|
||||||
- [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
- [ ] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
||||||
- [x] Перевести `<h2>` на `<Heading level={2}>`
|
- [ ] Перевести `<h2>` на `<Heading level={2}>`
|
||||||
- [x] Перевести `headerAction` container на `<Box>`
|
- [ ] Перевести `headerAction` container на `<Box>`
|
||||||
- [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
- [ ] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
||||||
- [x] Перевести alert-тексты на `<Text tone="negative">`
|
- [ ] Перевести alert-тексты на `<Text tone="negative">`
|
||||||
- [x] Перевести empty-тексты на `<Text tone="muted">`
|
- [ ] Перевести empty-тексты на `<Text tone="muted">`
|
||||||
- [x] Перевести `<label>` + `<select>` на `<Box component="label">` + `<select>`
|
- [ ] Перевести `<label>` + `<select>` на `<Box component="label">` + `<select>`
|
||||||
- [x] Оставить `loading-spinner` для overlay (глобальный класс)
|
- [ ] Оставить `loading-spinner` для overlay (глобальный класс)
|
||||||
|
|
||||||
## Task 8: BrokerPositionsPage + BrokerOperationsPage
|
## Task 8: BrokerPositionsPage + BrokerOperationsPage
|
||||||
|
|
||||||
- [x] Перевести `BrokerPositionsPage` на `<Box component="section">` + `<Heading>` + `<Text>`
|
- [ ] Перевести `BrokerPositionsPage` на `<Box component="section">` + `<Heading>` + `<Text>`
|
||||||
- [x] Перевести `BrokerOperationsPage` на `<Box>` + `<Heading>` + `<Box component="label">` + `<select>`
|
- [ ] Перевести `BrokerOperationsPage` на `<Box>` + `<Heading>` + `<Box component="label">` + `<select>`
|
||||||
- [x] Убедиться, что `role="alert"` сохраняется через `<Text tone="negative">`
|
- [ ] Убедиться, что `role="alert"` сохраняется через `<Text tone="negative">`
|
||||||
|
|
||||||
## Task 9: SkeletonBlock удаление
|
## Task 9: SkeletonBlock удаление
|
||||||
|
|
||||||
- [x] Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx`
|
- [ ] Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx`
|
||||||
- [x] Удалить экспорт из `apps/frontend/src/shared/ui/index.ts`
|
- [ ] Удалить экспорт из `apps/frontend/src/shared/ui/index.ts`
|
||||||
- [x] Проверить grep по `SkeletonBlock` — 0 совпадений
|
- [ ] Проверить grep по `SkeletonBlock` — 0 совпадений
|
||||||
|
|
||||||
## Task 10: Очистка `styles.css`
|
## Task 10: Очистка `styles.css`
|
||||||
|
|
||||||
- [x] Удалить блоки `.broker-account__*`
|
- [ ] Удалить блоки `.broker-account__*`
|
||||||
- [x] Удалить блоки `.broker-overview__*`
|
- [ ] Удалить блоки `.broker-overview__*` (кроме SVG-связанных)
|
||||||
- [x] Удалить блоки `.broker-allocation__*`
|
- [ ] Удалить блоки `.broker-allocation__*` (кроме SVG-внутренних, если остались)
|
||||||
- [x] Удалить блоки `.broker-operations__toolbar*`
|
- [ ] Удалить блоки `.broker-operations__toolbar*`
|
||||||
- [x] Удалить CSS-переменные `--broker-overview-*`
|
- [ ] Оставить `.loading-spinner` и `:root` переменные
|
||||||
- [x] Оставить `.loading-spinner`, `.skeleton`, `:root` общие переменные
|
|
||||||
|
|
||||||
## Task 11: Финальная проверка
|
## Task 11: Финальная проверка
|
||||||
|
|
||||||
- [x] `npm run test:frontend` — 111 tests PASS
|
- [ ] `npm run test:frontend`
|
||||||
- [x] `npm run lint -w apps/frontend` — без ошибок
|
- [ ] `npm run lint -w apps/frontend`
|
||||||
- [x] `npm run build:frontend` — без TypeScript/Vite ошибок
|
- [ ] `npm run build:frontend`
|
||||||
- [x] grep по `broker-(account|overview|allocation)` в TSX — 0 совпадений
|
- [ ] grep по `broker-(account|overview|allocation)` в TSX — 0 совпадений
|
||||||
- [x] grep по `SkeletonBlock` в TSX — 0 совпадений
|
- [ ] grep по `SkeletonBlock` в TSX — 0 совпадений
|
||||||
- [x] CSS: 274 → 99 строк
|
- [ ] `npm run format` для форматирования
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Implemented — 2026-06-21.
|
Draft
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Implemented — 2026-06-21.
|
Approved — утверждена пользователем 2026-06-21.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user