Compare commits

..

10 Commits

14 changed files with 431 additions and 487 deletions

View File

@ -1,5 +1,7 @@
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,
@ -32,7 +34,9 @@ export function BrokerOperationsPage() {
} }
const history = operations.error ? ( const history = operations.error ? (
<p role="alert">Не удалось загрузить историю операций</p> <Text component="p" tone="negative" role="alert">
Не удалось загрузить историю операций
</Text>
) : ( ) : (
<BrokerOperationsTable <BrokerOperationsTable
title="История операций" title="История операций"
@ -53,13 +57,15 @@ export function BrokerOperationsPage() {
); );
return ( return (
<section aria-labelledby="broker-operations-heading"> <Box component="section" aria-labelledby="broker-operations-heading">
<div className="broker-operations__toolbar"> <Box
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}> sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
>
<Heading level={2} id="broker-operations-heading">
Операции Операции
</h2> </Heading>
<label> <Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
<span>Тип операции</span> <Text variant="label">Тип операции</Text>
<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) => (
@ -68,9 +74,9 @@ export function BrokerOperationsPage() {
</option> </option>
))} ))}
</select> </select>
</label> </Box>
</div> </Box>
{history} {history}
</section> </Box>
); );
} }

View File

@ -1,3 +1,5 @@
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';
@ -15,14 +17,14 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
if (positions.error) { if (positions.error) {
return ( return (
<section aria-labelledby={`broker-${type}-heading`}> <Box component="section" aria-labelledby={`broker-${type}-heading`}>
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}> <Heading level={2} id={`broker-${type}-heading`}>
{title} {title}
</h2> </Heading>
<p role="alert"> <Text component="p" tone="negative" role="alert">
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
</p> </Text>
</section> </Box>
); );
} }

View File

@ -1,20 +0,0 @@
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,
}}
/>
);
}

View File

@ -1,4 +1,4 @@
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock'; import { Skeleton } from '@moex-vibe/design-system';
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}>
<SkeletonBlock height={12} width={col.width} /> <Skeleton height={12} width={col.width} shape="text" />
</td> </td>
))} ))}
</tr> </tr>

View File

@ -1,2 +1 @@
export { SkeletonBlock } from './SkeletonBlock';
export { TableSkeleton } from './TableSkeleton'; export { TableSkeleton } from './TableSkeleton';

View File

@ -16,12 +16,6 @@
--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 {
@ -94,47 +88,6 @@ 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 {
@ -143,42 +96,4 @@ 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;
}
}

View File

@ -1,4 +1,6 @@
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';
@ -26,7 +28,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
}); });
return ( return (
<figure className="broker-allocation"> <Box component="figure" sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<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) => (
@ -44,40 +46,50 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
/> />
))} ))}
</svg> </svg>
<figcaption> <Box component="figcaption" sx={{ display: 'grid', gap: 1.5 }}>
{sectors.length === 0 ? ( {sectors.length === 0 ? (
<p>Нет данных для распределения</p> <Text tone="muted">Нет данных для распределения</Text>
) : ( ) : (
<ul> <Box component="ul" sx={{ listStyle: 'none', display: 'grid', gap: 1 }}>
{sectors.map((sector) => ( {sectors.map((sector) => (
<li key={sector.key}> <Box
<span component="li"
className="broker-allocation__swatch" key={sector.key}
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 }}
/> />
<span> <Box component="span">
{sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '} {sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
{sector.percent.toFixed(1)}% {sector.percent.toFixed(1)}%
</span> </Box>
</li> </Box>
))} ))}
</ul> </Box>
)} )}
{negative.length > 0 && ( {negative.length > 0 && (
<ul <Box
className="broker-allocation__negative" component="ul"
aria-label="Отрицательные значения распределения" aria-label="Отрицательные значения распределения"
sx={{ listStyle: 'none', display: 'grid', gap: 1 }}
> >
{negative.map((item) => ( {negative.map((item) => (
<li key={item.key}> <Box component="li" key={item.key}>
{item.label}: отрицательное значение{' '} {item.label}: отрицательное значение{' '}
{formatBrokerCurrencyValue(currency, item.value)} {formatBrokerCurrencyValue(currency, item.value)}
</li> </Box>
))} ))}
</ul> </Box>
)} )}
</figcaption> </Box>
</figure> </Box>
); );
} }

View File

@ -1,6 +1,8 @@
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,
@ -10,42 +12,33 @@ 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 tableStyle = { const tableSx = {
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
fontSize: 14, fontSize: 14,
} satisfies React.CSSProperties; } as const;
const thStyle = { const thSx = {
borderBottom: '1px solid #e0e0e0', borderBottom: '1px solid',
color: 'var(--color-text-secondary)', borderColor: 'divider',
color: 'text.secondary',
fontWeight: 600, fontWeight: 600,
padding: '10px 8px', p: 1,
} satisfies React.CSSProperties; textAlign: 'left',
} as const;
const tdStyle = { const tdSx = {
borderBottom: '1px solid #eeeeee', borderBottom: '1px solid',
padding: '10px 8px', borderColor: 'divider',
p: 1,
verticalAlign: 'top', verticalAlign: 'top',
} satisfies React.CSSProperties; textAlign: 'left',
} as const;
const pagButtonStyle = { const tdSxRight = {
padding: '6px 14px', ...tdSx,
borderRadius: 6, textAlign: 'right',
border: '1px solid #e0e0e0', } as const;
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 '-';
@ -53,11 +46,10 @@ function formatDate(value: string | null) {
return new Date(value).toLocaleString('ru-RU'); return new Date(value).toLocaleString('ru-RU');
} }
function moneyColor(impact: BrokerOperationImpact): string { function operationTone(impact: BrokerOperationImpact) {
if (impact === 'adds') return 'var(--color-positive)'; if (impact === 'adds') return 'positive' as const;
if (impact === 'reduces') return 'var(--color-negative)'; if (impact === 'reduces') return 'negative' as const;
return 'primary' as const;
return 'var(--color-text)';
} }
function OperationInstrument({ operation }: { operation: BrokerOperation }) { function OperationInstrument({ operation }: { operation: BrokerOperation }) {
@ -69,19 +61,23 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) {
}); });
const name = operation.name || operation.description; const name = operation.name || operation.description;
if (!path && !name) return <span>-</span>; if (!path && !name) return <Text>-</Text>;
if (!path) return <span>{name}</span>; if (!path) return <Text>{name}</Text>;
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>; if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
return ( return (
<div style={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 0.5 }}>
<Link to={path} style={{ fontWeight: 700 }}> <Link to={path}>
{ticker} <Box component="span" sx={{ fontWeight: 700 }}>
{ticker}
</Box>
</Link> </Link>
{name && name !== ticker && ( {name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span> <Text variant="caption" tone="secondary">
{name}
</Text>
)} )}
</div> </Box>
); );
} }
@ -103,146 +99,156 @@ export function BrokerOperationsTable({
return ( return (
<section aria-busy={isFetching}> <section aria-busy={isFetching}>
<div <Box
style={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 1.5,
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 12, mb: 1.5,
}} }}
> >
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2> <Heading level={2}>{title}</Heading>
{headerAction} {headerAction}
{pagination && ( {pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<button <Button
type="button" variant="secondary"
size="small"
aria-label="Предыдущая страница" aria-label="Предыдущая страница"
onClick={onPrevious} onClick={onPrevious}
disabled={!canGoBack || isFetching} disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
> >
{isFetching ? ( {isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
<span </Button>
className="loading-spinner" <Box
style={{ width: 14, height: 14, display: 'block' }} sx={{
/>
) : (
'←'
)}
</button>
<span
style={{
minWidth: 20, minWidth: 20,
textAlign: 'center', textAlign: 'center',
color: 'var(--color-text-secondary)', color: 'text.secondary',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{pageNumber} {pageNumber}
</span> </Box>
<button <Button
type="button" variant="secondary"
size="small"
aria-label="Следующая страница" aria-label="Следующая страница"
onClick={onNext} onClick={onNext}
disabled={!canGoForward || isFetching} disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
> >
{isFetching ? ( {isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
<span </Button>
className="loading-spinner" </Box>
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'→'
)}
</button>
</div>
)} )}
</div> </Box>
{isLoading ? ( {isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
<table style={tableStyle}> <Box component="table" sx={tableSx}>
<thead> <Box component="thead">
<tr> <Box component="tr">
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Дата Дата
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Тип Тип
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Инструмент Инструмент
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
Сумма Сумма
</th> </Box>
</tr> </Box>
</thead> </Box>
<TableSkeleton <TableSkeleton
rows={5} rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]} columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/> />
</table> </Box>
</div> </Box>
) : operations.length === 0 && !isFetching ? ( ) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p> <Text component="p" tone="muted">
{emptyMessage}
</Text>
) : ( ) : (
<div className="table-container"> <Box sx={{ position: 'relative' }}>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
<table style={tableStyle}> <Box component="table" sx={tableSx}>
<thead> <Box component="thead">
<tr> <Box component="tr">
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Дата Дата
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Тип Тип
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx}>
Инструмент Инструмент
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
Сумма Сумма
</th> </Box>
</tr> </Box>
</thead> </Box>
<tbody> <Box component="tbody">
{operations.map((operation) => { {operations.map((operation) => {
const impact = getBrokerOperationImpact(operation); const impact = getBrokerOperationImpact(operation);
return ( return (
<tr key={operation.cursor || operation.id}> <Box component="tr" key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td> <Box component="td" sx={tdSx}>
<td style={tdStyle}> {formatDate(operation.date)}
<span>{getBrokerOperationTypeLabel(operation)}</span> </Box>
</td> <Box component="td" sx={tdSx}>
<td style={tdStyle}> <Text>{getBrokerOperationTypeLabel(operation)}</Text>
</Box>
<Box component="td" sx={tdSx}>
<OperationInstrument operation={operation} /> <OperationInstrument operation={operation} />
</td> </Box>
<td <Box
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)}
</td> </Box>
</tr> </Box>
); );
})} })}
</tbody> </Box>
</table> </Box>
</div> </Box>
{isFetching && ( {isFetching && (
<div className="table-loading-overlay" role="status"> <Box
<div className="loading-spinner" aria-hidden="true" /> sx={{
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}> position: 'absolute',
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}` : 'Обновление операций…'}
</span> </Text>
</div> </Box>
)} )}
</div> </Box>
)} )}
</section> </section>
); );

View File

@ -1,44 +1,36 @@
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses'; import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
import { TableSkeleton } from '@/shared/ui/TableSkeleton'; import { Box } from '@mui/material';
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 tableStyle = { const tableSx = {
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
fontSize: 14, fontSize: 14,
} satisfies React.CSSProperties; } as const;
const thStyle = { const thSx = {
borderBottom: '1px solid #e0e0e0', borderBottom: '1px solid',
color: 'var(--color-text-secondary)', borderColor: 'divider',
color: 'text.secondary',
fontWeight: 600, fontWeight: 600,
padding: '10px 8px', p: 1,
} satisfies React.CSSProperties; textAlign: 'left',
} as const;
const tdStyle = { const tdSx = {
borderBottom: '1px solid #eeeeee', borderBottom: '1px solid',
padding: '10px 8px', borderColor: 'divider',
p: 1,
verticalAlign: 'top', verticalAlign: 'top',
} satisfies React.CSSProperties; textAlign: 'right',
} as const;
const pagButtonStyle = { const tdSxLeft = {
padding: '6px 14px', ...tdSx,
borderRadius: 6, textAlign: 'left',
border: '1px solid #e0e0e0', } as const;
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');
@ -69,127 +61,127 @@ export function BrokerPositionTable({
return ( return (
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}> <section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
<div <Box
style={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 1.5,
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 10, mb: 1.5,
}} }}
> >
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}> <Heading level={2} id={`broker-${title.toLowerCase()}-heading`}>
{title} {title}
</h2> </Heading>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<button <Button
type="button" variant="secondary"
size="small"
aria-label="Предыдущая страница" aria-label="Предыдущая страница"
onClick={onPrevious} onClick={onPrevious}
disabled={!canGoBack || isFetching} disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
> >
{isFetching ? ( {isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
<span </Button>
className="loading-spinner" <Box
style={{ width: 14, height: 14, display: 'block' }} sx={{
/>
) : (
'←'
)}
</button>
<span
style={{
minWidth: 20, minWidth: 20,
textAlign: 'center', textAlign: 'center',
color: 'var(--color-text-secondary)', color: 'text.secondary',
fontSize: 14, fontSize: 14,
fontWeight: 600, fontWeight: 600,
}} }}
> >
{pageNumber} {pageNumber}
</span> </Box>
<button <Button
type="button" variant="secondary"
size="small"
aria-label="Следующая страница" aria-label="Следующая страница"
onClick={onNext} onClick={onNext}
disabled={!canGoForward || isFetching} disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
> >
{isFetching ? ( {isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
<span </Button>
className="loading-spinner" </Box>
style={{ width: 14, height: 14, display: 'block' }} </Box>
/>
) : (
'→'
)}
</button>
</div>
</div>
{isLoading ? ( {isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
<table style={tableStyle}> <Box component="table" sx={tableSx}>
<thead> <Box component="thead">
<tr> <Box component="tr">
<th align="left" style={thStyle}> <Box component="th" sx={thSx} align="left">
Тикер Тикер
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx} align="left">
Название Название
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Количество Количество
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Цена Цена
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Стоимость Стоимость
</th> </Box>
</tr> </Box>
</thead> </Box>
<TableSkeleton <Box component="tbody">
rows={4} {[1, 2, 3, 4].map((row) => (
columns={[ <Box component="tr" key={row}>
{ width: '30%' }, <Box component="td" sx={tdSxLeft}>
{ width: '50%' }, <Skeleton height={12} width="30%" shape="text" />
{ width: '20%' }, </Box>
{ width: '25%' }, <Box component="td" sx={tdSxLeft}>
{ width: '25%' }, <Skeleton height={12} width="50%" shape="text" />
]} </Box>
/> <Box component="td" sx={tdSx}>
</table> <Skeleton height={12} width="20%" shape="text" />
</div> </Box>
<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 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p> <Text component="p" tone="secondary">
{emptyMessage}
</Text>
) : ( ) : (
<div className="table-container"> <Box sx={{ position: 'relative' }}>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}> <Box component="table" sx={tableSx} aria-label={`Брокерские позиции: ${title}`}>
<thead> <Box component="thead">
<tr> <Box component="tr">
<th align="left" style={thStyle}> <Box component="th" sx={thSx} align="left">
Тикер Тикер
</th> </Box>
<th align="left" style={thStyle}> <Box component="th" sx={thSx} align="left">
Название Название
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Количество Количество
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Цена Цена
</th> </Box>
<th align="right" style={thStyle}> <Box component="th" sx={thSx} align="right">
Стоимость Стоимость
</th> </Box>
</tr> </Box>
</thead> </Box>
<tbody> <Box component="tbody">
{positions.map((position) => ( {positions.map((position) => (
<tr <Box
component="tr"
key={ key={
position.positionUid || position.positionUid ||
position.instrumentUid || position.instrumentUid ||
@ -197,37 +189,45 @@ export function BrokerPositionTable({
position.figi position.figi
} }
> >
<td style={tdStyle}> <Box component="td" sx={tdSxLeft}>
<PositionTicker position={position} /> <PositionTicker position={position} />
</td> </Box>
<td style={tdStyle}> <Box component="td" sx={tdSxLeft}>
<span style={{ color: 'var(--color-text-secondary)' }}> <Text tone="secondary">{position.name || '-'}</Text>
{position.name || '-'} </Box>
</span> <Box component="td" sx={tdSx}>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)} {formatQuantity(position.quantity)}
</td> </Box>
<td align="right" style={tdStyle}> <Box component="td" sx={tdSx}>
{formatMoney(position.currentPrice)} {formatMoney(position.currentPrice)}
</td> </Box>
<td align="right" style={tdStyle}> <Box component="td" sx={tdSx}>
{formatMoney(position.currentValue)} {formatMoney(position.currentValue)}
</td> </Box>
</tr> </Box>
))} ))}
</tbody> </Box>
</table> </Box>
</div> </Box>
{isFetching && ( {isFetching && (
<div className="table-loading-overlay"> <Box
<div className="loading-spinner" /> sx={{
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}> position: 'absolute',
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}
</span> </Text>
</div> </Box>
)} )}
</div> </Box>
)} )}
</section> </section>
); );

View File

@ -1,4 +1,5 @@
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';
@ -11,12 +12,18 @@ export function PositionTicker({ position }: { position: BrokerPosition }) {
}); });
if (!path || label === '-') { if (!path || label === '-') {
return <strong>{label}</strong>; return (
<Box component="span" sx={{ fontWeight: 700 }}>
{label}
</Box>
);
} }
return ( return (
<Link to={path} style={{ fontWeight: 700 }}> <Link to={path}>
{label} <Box component="span" sx={{ fontWeight: 700 }}>
{label}
</Box>
</Link> </Link>
); );
} }

View File

@ -1,7 +1,7 @@
# Дизайн-системный рефакторинг страниц брокерского счёта # Дизайн-системный рефакторинг страниц брокерского счёта
Дата: 2026-06-21 Дата: 2026-06-21
Статус: Draft Статус: реализовано
## Контекст ## Контекст
@ -55,15 +55,31 @@ Epic `Брокерский портфель` уже покрыт функцио
## Acceptance Criteria ## Acceptance Criteria
- [ ] `BrokerAccountLayout` не содержит CSS-классов из `styles.css`; используется `<Heading>`, `<Text>`, `<Box>`. - [x] `BrokerAccountLayout` не содержит CSS-классов из `styles.css`; используется `<Heading>`, `<Text>`, `<Box>`.
- [ ] `BrokerAccountOverviewPage` и содержащиеся в нём `BrokerSummary`, `BrokerAssetCards` переведены на DS-компоненты. - [x] `BrokerAccountOverviewPage` и содержащиеся в нём `BrokerSummary`, `BrokerAssetCards` переведены на DS-компоненты.
- [ ] `BrokerOverviewSkeleton` использует DS `<Skeleton>`. - [x] `BrokerOverviewSkeleton` использует DS `<Skeleton>`.
- [ ] `BrokerAllocationChart` обёртки (`<figure>`, `<figcaption>`) переведены на `<Box>`; текст легенды — на `<Text>`. - [x] `BrokerAllocationChart` обёртки (`<figure>`, `<figcaption>`) переведены на `<Box>`; текст легенды — на `<Text>`.
- [ ] `BrokerPositionTable` и `BrokerOperationsTable` используют `<Box>` для layout, `<Heading>` для заголовков, `<Button>` или `<Box>` для пагинации, пустые и loading-состояния — через DS. - [x] `BrokerPositionTable` и `BrokerOperationsTable` используют `<Box>` для layout, `<Heading>` для заголовков, `<Button>` или `<Box>` для пагинации, пустые и loading-состояния — через DS.
- [ ] `TableSkeleton` переведён на DS `<Skeleton>`. - [x] `TableSkeleton` переведён на DS `<Skeleton>`.
- [ ] `SkeletonBlock` удалён после замены всех потребителей. - [x] `SkeletonBlock` удалён после замены всех потребителей.
- [ ] `BrokerPositionsPage` и `BrokerOperationsPage` переведены на `<Box>`, `<Heading>`, `<Text>`. - [x] `BrokerPositionsPage` и `BrokerOperationsPage` переведены на `<Box>`, `<Heading>`, `<Text>`.
- [ ] `PositionTicker``<Link>` с `<Text>` (или оставляет `fontWeight: 700` если без `sx`). - [x] `PositionTicker``<Link>` с `<Box component="span" sx={{ fontWeight: 700 }}>`.
- [ ] ESLint allowlist не нарушен (`no-restricted-imports` разрешает `Box`, `Stack`, `Grid` из `@mui/material` barrel). - [x] ESLint allowlist не нарушен (`no-restricted-imports` разрешает `Box`, `Stack`, `Grid` из `@mui/material` barrel).
- [ ] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят. - [x] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят.
- [ ] CSS-классы, оставшиеся без потребителей, удалены из `styles.css` (но сохраняются градиенты hero-секций). - [x] 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`).

View File

@ -8,93 +8,94 @@
## Task 1: BrokerAccountLayout ## Task 1: BrokerAccountLayout
- [ ] Заменить `div.broker-account` на `<Box component="div">` - [x] Заменить `div.broker-account` на `<Box component="div">`
- [ ] Перевести `header` + `h1` на `<Box>` + `<Heading level={1}>` - [x] Перевести `header` + `h1` на `<Box>` + `<Heading level={1}>`
- [ ] Перевести `nav` на `<Box component="nav">` + `<NavLink>` (оставить className callback) - [x] Перевести `nav` на `<Box component="nav">` + `<NavLink>` (Box component={NavLink} с `aria-current="page"`)
- [ ] Перевести `div.broker-account__workspace` на grid `<Box>` - [x] Перевести `div.broker-account__workspace` на grid `<Box>`
- [ ] Перевести `div.broker-account__content` на `<Box>` - [x] Перевести `div.broker-account__content` на `<Box>`
- [ ] Удалить соответствующие CSS-selectors из `styles.css` - [x] Удалить соответствующие CSS-selectors из `styles.css`
## Task 2: BrokerAccountOverviewPage + BrokerSummary + BrokerAssetCards ## Task 2: BrokerAccountOverviewPage + BrokerSummary + BrokerAssetCards
- [ ] Перевести `BrokerSummary` на `<Box>` + `<Text>` - [x] Перевести `BrokerSummary` на `<Box>` + `<Text>`
- [ ] Перевести eyebrow/label на `<Text variant="label" tone="secondary">` - [x] Перевести eyebrow/label на `<Text variant="label" tone="secondary">`
- [ ] Перевести `<strong>` значений на `<Text sx={{ fontWeight: 700 }}>` - [x] Перевести `<strong>` значений на `<Box component="span" sx={{ fontWeight: 700 }}>`
- [ ] Перевести `<ul>` cash на `<Box component="ul">` - [x] Перевести `<ul>` cash на `<Box component="ul">`
- [ ] Перевести `BrokerAssetCards` на `<Box>` + `<Link>` + `<Text>` - [x] Перевести `BrokerAssetCards` на `<Box>` + `<Link>` + `<Text>`
- [ ] Перевести `BrokerAccountOverviewPage` container на `<Box component="div">` - [x] Перевести `BrokerAccountOverviewPage` container на `<Box component="div">`
- [ ] Оставить hero-gradient без изменений (Out of scope) - [x] Перевести `<p role="alert">` на `<Text component="p" tone="negative">`
- [x] Оставить hero-gradient без изменений (Out of scope)
## Task 3: BrokerOverviewSkeleton ## Task 3: BrokerOverviewSkeleton
- [ ] Заменить импорт `SkeletonBlock` на DS `<Skeleton>` - [x] Заменить импорт `SkeletonBlock` на DS `<Skeleton>`
- [ ] Перевести контейнеры на `<Box>` - [x] Перевести контейнеры на `<Box>`
- [ ] Сделать circular skeleton для портфельной карточки - [x] Сделать circular skeleton для портфельной карточки
- [ ] Сделать text skeleton для текстовых placeholder'ей - [x] Сделать text skeleton для текстовых placeholder'ей
## Task 4: BrokerAllocationChart (wrap only) ## Task 4: BrokerAllocationChart (wrap only)
- [ ] Перевести `<figure>` на `<Box component="figure">` - [x] Перевести `<figure>` на `<Box component="figure">`
- [ ] Перевести `<figcaption>` на `<Box component="figcaption">` - [x] Перевести `<figcaption>` на `<Box component="figcaption">`
- [ ] Перевести `<p>` пустого состояния на `<Text tone="muted">` - [x] Перевести `<p>` пустого состояния на `<Text tone="muted">`
- [ ] Перевести `<ul>` / `<li>` легенды на `<Box component="ul">` + `<Box component="li">` - [x] Перевести `<ul>` / `<li>` легенды на `<Box component="ul">` + `<Box component="li">`
- [ ] Перевести swatch на `<Box>` с inline-цветом в `sx` - [x] Перевести swatch на `<Box>` с inline-цветом в `sx`
- [ ] Перевести тексты легенды на `<Text>` / `<Box component="span">` - [x] Перевести тексты легенды на `<Box component="span">`
- [ ] Перевести negative-список на `<Box component="ul">` - [x] Перевести negative-список на `<Box component="ul">`
- [ ] Удалить CSS для `.broker-allocation*` (кроме inline SVG) в `styles.css` - [x] Удалить CSS для `.broker-allocation*` в `styles.css`
## Task 5: BrokerPositionTable + PositionTicker ## Task 5: BrokerPositionTable + PositionTicker
- [ ] Перевести вспомогательные стили на `<Box sx={{...}}>` - [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
- [ ] Перевести `<h2>` на `<Heading level={2}>` с кастомной `sx` - [x] Перевести `<h2>` на `<Heading level={2}>`
- [ ] Перевести alert-тексты на `<Text tone="negative">` - [x] Перевести alert-тексты на `<Text tone="negative">`
- [ ] Перевести пагинационные `<button>` на `<Button variant="secondary">` - [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
- [ ] Перевести `loading-overlay` на `<Box>` - [x] Перевести `loading-overlay` на `<Box>`
- [ ] Перевести `PositionTicker` ссылки на `<Link>` + `<Text sx={{ fontWeight: 700 }}>` - [x] Перевести `PositionTicker` ссылки на `<Box component="span" sx={{ fontWeight: 700 }}>`
- [ ] Сохранить `loading-spinner` (глобальный класс, пока без замены) - [x] Сохранить `loading-spinner` (глобальный класс, пока без замены)
## Task 6: TableSkeleton (DS Skeleton) ## Task 6: TableSkeleton (DS Skeleton)
- [ ] Убрать импорт `SkeletonBlock` - [x] Убрать импорт `SkeletonBlock`
- [ ] Заменить на `<Skeleton height={12} width={col.width} shape="text" />` - [x] Заменить на `<Skeleton height={12} width={col.width} shape="text" />`
- [ ] Сделать прогон тестов, убедиться что нет предупреждений
## Task 7: BrokerOperationsTable ## Task 7: BrokerOperationsTable
- [ ] Перевести вспомогательные стили на `<Box sx={{...}}>` - [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
- [ ] Перевести `<h2>` на `<Heading level={2}>` - [x] Перевести `<h2>` на `<Heading level={2}>`
- [ ] Перевести `headerAction` container на `<Box>` - [x] Перевести `headerAction` container на `<Box>`
- [ ] Перевести пагинационные `<button>` на `<Button variant="secondary">` - [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
- [ ] Перевести alert-тексты на `<Text tone="negative">` - [x] Перевести alert-тексты на `<Text tone="negative">`
- [ ] Перевести empty-тексты на `<Text tone="muted">` - [x] Перевести empty-тексты на `<Text tone="muted">`
- [ ] Перевести `<label>` + `<select>` на `<Box component="label">` + `<select>` - [x] Перевести `<label>` + `<select>` на `<Box component="label">` + `<select>`
- [ ] Оставить `loading-spinner` для overlay (глобальный класс) - [x] Оставить `loading-spinner` для overlay (глобальный класс)
## Task 8: BrokerPositionsPage + BrokerOperationsPage ## Task 8: BrokerPositionsPage + BrokerOperationsPage
- [ ] Перевести `BrokerPositionsPage` на `<Box component="section">` + `<Heading>` + `<Text>` - [x] Перевести `BrokerPositionsPage` на `<Box component="section">` + `<Heading>` + `<Text>`
- [ ] Перевести `BrokerOperationsPage` на `<Box>` + `<Heading>` + `<Box component="label">` + `<select>` - [x] Перевести `BrokerOperationsPage` на `<Box>` + `<Heading>` + `<Box component="label">` + `<select>`
- [ ] Убедиться, что `role="alert"` сохраняется через `<Text tone="negative">` - [x] Убедиться, что `role="alert"` сохраняется через `<Text tone="negative">`
## Task 9: SkeletonBlock удаление ## Task 9: SkeletonBlock удаление
- [ ] Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx` - [x] Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx`
- [ ] Удалить экспорт из `apps/frontend/src/shared/ui/index.ts` - [x] Удалить экспорт из `apps/frontend/src/shared/ui/index.ts`
- [ ] Проверить grep по `SkeletonBlock` — 0 совпадений - [x] Проверить grep по `SkeletonBlock` — 0 совпадений
## Task 10: Очистка `styles.css` ## Task 10: Очистка `styles.css`
- [ ] Удалить блоки `.broker-account__*` - [x] Удалить блоки `.broker-account__*`
- [ ] Удалить блоки `.broker-overview__*` (кроме SVG-связанных) - [x] Удалить блоки `.broker-overview__*`
- [ ] Удалить блоки `.broker-allocation__*` (кроме SVG-внутренних, если остались) - [x] Удалить блоки `.broker-allocation__*`
- [ ] Удалить блоки `.broker-operations__toolbar*` - [x] Удалить блоки `.broker-operations__toolbar*`
- [ ] Оставить `.loading-spinner` и `:root` переменные - [x] Удалить CSS-переменные `--broker-overview-*`
- [x] Оставить `.loading-spinner`, `.skeleton`, `:root` общие переменные
## Task 11: Финальная проверка ## Task 11: Финальная проверка
- [ ] `npm run test:frontend` - [x] `npm run test:frontend` — 111 tests PASS
- [ ] `npm run lint -w apps/frontend` - [x] `npm run lint -w apps/frontend` — без ошибок
- [ ] `npm run build:frontend` - [x] `npm run build:frontend` — без TypeScript/Vite ошибок
- [ ] grep по `broker-(account|overview|allocation)` в TSX — 0 совпадений - [x] grep по `broker-(account|overview|allocation)` в TSX — 0 совпадений
- [ ] grep по `SkeletonBlock` в TSX — 0 совпадений - [x] grep по `SkeletonBlock` в TSX — 0 совпадений
- [ ] `npm run format` для форматирования - [x] CSS: 274 → 99 строк

View File

@ -2,7 +2,7 @@
## Status ## Status
Draft Implemented — 2026-06-21.
## Goal ## Goal

View File

@ -2,7 +2,7 @@
## Status ## Status
Approved — утверждена пользователем 2026-06-21. Implemented — 2026-06-21.
## Goal ## Goal