moex-vibe/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx
Sergey Krylov d8070209ed feat(frontend): add structural skeletons matching reference loading states
- 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)
2026-06-27 20:47:04 +03:00

259 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}