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 { useBrokerPortfolio } from '@/entities/broker-account'
|
||||||
import type { BrokerPortfolio } from '@/shared/api'
|
import type { BrokerPortfolio } from '@/shared/api'
|
||||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
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 = {
|
const baseLinkStyle: React.CSSProperties = {
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
@ -45,27 +45,67 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<Box
|
||||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
sx={{
|
||||||
{portfolio.data ? (
|
display: 'flex',
|
||||||
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
|
alignItems: 'flex-end',
|
||||||
<Box
|
justifyContent: 'space-between',
|
||||||
sx={{
|
gap: 3,
|
||||||
fontWeight: 700,
|
minHeight: 72,
|
||||||
fontSize: 16,
|
}}
|
||||||
lineHeight: 1.25,
|
>
|
||||||
color: moneyToneToColor(
|
<Box
|
||||||
moneyTone(portfolio.data.yields.expectedPercent as number | null),
|
sx={{
|
||||||
),
|
display: 'flex',
|
||||||
}}
|
alignItems: 'flex-end',
|
||||||
>
|
gap: 2.5,
|
||||||
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
|
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>
|
||||||
<Box sx={{ fontSize: 12, lineHeight: 1.5, color: 'text.secondary' }}>
|
) : null}
|
||||||
За день: {formatBrokerMoney(portfolio.data.yields.daily)}
|
</Box>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function BrokerDashboard({
|
|||||||
|
|
||||||
const eventsOps = useBrokerOperations(
|
const eventsOps = useBrokerOperations(
|
||||||
accountId,
|
accountId,
|
||||||
{ categories: 'income,tax,fee', limit: 7 },
|
{ categories: 'income,tax,fee', limit: 100 },
|
||||||
{ enabled: true },
|
{ enabled: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -34,6 +34,7 @@ export function BrokerDashboard({
|
|||||||
/>
|
/>
|
||||||
<BrokerDashboardAnalyticsCard
|
<BrokerDashboardAnalyticsCard
|
||||||
data={analytics.data}
|
data={analytics.data}
|
||||||
|
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
||||||
isLoading={analytics.isLoading}
|
isLoading={analytics.isLoading}
|
||||||
isError={analytics.isError}
|
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 { Box } from '@mui/material'
|
||||||
import type { BrokerAnalytics } from '@/shared/api'
|
import type { BrokerAnalytics, BrokerMoney } from '@/shared/api'
|
||||||
import {
|
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
||||||
formatDashboardCurrency,
|
|
||||||
type MoneyTone,
|
|
||||||
moneyTone,
|
|
||||||
moneyToneToColor,
|
|
||||||
} from '../lib/dashboardVisual'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||||
|
|
||||||
type AnalyticsField =
|
type AnalyticsField =
|
||||||
@ -54,10 +49,12 @@ function analyticsDisplay(field: AnalyticsField, value: number, currency: string
|
|||||||
|
|
||||||
export function BrokerDashboardAnalyticsCard({
|
export function BrokerDashboardAnalyticsCard({
|
||||||
data,
|
data,
|
||||||
|
portfolioValue,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
}: {
|
}: {
|
||||||
data: BrokerAnalytics | undefined
|
data: BrokerAnalytics | undefined
|
||||||
|
portfolioValue?: BrokerMoney
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isError: boolean
|
isError: boolean
|
||||||
}) {
|
}) {
|
||||||
@ -70,48 +67,137 @@ export function BrokerDashboardAnalyticsCard({
|
|||||||
) : !data ? (
|
) : !data ? (
|
||||||
<Text tone="muted">Нет данных для аналитики</Text>
|
<Text tone="muted">Нет данных для аналитики</Text>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
sx={{
|
<Box
|
||||||
display: 'grid',
|
sx={{
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
display: 'grid',
|
||||||
gap: 2,
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||||||
}}
|
gap: 2,
|
||||||
>
|
}}
|
||||||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
>
|
||||||
const value = data[field]
|
<Box
|
||||||
const tone = analyticsTone(field, value)
|
sx={{
|
||||||
return (
|
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
|
<Box
|
||||||
key={field}
|
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
mt: 0.5,
|
||||||
border: '1px solid',
|
fontWeight: 700,
|
||||||
borderColor: tone === 'negative' ? 'error.light' : 'divider',
|
fontSize: 18,
|
||||||
bgcolor:
|
whiteSpace: 'nowrap',
|
||||||
tone === 'positive'
|
color: 'success.main',
|
||||||
? 'rgba(46, 125, 50, 0.06)'
|
|
||||||
: tone === 'negative'
|
|
||||||
? 'rgba(211, 47, 47, 0.06)'
|
|
||||||
: 'grey.50',
|
|
||||||
p: 1.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Metric
|
{formatDashboardCurrency({
|
||||||
label={label}
|
currency: data.currency,
|
||||||
value={
|
value: data.totalDividends + data.totalCoupons,
|
||||||
<Box
|
})}
|
||||||
component="span"
|
|
||||||
data-testid={testId}
|
|
||||||
data-tone={tone}
|
|
||||||
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
{analyticsDisplay(field, value, data.currency)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</BrokerDashboardCard>
|
</BrokerDashboardCard>
|
||||||
|
|||||||
@ -47,7 +47,11 @@ export function BrokerDashboardCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<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}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
{badge ? (
|
{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 { Box } from '@mui/material'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
import type { BrokerOperation } from '@/shared/api'
|
import type { BrokerOperation } from '@/shared/api'
|
||||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
import { formatBrokerDate } from '@/shared/lib/formatters'
|
||||||
import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
|
import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
|
||||||
@ -38,6 +39,18 @@ const TD_SX_INSTRUMENT = {
|
|||||||
minWidth: 180,
|
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 = {
|
type BrokerDashboardEventsCardProps = {
|
||||||
accountId: string
|
accountId: string
|
||||||
data: BrokerOperation[] | undefined
|
data: BrokerOperation[] | undefined
|
||||||
@ -45,29 +58,94 @@ type BrokerDashboardEventsCardProps = {
|
|||||||
isError: boolean
|
isError: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOperationBadge(operation: BrokerOperation): { label: string } {
|
function getOperationBadge(operation: BrokerOperation): BadgeInfo {
|
||||||
if (operation.category === 'tax') return { label: 'Налог' }
|
if (operation.category === 'tax') return { label: 'Налог', badgeKey: 'tax' }
|
||||||
if (operation.category === 'fee') return { label: 'Комиссия' }
|
if (operation.category === 'fee') return { label: 'Комиссия', badgeKey: 'fee' }
|
||||||
if (operation.category === 'income') {
|
if (operation.category === 'income') {
|
||||||
if (
|
if (
|
||||||
operation.type === 'OPERATION_TYPE_DIVIDEND' ||
|
operation.type === 'OPERATION_TYPE_DIVIDEND' ||
|
||||||
operation.type === 'OPERATION_TYPE_DIV_EXT'
|
operation.type === 'OPERATION_TYPE_DIV_EXT'
|
||||||
) {
|
) {
|
||||||
return { label: 'Дивиденд' }
|
return { label: 'Дивиденд', badgeKey: 'dividend' }
|
||||||
}
|
}
|
||||||
if (operation.type === 'OPERATION_TYPE_COUPON') {
|
if (operation.type === 'OPERATION_TYPE_COUPON') {
|
||||||
return { label: 'Купон' }
|
return { label: 'Купон', badgeKey: 'coupon' }
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
|
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
|
||||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
|
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
|
||||||
operation.type === 'OPERATION_TYPE_MATURITY'
|
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({
|
export function BrokerDashboardEventsCard({
|
||||||
@ -76,7 +154,13 @@ export function BrokerDashboardEventsCard({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
}: BrokerDashboardEventsCardProps) {
|
}: BrokerDashboardEventsCardProps) {
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
const operations = data ?? []
|
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 (
|
return (
|
||||||
<BrokerDashboardCard
|
<BrokerDashboardCard
|
||||||
@ -113,10 +197,10 @@ export function BrokerDashboardEventsCard({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="tbody">
|
<Box component="tbody">
|
||||||
{operations.slice(0, 7).map((op) => {
|
{pageItems.map((op) => {
|
||||||
const boldText = op.name ?? op.description ?? op.ticker ?? '—'
|
const boldText = op.name ?? op.description ?? op.ticker ?? '—'
|
||||||
const grayText = op.name ? (op.ticker ?? null) : null
|
const grayText = op.name ? (op.ticker ?? null) : null
|
||||||
const { label } = getOperationBadge(op)
|
const { label, badgeKey } = getOperationBadge(op)
|
||||||
const amountValue = op.payment?.value ?? 0
|
const amountValue = op.payment?.value ?? 0
|
||||||
const amountTone = amountValue >= 0 ? 'positive' : 'negative'
|
const amountTone = amountValue >= 0 ? 'positive' : 'negative'
|
||||||
const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
|
const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
|
||||||
@ -152,7 +236,7 @@ export function BrokerDashboardEventsCard({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={TD_SX_LEFT}>
|
<Box component="td" sx={TD_SX_LEFT}>
|
||||||
<Chip label={label} tone="neutral" selected={false} />
|
<Badge label={label} badgeKey={badgeKey} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component="td"
|
component="td"
|
||||||
@ -171,6 +255,32 @@ export function BrokerDashboardEventsCard({
|
|||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</BrokerDashboardCard>
|
</BrokerDashboardCard>
|
||||||
|
|||||||
@ -19,11 +19,11 @@ function generateChartPath(points: { value: BrokerMoney }[]): { line: string; ar
|
|||||||
const padding = range * 0.1
|
const padding = range * 0.1
|
||||||
|
|
||||||
const viewWidth = 300
|
const viewWidth = 300
|
||||||
const viewHeight = 120
|
const viewHeight = 90
|
||||||
const padLeft = 10
|
const padLeft = 10
|
||||||
const padRight = 10
|
const padRight = 10
|
||||||
const padTop = 5
|
const padTop = 5
|
||||||
const padBottom = 20
|
const padBottom = 5
|
||||||
const plotWidth = viewWidth - padLeft - padRight
|
const plotWidth = viewWidth - padLeft - padRight
|
||||||
const plotHeight = viewHeight - padTop - padBottom
|
const plotHeight = viewHeight - padTop - padBottom
|
||||||
const yMin = min - padding
|
const yMin = min - padding
|
||||||
@ -48,7 +48,7 @@ function generateChartPath(points: { value: BrokerMoney }[]): { line: string; ar
|
|||||||
lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}`
|
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`
|
const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z`
|
||||||
|
|
||||||
return { line: lineCmd, area: areaCmd }
|
return { line: lineCmd, area: areaCmd }
|
||||||
@ -65,7 +65,7 @@ export function BrokerPortfolioHistoryCard({
|
|||||||
title="Стоимость портфеля за 6 месяцев"
|
title="Стоимость портфеля за 6 месяцев"
|
||||||
sx={{
|
sx={{
|
||||||
borderColor: 'success.light',
|
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) {
|
if (isLoading || !data) {
|
||||||
return <Skeleton height={200} shape="rounded" />
|
return <Skeleton height={180} shape="rounded" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const points = data.points
|
const points = data.points
|
||||||
@ -86,50 +86,82 @@ export function BrokerPortfolioHistoryCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
<Text variant="label" tone="secondary">
|
<Box
|
||||||
Текущая стоимость:{' '}
|
sx={{
|
||||||
<Box component="span" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
display: 'flex',
|
||||||
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
alignItems: 'flex-start',
|
||||||
</Box>
|
justifyContent: 'space-between',
|
||||||
</Text>
|
gap: 2,
|
||||||
<svg
|
}}
|
||||||
viewBox="0 0 300 120"
|
|
||||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
||||||
aria-label="График изменения стоимости портфеля"
|
|
||||||
>
|
>
|
||||||
<defs>
|
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
<Text variant="label" tone="secondary">
|
||||||
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
Текущая стоимость
|
||||||
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
</Text>
|
||||||
</linearGradient>
|
<Box sx={{ fontWeight: 700, fontSize: 18, lineHeight: 1.25 }}>
|
||||||
</defs>
|
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
||||||
<path d={area} fill="url(#areaGradient)" />
|
</Box>
|
||||||
<path
|
</Box>
|
||||||
d={line}
|
</Box>
|
||||||
fill="none"
|
<Box sx={{ mx: -2.25, width: 'calc(100% + 36px)' }}>
|
||||||
stroke="#2e7d32"
|
<svg
|
||||||
strokeWidth={2}
|
viewBox="0 0 300 90"
|
||||||
strokeLinecap="round"
|
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||||
strokeLinejoin="round"
|
aria-label="График изменения стоимости портфеля"
|
||||||
/>
|
>
|
||||||
{points.map((point, i) => {
|
<defs>
|
||||||
const plotWidth = 300 - 10 - 10
|
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||||
const px = 10 + (i / (points.length - 1)) * plotWidth
|
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
||||||
return (
|
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
||||||
<text
|
</linearGradient>
|
||||||
key={point.month}
|
</defs>
|
||||||
x={px}
|
<path d={area} fill="url(#areaGradient)" />
|
||||||
y={115}
|
<path
|
||||||
textAnchor="middle"
|
d={line}
|
||||||
fill="text.secondary"
|
fill="none"
|
||||||
fontSize={9}
|
stroke="#2e7d32"
|
||||||
style={{ fill: 'var(--color-text-secondary, #64748b)' }}
|
strokeWidth={2}
|
||||||
>
|
strokeLinecap="round"
|
||||||
{point.label}
|
strokeLinejoin="round"
|
||||||
</text>
|
/>
|
||||||
)
|
</svg>
|
||||||
})}
|
<Box
|
||||||
</svg>
|
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>
|
</Box>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user