codex/frontend-overview-redesign #50

Merged
ksv741 merged 8 commits from codex/frontend-overview-redesign into main 2026-06-27 21:18:34 +03:00
6 changed files with 401 additions and 128 deletions
Showing only changes of commit 8b24d8b82b - Show all commits

View File

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

View File

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

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

View File

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

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

View File

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