- Delete shared/api/responses.ts, move type re-exports to index.ts - Update all 55+ imports from shared/api/responses to shared/api - Delete stale client.test.ts (tested deleted client.ts) - Run biome checks and fix import ordering - Update tasks.md and plan.md to reflect actual approach
272 lines
8.2 KiB
TypeScript
272 lines
8.2 KiB
TypeScript
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
|
||
import { Box } from '@mui/material'
|
||
import { Link } from '@tanstack/react-router'
|
||
import type { ReactNode } from 'react'
|
||
import {
|
||
type BrokerOperationImpact,
|
||
getBrokerOperationImpact,
|
||
getBrokerOperationTypeLabel,
|
||
} from '@/entities/broker-operation'
|
||
import { getBrokerInstrumentPath } from '@/entities/broker-position'
|
||
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
|
||
import { formatBrokerSignedMoney } from '@/shared/lib/formatters'
|
||
import { TableSkeleton } from '@/shared/ui/TableSkeleton'
|
||
|
||
const tableSx = {
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: 14,
|
||
} as const
|
||
|
||
const thSx = {
|
||
borderBottom: '1px solid',
|
||
borderColor: 'divider',
|
||
color: 'text.secondary',
|
||
fontWeight: 600,
|
||
p: 1,
|
||
textAlign: 'left',
|
||
} as const
|
||
|
||
const tdSx = {
|
||
borderBottom: '1px solid',
|
||
borderColor: 'divider',
|
||
p: 1,
|
||
verticalAlign: 'top',
|
||
textAlign: 'left',
|
||
} as const
|
||
|
||
const tdSxRight = {
|
||
...tdSx,
|
||
textAlign: 'right',
|
||
} as const
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return '-'
|
||
|
||
return new Date(value).toLocaleString('ru-RU')
|
||
}
|
||
|
||
function operationTone(impact: BrokerOperationImpact) {
|
||
if (impact === 'adds') return 'positive' as const
|
||
if (impact === 'reduces') return 'negative' as const
|
||
return 'primary' as const
|
||
}
|
||
|
||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||
const ticker = operation.ticker || operation.description || '-'
|
||
const path = getBrokerInstrumentPath({
|
||
ticker: operation.ticker,
|
||
instrumentType: operation.instrumentType,
|
||
classCode: operation.classCode,
|
||
})
|
||
const name = operation.name || operation.description
|
||
|
||
if (!path && !name) return <Text>-</Text>
|
||
if (!path) return <Text>{name}</Text>
|
||
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>
|
||
|
||
return (
|
||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||
<Link to={path}>
|
||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||
{ticker}
|
||
</Box>
|
||
</Link>
|
||
{name && name !== ticker && (
|
||
<Text variant="caption" tone="secondary">
|
||
{name}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
export function BrokerOperationsTable({
|
||
title,
|
||
headerAction,
|
||
emptyMessage,
|
||
isLoading,
|
||
isFetching,
|
||
page,
|
||
pagination,
|
||
}: BrokerOperationsTableProps) {
|
||
const pageNumber = pagination?.pageNumber
|
||
const canGoBack = pagination?.canGoBack ?? false
|
||
const canGoForward = pagination?.canGoForward ?? false
|
||
const onPrevious = pagination?.onPrevious
|
||
const onNext = pagination?.onNext
|
||
const operations = page?.items ?? []
|
||
|
||
return (
|
||
<section aria-busy={isFetching}>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1.5,
|
||
justifyContent: 'space-between',
|
||
mb: 1.5,
|
||
}}
|
||
>
|
||
<Heading level={2}>{title}</Heading>
|
||
{headerAction}
|
||
{pagination && (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Button
|
||
variant="secondary"
|
||
size="small"
|
||
aria-label="Предыдущая страница"
|
||
onClick={onPrevious}
|
||
disabled={!canGoBack || isFetching}
|
||
>
|
||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
||
</Button>
|
||
<Box
|
||
sx={{
|
||
minWidth: 20,
|
||
textAlign: 'center',
|
||
color: 'text.secondary',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{pageNumber}
|
||
</Box>
|
||
<Button
|
||
variant="secondary"
|
||
size="small"
|
||
aria-label="Следующая страница"
|
||
onClick={onNext}
|
||
disabled={!canGoForward || isFetching}
|
||
>
|
||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
||
</Button>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{isLoading ? (
|
||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||
<Box component="table" sx={tableSx}>
|
||
<Box component="thead">
|
||
<Box component="tr">
|
||
<Box component="th" sx={thSx}>
|
||
Дата
|
||
</Box>
|
||
<Box component="th" sx={thSx}>
|
||
Тип
|
||
</Box>
|
||
<Box component="th" sx={thSx}>
|
||
Инструмент
|
||
</Box>
|
||
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
||
Сумма
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
<TableSkeleton
|
||
rows={5}
|
||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
) : operations.length === 0 && !isFetching ? (
|
||
<Text component="p" tone="muted">
|
||
{emptyMessage}
|
||
</Text>
|
||
) : (
|
||
<Box sx={{ position: 'relative' }}>
|
||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||
<Box component="table" sx={tableSx}>
|
||
<Box component="thead">
|
||
<Box component="tr">
|
||
<Box component="th" sx={thSx}>
|
||
Дата
|
||
</Box>
|
||
<Box component="th" sx={thSx}>
|
||
Тип
|
||
</Box>
|
||
<Box component="th" sx={thSx}>
|
||
Инструмент
|
||
</Box>
|
||
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
||
Сумма
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
<Box component="tbody">
|
||
{operations.map((operation) => {
|
||
const impact = getBrokerOperationImpact(operation)
|
||
return (
|
||
<Box component="tr" key={operation.cursor || operation.id}>
|
||
<Box component="td" sx={tdSx}>
|
||
{formatDate(operation.date)}
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
<Text>{getBrokerOperationTypeLabel(operation)}</Text>
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
<OperationInstrument operation={operation} />
|
||
</Box>
|
||
<Box
|
||
component="td"
|
||
sx={{
|
||
...tdSxRight,
|
||
color:
|
||
operationTone(impact) === 'positive'
|
||
? 'success.main'
|
||
: operationTone(impact) === 'negative'
|
||
? 'error.main'
|
||
: 'text.primary',
|
||
fontWeight: 700,
|
||
}}
|
||
align="right"
|
||
>
|
||
{formatBrokerSignedMoney(operation.payment)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
{isFetching && (
|
||
<Box
|
||
sx={{
|
||
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}…` : 'Обновление операций…'}
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
type BrokerOperationsTableProps = {
|
||
title: string
|
||
headerAction?: ReactNode
|
||
emptyMessage: string
|
||
isLoading: boolean
|
||
isFetching: boolean
|
||
page: BrokerOperationsPage | undefined
|
||
pagination?: {
|
||
pageNumber: number
|
||
canGoBack: boolean
|
||
canGoForward: boolean
|
||
onPrevious: () => void
|
||
onNext: () => void
|
||
}
|
||
}
|