feat: migrate BrokerOperationsTable to design system

This commit is contained in:
Sergey Krylov 2026-06-21 18:59:58 +03:00
parent da656330b3
commit e044b64de4

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}>
<Box component="span" sx={{ fontWeight: 700 }}>
{ticker} {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> </Box>
</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}>
Дата Дата
</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>
); );