- Portfolio history: chart-loading skeleton with shimmer area and dashed guide - Analytics: 2 summary cards + 6 detail grid cards with skel bars - Title/yield: 3 skeleton bars (label, value, daily) when portfolio loading - Events card already had skeleton table (no change needed) - Fix test expectation for operations limit (7 -> 100)
259 lines
8.6 KiB
TypeScript
259 lines
8.6 KiB
TypeScript
import { Skeleton, Text } from '@moex-vibe/design-system'
|
||
import { Box } from '@mui/material'
|
||
import type { BrokerAnalytics, BrokerMoney } from '@/shared/api'
|
||
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||
|
||
type AnalyticsField =
|
||
| 'totalDeposits'
|
||
| 'totalWithdrawn'
|
||
| 'totalDividends'
|
||
| 'totalCoupons'
|
||
| 'totalFees'
|
||
| 'totalTaxesPaid'
|
||
|
||
const ANALYTICS_METRICS: readonly {
|
||
field: AnalyticsField
|
||
label: string
|
||
testId: string
|
||
}[] = [
|
||
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
|
||
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
|
||
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
|
||
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
|
||
{ field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' },
|
||
{
|
||
field: 'totalTaxesPaid',
|
||
label: 'Уплаченные налоги',
|
||
testId: 'dashboard-analytics-totalTaxesPaid',
|
||
},
|
||
]
|
||
|
||
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
|
||
if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative'
|
||
if (field === 'totalWithdrawn') {
|
||
return value > 0 ? 'negative' : moneyTone(value)
|
||
}
|
||
return moneyTone(value)
|
||
}
|
||
|
||
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
|
||
if (field === 'totalFees' || field === 'totalTaxesPaid') {
|
||
const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) })
|
||
return `\u2212${formatted}`
|
||
}
|
||
const formatted = formatDashboardCurrency({ currency, value })
|
||
if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}`
|
||
return formatted
|
||
}
|
||
|
||
export function BrokerDashboardAnalyticsCard({
|
||
data,
|
||
portfolioValue,
|
||
isLoading,
|
||
isError,
|
||
}: {
|
||
data: BrokerAnalytics | undefined
|
||
portfolioValue?: BrokerMoney
|
||
isLoading: boolean
|
||
isError: boolean
|
||
}) {
|
||
return (
|
||
<BrokerDashboardCard title="Аналитика доходности">
|
||
{isError ? (
|
||
<Text tone="negative">Не удалось загрузить аналитику</Text>
|
||
) : isLoading ? (
|
||
<Box sx={{ display: 'grid', gap: 2 }} aria-hidden="true">
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{Array.from({ length: 2 }, (_, i) => (
|
||
<Box
|
||
key={i}
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
bgcolor: 'grey.50',
|
||
p: 1.5,
|
||
minHeight: 78,
|
||
display: 'grid',
|
||
gap: 1.25,
|
||
}}
|
||
>
|
||
<Skeleton height={12} width={92} shape="rounded" />
|
||
<Skeleton height={15} width={64} shape="rounded" />
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{Array.from({ length: 6 }, (_, i) => (
|
||
<Box
|
||
key={i}
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
bgcolor: 'grey.50',
|
||
p: 1.5,
|
||
minHeight: 72,
|
||
display: 'grid',
|
||
gap: 1.25,
|
||
}}
|
||
>
|
||
<Skeleton height={12} width={92} shape="rounded" />
|
||
<Skeleton height={15} width={64} shape="rounded" />
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
) : !data ? (
|
||
<Text tone="muted">Нет данных для аналитики</Text>
|
||
) : (
|
||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
bgcolor: 'rgba(255,255,255,0.5)',
|
||
p: 1.5,
|
||
minHeight: 72,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<Text variant="label" tone="secondary">
|
||
Стоимость портфеля
|
||
</Text>
|
||
<Box sx={{ mt: 0.5, fontWeight: 700, fontSize: 18, whiteSpace: 'nowrap' }}>
|
||
{formatDashboardCurrency({
|
||
currency: portfolioValue?.currency ?? data.currency,
|
||
value: portfolioValue?.value ?? 0,
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
bgcolor: 'rgba(255,255,255,0.5)',
|
||
p: 1.5,
|
||
minHeight: 72,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<Text variant="label" tone="secondary">
|
||
Всего доходов
|
||
</Text>
|
||
<Box
|
||
sx={{
|
||
mt: 0.5,
|
||
fontWeight: 700,
|
||
fontSize: 18,
|
||
whiteSpace: 'nowrap',
|
||
color: 'success.main',
|
||
}}
|
||
>
|
||
{formatDashboardCurrency({
|
||
currency: data.currency,
|
||
value: data.totalDividends + data.totalCoupons,
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
||
const value = data[field]
|
||
const tone = analyticsTone(field, value)
|
||
return (
|
||
<Box
|
||
key={field}
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid',
|
||
borderColor:
|
||
tone === 'positive'
|
||
? 'rgba(46, 125, 50, 0.3)'
|
||
: tone === 'negative'
|
||
? 'rgba(211, 47, 47, 0.3)'
|
||
: 'divider',
|
||
background:
|
||
tone === 'positive'
|
||
? 'linear-gradient(180deg, #f3faf5, #edf7f0)'
|
||
: tone === 'negative'
|
||
? 'linear-gradient(180deg, #fff7f5, #fff1ef)'
|
||
: undefined,
|
||
bgcolor: tone === 'positive' || tone === 'negative' ? undefined : 'grey.50',
|
||
p: 1.5,
|
||
minHeight: 72,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
color: 'text.secondary',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
lineHeight: 1.25,
|
||
}}
|
||
>
|
||
{label}
|
||
</Box>
|
||
<Box
|
||
data-testid={testId}
|
||
data-tone={tone}
|
||
sx={{
|
||
mt: 0.5,
|
||
fontWeight: 700,
|
||
fontSize: 15,
|
||
lineHeight: 1.25,
|
||
color:
|
||
tone === 'positive'
|
||
? 'success.main'
|
||
: tone === 'negative'
|
||
? 'error.main'
|
||
: 'text.disabled',
|
||
}}
|
||
>
|
||
{analyticsDisplay(field, value, data.currency)}
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</BrokerDashboardCard>
|
||
)
|
||
}
|