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:
Sergey Krylov 2026-06-27 20:16:40 +03:00
parent d21e2411b8
commit 8b24d8b82b
6 changed files with 401 additions and 128 deletions

View File

@ -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,28 +45,68 @@ 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={{
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 ? ( {portfolio.data ? (
<Box sx={{ textAlign: 'right', flexShrink: 0 }}> <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 <Box
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
fontSize: 16, fontSize: 22,
lineHeight: 1.25, lineHeight: 1,
color: moneyToneToColor( color:
moneyTone(portfolio.data.yields.expectedPercent as number | null), moneyTone(portfolio.data.yields.expectedPercent as number | null) ===
), 'positive'
? 'success.main'
: 'error.main',
}} }}
> >
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)} {formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
</Box> </Box>
<Box sx={{ fontSize: 12, lineHeight: 1.5, color: 'text.secondary' }}> <Box
За день: {formatBrokerMoney(portfolio.data.yields.daily)} 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>
) : null} ) : null}
</Box> </Box>
</Box>
<Box <Box
component="nav" component="nav"

View File

@ -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}
/> />

View File

@ -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,6 +67,69 @@ export function BrokerDashboardAnalyticsCard({
) : !data ? ( ) : !data ? (
<Text tone="muted">Нет данных для аналитики</Text> <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 <Box
sx={{ sx={{
display: 'grid', display: 'grid',
@ -86,33 +146,59 @@ export function BrokerDashboardAnalyticsCard({
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: '1px solid',
borderColor: tone === 'negative' ? 'error.light' : 'divider', borderColor:
bgcolor:
tone === 'positive' tone === 'positive'
? 'rgba(46, 125, 50, 0.06)' ? 'rgba(46, 125, 50, 0.3)'
: tone === 'negative' : tone === 'negative'
? 'rgba(211, 47, 47, 0.06)' ? 'rgba(211, 47, 47, 0.3)'
: 'grey.50', : '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, p: 1.5,
minHeight: 72,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}} }}
> >
<Metric
label={label}
value={
<Box <Box
component="span" sx={{
color: 'text.secondary',
fontSize: 12,
fontWeight: 700,
lineHeight: 1.25,
}}
>
{label}
</Box>
<Box
data-testid={testId} data-testid={testId}
data-tone={tone} data-tone={tone}
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }} 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)} {analyticsDisplay(field, value, data.currency)}
</Box> </Box>
}
/>
</Box> </Box>
) )
})} })}
</Box> </Box>
</Box>
)} )}
</BrokerDashboardCard> </BrokerDashboardCard>
) )

View File

@ -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 ? (

View File

@ -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>

View File

@ -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,14 +86,26 @@ export function BrokerPortfolioHistoryCard({
return ( return (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Text variant="label" tone="secondary"> <Text variant="label" tone="secondary">
Текущая стоимость:{' '} Текущая стоимость
<Box component="span" sx={{ fontWeight: 700, color: 'text.primary' }}> </Text>
<Box sx={{ fontWeight: 700, fontSize: 18, lineHeight: 1.25 }}>
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'} {portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
</Box> </Box>
</Text> </Box>
</Box>
<Box sx={{ mx: -2.25, width: 'calc(100% + 36px)' }}>
<svg <svg
viewBox="0 0 300 120" viewBox="0 0 300 90"
style={{ width: '100%', height: 'auto', display: 'block' }} style={{ width: '100%', height: 'auto', display: 'block' }}
aria-label="График изменения стоимости портфеля" aria-label="График изменения стоимости портфеля"
> >
@ -112,24 +124,44 @@ export function BrokerPortfolioHistoryCard({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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) => { {points.map((point, i) => {
const plotWidth = 300 - 10 - 10 const plotWidth = 300 - 10 - 10
const px = 10 + (i / (points.length - 1)) * plotWidth const leftPct = ((10 + (i / (points.length - 1)) * plotWidth) / 300) * 100
const isFirst = i === 0
const isLast = i === points.length - 1
return ( return (
<text <Box
key={point.month} key={point.month}
x={px} component="span"
y={115} sx={{
textAnchor="middle" position: 'absolute',
fill="text.secondary" bottom: 0,
fontSize={9} left: isLast ? `${leftPct}%` : `${leftPct}%`,
style={{ fill: 'var(--color-text-secondary, #64748b)' }} transform: isFirst
? 'none'
: isLast
? 'translateX(-100%)'
: 'translateX(-50%)',
}}
> >
{point.label} {point.label}
</text> </Box>
) )
})} })}
</svg> </Box>
</Box>
</Box> </Box>
) )
})()} })()}