fix(frontend): align badges, pagination, chart labels with reference
- Replace MUI Chip in events card with custom Badge matching reference colors (blue coupon, green dividend, amber maturity, red tax/fee) - Add working pagination (useState, 7 per page, ←/→ handlers) - Increase events API limit to 100 for multi-page support - Fix chart month labels: left-align first, right-align last to prevent overflow beyond container edges
This commit is contained in:
parent
d21e2411b8
commit
8b24d8b82b
@ -6,7 +6,7 @@ import { createContext, type ReactNode } from 'react'
|
||||
import { useBrokerPortfolio } from '@/entities/broker-account'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
||||
import { moneyTone, moneyToneToColor } from '@/widgets/broker-dashboard/lib/dashboardVisual'
|
||||
import { moneyTone } from '@/widgets/broker-dashboard/lib/dashboardVisual'
|
||||
|
||||
const baseLinkStyle: React.CSSProperties = {
|
||||
padding: '10px 14px',
|
||||
@ -45,27 +45,67 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||
{portfolio.data ? (
|
||||
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
lineHeight: 1.25,
|
||||
color: moneyToneToColor(
|
||||
moneyTone(portfolio.data.yields.expectedPercent as number | null),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 3,
|
||||
minHeight: 72,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 2.5,
|
||||
minWidth: 0,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
level={1}
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 700, lineHeight: 1.05 }}
|
||||
>
|
||||
{portfolio.data?.account.name || 'Брокерский счёт'}
|
||||
</Heading>
|
||||
{portfolio.data ? (
|
||||
<Box sx={{ flexShrink: 0, display: 'grid', gap: 0.25, pb: 0.25 }}>
|
||||
<Box sx={{ fontSize: 11, fontWeight: 700, color: 'text.secondary', lineHeight: 1 }}>
|
||||
Доходность
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 22,
|
||||
lineHeight: 1,
|
||||
color:
|
||||
moneyTone(portfolio.data.yields.expectedPercent as number | null) ===
|
||||
'positive'
|
||||
? 'success.main'
|
||||
: 'error.main',
|
||||
}}
|
||||
>
|
||||
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.25,
|
||||
color:
|
||||
moneyTone(portfolio.data.yields.daily as number | null) === 'positive'
|
||||
? 'success.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
За день:{' '}
|
||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||
{formatBrokerMoney(portfolio.data.yields.daily)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ fontSize: 12, lineHeight: 1.5, color: 'text.secondary' }}>
|
||||
За день: {formatBrokerMoney(portfolio.data.yields.daily)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
||||
@ -20,7 +20,7 @@ export function BrokerDashboard({
|
||||
|
||||
const eventsOps = useBrokerOperations(
|
||||
accountId,
|
||||
{ categories: 'income,tax,fee', limit: 7 },
|
||||
{ categories: 'income,tax,fee', limit: 100 },
|
||||
{ enabled: true },
|
||||
)
|
||||
|
||||
@ -34,6 +34,7 @@ export function BrokerDashboard({
|
||||
/>
|
||||
<BrokerDashboardAnalyticsCard
|
||||
data={analytics.data}
|
||||
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
||||
isLoading={analytics.isLoading}
|
||||
isError={analytics.isError}
|
||||
/>
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
import { Metric, Text } from '@moex-vibe/design-system'
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BrokerAnalytics } from '@/shared/api'
|
||||
import {
|
||||
formatDashboardCurrency,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
} from '../lib/dashboardVisual'
|
||||
import type { BrokerAnalytics, BrokerMoney } from '@/shared/api'
|
||||
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
|
||||
type AnalyticsField =
|
||||
@ -54,10 +49,12 @@ function analyticsDisplay(field: AnalyticsField, value: number, currency: string
|
||||
|
||||
export function BrokerDashboardAnalyticsCard({
|
||||
data,
|
||||
portfolioValue,
|
||||
isLoading,
|
||||
isError,
|
||||
}: {
|
||||
data: BrokerAnalytics | undefined
|
||||
portfolioValue?: BrokerMoney
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}) {
|
||||
@ -70,48 +67,137 @@ export function BrokerDashboardAnalyticsCard({
|
||||
) : !data ? (
|
||||
<Text tone="muted">Нет данных для аналитики</Text>
|
||||
) : (
|
||||
<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 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
|
||||
key={field}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: tone === 'negative' ? 'error.light' : 'divider',
|
||||
bgcolor:
|
||||
tone === 'positive'
|
||||
? 'rgba(46, 125, 50, 0.06)'
|
||||
: tone === 'negative'
|
||||
? 'rgba(211, 47, 47, 0.06)'
|
||||
: 'grey.50',
|
||||
p: 1.5,
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'success.main',
|
||||
}}
|
||||
>
|
||||
<Metric
|
||||
label={label}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
data-testid={testId}
|
||||
data-tone={tone}
|
||||
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
|
||||
>
|
||||
{analyticsDisplay(field, value, data.currency)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@ -47,7 +47,11 @@ export function BrokerDashboardCard({
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Heading level={2} size="section">
|
||||
<Heading
|
||||
level={2}
|
||||
size="section"
|
||||
style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.18 }}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{badge ? (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { BrokerOperation } from '@/shared/api'
|
||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
|
||||
@ -38,6 +39,18 @@ const TD_SX_INSTRUMENT = {
|
||||
minWidth: 180,
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 7
|
||||
|
||||
const BADGE_STYLES: Record<string, { bg: string; borderColor: string; color: string }> = {
|
||||
coupon: { bg: '#e8f0ff', borderColor: '#cddcff', color: '#2563eb' },
|
||||
dividend: { bg: '#e5f2ea', borderColor: '#bcdcc9', color: '#176747' },
|
||||
maturity: { bg: '#fff5dc', borderColor: '#f0d898', color: '#8d6400' },
|
||||
tax: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
||||
fee: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
||||
}
|
||||
|
||||
type BadgeInfo = { label: string; badgeKey: string }
|
||||
|
||||
type BrokerDashboardEventsCardProps = {
|
||||
accountId: string
|
||||
data: BrokerOperation[] | undefined
|
||||
@ -45,29 +58,94 @@ type BrokerDashboardEventsCardProps = {
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
function getOperationBadge(operation: BrokerOperation): { label: string } {
|
||||
if (operation.category === 'tax') return { label: 'Налог' }
|
||||
if (operation.category === 'fee') return { label: 'Комиссия' }
|
||||
function getOperationBadge(operation: BrokerOperation): BadgeInfo {
|
||||
if (operation.category === 'tax') return { label: 'Налог', badgeKey: 'tax' }
|
||||
if (operation.category === 'fee') return { label: 'Комиссия', badgeKey: 'fee' }
|
||||
if (operation.category === 'income') {
|
||||
if (
|
||||
operation.type === 'OPERATION_TYPE_DIVIDEND' ||
|
||||
operation.type === 'OPERATION_TYPE_DIV_EXT'
|
||||
) {
|
||||
return { label: 'Дивиденд' }
|
||||
return { label: 'Дивиденд', badgeKey: 'dividend' }
|
||||
}
|
||||
if (operation.type === 'OPERATION_TYPE_COUPON') {
|
||||
return { label: 'Купон' }
|
||||
return { label: 'Купон', badgeKey: 'coupon' }
|
||||
}
|
||||
if (
|
||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
|
||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
|
||||
operation.type === 'OPERATION_TYPE_MATURITY'
|
||||
) {
|
||||
return { label: 'Погашение' }
|
||||
return { label: 'Погашение', badgeKey: 'maturity' }
|
||||
}
|
||||
return { label: 'Доход' }
|
||||
return { label: 'Доход', badgeKey: '' }
|
||||
}
|
||||
return { label: 'Прочее' }
|
||||
return { label: 'Прочее', badgeKey: '' }
|
||||
}
|
||||
|
||||
function Badge({ label, badgeKey }: { label: string; badgeKey: string }) {
|
||||
const style = BADGE_STYLES[badgeKey]
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 24,
|
||||
px: 1.25,
|
||||
borderRadius: '999px',
|
||||
border: 1,
|
||||
borderColor: style?.borderColor ?? 'divider',
|
||||
bgcolor: style?.bg ?? 'transparent',
|
||||
color: style?.color ?? 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function PagerButton({
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minWidth: 34,
|
||||
height: 32,
|
||||
border: 1,
|
||||
borderColor: disabled ? 'divider' : 'grey.300',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
color: disabled ? 'text.disabled' : 'primary.main',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
px: 1,
|
||||
'&:hover:not(:disabled)': { bgcolor: 'grey.100' },
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrokerDashboardEventsCard({
|
||||
@ -76,7 +154,13 @@ export function BrokerDashboardEventsCard({
|
||||
isLoading,
|
||||
isError,
|
||||
}: BrokerDashboardEventsCardProps) {
|
||||
const [page, setPage] = useState(0)
|
||||
const operations = data ?? []
|
||||
const totalPages = Math.max(1, Math.ceil(operations.length / ITEMS_PER_PAGE))
|
||||
const safePage = Math.min(page, totalPages - 1)
|
||||
const pageStart = safePage * ITEMS_PER_PAGE
|
||||
const pageEnd = pageStart + ITEMS_PER_PAGE
|
||||
const pageItems = operations.slice(pageStart, pageEnd)
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
@ -113,10 +197,10 @@ export function BrokerDashboardEventsCard({
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{operations.slice(0, 7).map((op) => {
|
||||
{pageItems.map((op) => {
|
||||
const boldText = op.name ?? op.description ?? op.ticker ?? '—'
|
||||
const grayText = op.name ? (op.ticker ?? null) : null
|
||||
const { label } = getOperationBadge(op)
|
||||
const { label, badgeKey } = getOperationBadge(op)
|
||||
const amountValue = op.payment?.value ?? 0
|
||||
const amountTone = amountValue >= 0 ? 'positive' : 'negative'
|
||||
const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
|
||||
@ -152,7 +236,7 @@ export function BrokerDashboardEventsCard({
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
<Chip label={label} tone="neutral" selected={false} />
|
||||
<Badge label={label} badgeKey={badgeKey} />
|
||||
</Box>
|
||||
<Box
|
||||
component="td"
|
||||
@ -171,6 +255,32 @@ export function BrokerDashboardEventsCard({
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mt: 1.5,
|
||||
minHeight: 34,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PagerButton
|
||||
disabled={safePage === 0}
|
||||
label="←"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<Box component="span" sx={{ fontSize: 13, color: 'text.secondary' }}>
|
||||
{safePage + 1}
|
||||
</Box>
|
||||
<PagerButton
|
||||
disabled={safePage >= totalPages - 1}
|
||||
label="→"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
|
||||
@ -19,11 +19,11 @@ function generateChartPath(points: { value: BrokerMoney }[]): { line: string; ar
|
||||
const padding = range * 0.1
|
||||
|
||||
const viewWidth = 300
|
||||
const viewHeight = 120
|
||||
const viewHeight = 90
|
||||
const padLeft = 10
|
||||
const padRight = 10
|
||||
const padTop = 5
|
||||
const padBottom = 20
|
||||
const padBottom = 5
|
||||
const plotWidth = viewWidth - padLeft - padRight
|
||||
const plotHeight = viewHeight - padTop - padBottom
|
||||
const yMin = min - padding
|
||||
@ -48,7 +48,7 @@ function generateChartPath(points: { value: BrokerMoney }[]): { line: string; ar
|
||||
lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}`
|
||||
}
|
||||
|
||||
const bottom = viewHeight - padBottom
|
||||
const bottom = viewHeight
|
||||
const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z`
|
||||
|
||||
return { line: lineCmd, area: areaCmd }
|
||||
@ -65,7 +65,7 @@ export function BrokerPortfolioHistoryCard({
|
||||
title="Стоимость портфеля за 6 месяцев"
|
||||
sx={{
|
||||
borderColor: 'success.light',
|
||||
bgcolor: 'rgba(46, 125, 50, 0.06)',
|
||||
background: 'linear-gradient(135deg, rgba(229, 242, 234, 0.96), rgba(255, 255, 255, 0.86))',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
@ -74,7 +74,7 @@ export function BrokerPortfolioHistoryCard({
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Skeleton height={200} shape="rounded" />
|
||||
return <Skeleton height={180} shape="rounded" />
|
||||
}
|
||||
|
||||
const points = data.points
|
||||
@ -86,50 +86,82 @@ export function BrokerPortfolioHistoryCard({
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Text variant="label" tone="secondary">
|
||||
Текущая стоимость:{' '}
|
||||
<Box component="span" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
||||
</Box>
|
||||
</Text>
|
||||
<svg
|
||||
viewBox="0 0 300 120"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
aria-label="График изменения стоимости портфеля"
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#areaGradient)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{points.map((point, i) => {
|
||||
const plotWidth = 300 - 10 - 10
|
||||
const px = 10 + (i / (points.length - 1)) * plotWidth
|
||||
return (
|
||||
<text
|
||||
key={point.month}
|
||||
x={px}
|
||||
y={115}
|
||||
textAnchor="middle"
|
||||
fill="text.secondary"
|
||||
fontSize={9}
|
||||
style={{ fill: 'var(--color-text-secondary, #64748b)' }}
|
||||
>
|
||||
{point.label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Text variant="label" tone="secondary">
|
||||
Текущая стоимость
|
||||
</Text>
|
||||
<Box sx={{ fontWeight: 700, fontSize: 18, lineHeight: 1.25 }}>
|
||||
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mx: -2.25, width: 'calc(100% + 36px)' }}>
|
||||
<svg
|
||||
viewBox="0 0 300 90"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
aria-label="График изменения стоимости портфеля"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#areaGradient)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mx: -2.25,
|
||||
width: 'calc(100% + 36px)',
|
||||
height: 20,
|
||||
color: 'text.disabled',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{points.map((point, i) => {
|
||||
const plotWidth = 300 - 10 - 10
|
||||
const leftPct = ((10 + (i / (points.length - 1)) * plotWidth) / 300) * 100
|
||||
const isFirst = i === 0
|
||||
const isLast = i === points.length - 1
|
||||
return (
|
||||
<Box
|
||||
key={point.month}
|
||||
component="span"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: isLast ? `${leftPct}%` : `${leftPct}%`,
|
||||
transform: isFirst
|
||||
? 'none'
|
||||
: isLast
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{point.label}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user