diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
index c5ae061..fdd5398 100644
--- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
+++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
@@ -1,18 +1,11 @@
import { Text } from '@moex-vibe/design-system'
-import { Box } from '@mui/material'
-import { Link } from '@tanstack/react-router'
-import { useBrokerOperations } from '@/entities/broker-operation'
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
-import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
-import { BrokerEventsOverview } from '@/widgets/broker-events-overview'
-import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
-import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview'
+import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext()
- const operations = useBrokerOperations(accountId, { limit: 5 })
- if (portfolio.isLoading) return
+ if (portfolio.isLoading) return
if (portfolio.error || !portfolio.data) {
return (
@@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() {
)
}
- return (
-
-
-
-
-
- {operations.error ? (
-
- Не удалось загрузить последние операции
-
- ) : (
- Вся история
- }
- emptyMessage="Операций с начала текущего года нет"
- isLoading={operations.isLoading}
- isFetching={operations.isFetching}
- page={operations.data}
- />
- )}
-
- )
+ return
}
diff --git a/apps/frontend/src/widgets/broker-dashboard/index.ts b/apps/frontend/src/widgets/broker-dashboard/index.ts
new file mode 100644
index 0000000..2f37b13
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/index.ts
@@ -0,0 +1,2 @@
+export { BrokerDashboard } from './ui/BrokerDashboard'
+export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts
new file mode 100644
index 0000000..0733692
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts
@@ -0,0 +1,67 @@
+import dayjs from 'dayjs'
+
+export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
+export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const
+
+export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number]
+export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number]
+export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all'
+
+export type DashboardFilterState = {
+ from: string
+ to: string
+ types: T[]
+}
+
+export function defaultEventsFilters(): DashboardFilterState {
+ const now = dayjs()
+ return {
+ from: now.subtract(7, 'day').format('YYYY-MM-DD'),
+ to: now.add(7, 'day').format('YYYY-MM-DD'),
+ types: [...DASHBOARD_EVENT_TYPES],
+ }
+}
+
+export function defaultIncomeFilters(): DashboardFilterState {
+ const now = dayjs()
+ return {
+ from: now.startOf('year').format('YYYY-MM-DD'),
+ to: now.format('YYYY-MM-DD'),
+ types: [...DASHBOARD_INCOME_TYPES],
+ }
+}
+
+export function applyDatePreset(
+ filters: DashboardFilterState,
+ preset: DashboardDatePreset,
+): DashboardFilterState {
+ const now = dayjs()
+ if (preset === 'all') return { ...filters, from: '', to: now.format('YYYY-MM-DD') }
+ const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
+ const unit = preset === '1y' ? 'year' : 'day'
+ return {
+ ...filters,
+ from: now.subtract(amount, unit).format('YYYY-MM-DD'),
+ to: now.format('YYYY-MM-DD'),
+ }
+}
+
+export function validateDashboardFilters(
+ filters: DashboardFilterState,
+): string {
+ if (filters.types.length === 0) return 'Выберите хотя бы один тип'
+ if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) {
+ return 'Дата окончания не может быть раньше даты начала'
+ }
+ return ''
+}
+
+export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string {
+ const operationTypes = new Set()
+ if (types.includes('dividend')) {
+ operationTypes.add('OPERATION_TYPE_DIVIDEND')
+ operationTypes.add('OPERATION_TYPE_DIV_EXT')
+ }
+ if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
+ return [...operationTypes].join(',')
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts
new file mode 100644
index 0000000..dbf7f2e
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts
@@ -0,0 +1,24 @@
+import type { BrokerEventItem } from '@/shared/api'
+
+export function dashboardValue(value: string | null | undefined): string {
+ return value && value.trim().length > 0 ? value : '—'
+}
+
+export function eventTypeLabel(type: BrokerEventItem['type']): string {
+ switch (type) {
+ case 'dividend':
+ return 'Дивиденд'
+ case 'coupon':
+ return 'Купон'
+ case 'maturity':
+ return 'Погашение'
+ case 'offer':
+ return 'Оферта'
+ }
+}
+
+export function eventStatusLabel(event: BrokerEventItem): string {
+ if (event.source === 'actual') return 'Поступило'
+ if (event.type === 'offer') return 'Оферта'
+ return 'Прогноз'
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts
new file mode 100644
index 0000000..7c33f73
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import type { BrokerOperation } from '@/shared/api'
+import {
+ getDashboardIncomeRows,
+ isDashboardIncomeOperation,
+ sumDashboardIncome,
+} from './dashboardIncome'
+
+function operation(type: string, value: number | null): BrokerOperation {
+ return {
+ cursor: null,
+ accountId: 'acc-1',
+ id: type,
+ parentOperationId: null,
+ date: '2026-06-01T00:00:00.000Z',
+ type,
+ category: 'income',
+ description: null,
+ name: 'Apple Inc.',
+ state: 'OPERATION_STATE_EXECUTED',
+ instrumentUid: null,
+ figi: null,
+ ticker: 'AAPL',
+ classCode: null,
+ instrumentType: 'share',
+ payment:
+ value === null ? null : { currency: 'RUB', units: String(Math.trunc(value)), nano: 0, value },
+ price: null,
+ commission: null,
+ yield: null,
+ accruedInt: null,
+ quantity: null,
+ quantityDone: null,
+ }
+}
+
+describe('dashboardIncome', () => {
+ it('detects dividend and coupon operation types', () => {
+ expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIVIDEND', 10))).toBe(true)
+ expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIV_EXT', 10))).toBe(true)
+ expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_COUPON', 10))).toBe(true)
+ expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_BUY', -10))).toBe(false)
+ })
+
+ it('returns only displayable income rows with payments', () => {
+ const rows = getDashboardIncomeRows([
+ operation('OPERATION_TYPE_DIVIDEND', 10),
+ operation('OPERATION_TYPE_BUY', -10),
+ operation('OPERATION_TYPE_COUPON', 5),
+ ])
+
+ expect(rows).toHaveLength(2)
+ expect(rows[0].typeLabel).toBe('Дивиденд')
+ expect(rows[0].amount.value).toBe(10)
+ expect(rows[1].typeLabel).toBe('Купон')
+ expect(rows[1].amount.value).toBe(5)
+ })
+
+ it('sums displayed income rows by currency', () => {
+ const rows = getDashboardIncomeRows([
+ operation('OPERATION_TYPE_DIVIDEND', 10),
+ operation('OPERATION_TYPE_COUPON', 2.5),
+ ])
+
+ expect(sumDashboardIncome(rows)).toEqual({ currency: 'RUB', value: 12.5 })
+ })
+
+ it('returns null for empty rows sum', () => {
+ expect(sumDashboardIncome([])).toBeNull()
+ })
+
+ it('returns empty array for empty input', () => {
+ expect(getDashboardIncomeRows([])).toEqual([])
+ })
+
+ it('returns empty array when no income operations present', () => {
+ const rows = getDashboardIncomeRows([
+ operation('OPERATION_TYPE_BUY', -10),
+ operation('OPERATION_TYPE_SELL', 20),
+ ])
+
+ expect(rows).toEqual([])
+ })
+})
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts
new file mode 100644
index 0000000..bdd8485
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts
@@ -0,0 +1,49 @@
+import type { BrokerMoney, BrokerOperation } from '@/shared/api'
+
+const INCOME_TYPES = new Set([
+ 'OPERATION_TYPE_DIVIDEND',
+ 'OPERATION_TYPE_DIV_EXT',
+ 'OPERATION_TYPE_COUPON',
+])
+
+export type DashboardIncomeRow = {
+ id: string
+ date: string | null
+ instrument: string
+ typeLabel: 'Дивиденд' | 'Купон'
+ amount: BrokerMoney
+}
+
+export function isDashboardIncomeOperation(operation: BrokerOperation): boolean {
+ return INCOME_TYPES.has(operation.type) && operation.payment !== null
+}
+
+function typeLabel(type: string): DashboardIncomeRow['typeLabel'] {
+ return type === 'OPERATION_TYPE_COUPON' ? 'Купон' : 'Дивиденд'
+}
+
+export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
+ return operations.filter(isDashboardIncomeOperation).map((operation) => ({
+ id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
+ date: typeof operation.date === 'string' ? operation.date : null,
+ instrument:
+ typeof operation.ticker === 'string'
+ ? operation.ticker
+ : typeof operation.name === 'string'
+ ? operation.name
+ : typeof operation.description === 'string'
+ ? operation.description
+ : '—',
+ typeLabel: typeLabel(operation.type),
+ amount: operation.payment!,
+ }))
+}
+
+export function sumDashboardIncome(
+ rows: DashboardIncomeRow[],
+): { currency: string; value: number } | null {
+ if (rows.length === 0) return null
+ const currency = rows[0].amount.currency
+ const value = rows.reduce((sum, row) => sum + row.amount.value, 0)
+ return { currency, value }
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx
new file mode 100644
index 0000000..a800466
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import type { BrokerPortfolio } from '@/shared/api'
+import { BrokerDashboard } from './BrokerDashboard'
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/entities/broker-analytics', () => ({
+ useBrokerAnalytics: () => ({
+ data: {
+ totalDeposits: 1000,
+ totalWithdrawn: 100,
+ netInvested: 900,
+ totalDividends: 25,
+ totalCoupons: 15,
+ totalReceived: 40,
+ totalReturnPercent: 4.44,
+ currency: 'RUB',
+ },
+ isLoading: false,
+ isError: false,
+ }),
+}))
+
+vi.mock('@/entities/broker-event', () => ({
+ useBrokerEvents: () => ({
+ data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
+ isLoading: false,
+ isError: false,
+ }),
+}))
+
+vi.mock('@/entities/broker-operation', () => ({
+ useBrokerOperations: () => ({
+ data: {
+ accountId: 'acc-1',
+ items: [],
+ nextCursor: null,
+ hasNext: false,
+ asOf: '2026-06-26T00:00:00.000Z',
+ },
+ isLoading: false,
+ isError: false,
+ }),
+}))
+
+vi.mock('@/widgets/broker-allocation-chart', () => ({
+ BrokerAllocationChart: () => allocation chart
,
+}))
+
+const portfolio: BrokerPortfolio = {
+ account: {
+ id: 'acc-1',
+ name: 'Основной счёт',
+ type: 'brokerage',
+ status: 'open',
+ openedAt: null,
+ accessLevel: null,
+ },
+ positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 },
+ totals: {
+ shares: null,
+ bonds: null,
+ etf: null,
+ currencies: null,
+ futures: null,
+ options: null,
+ structuredProducts: null,
+ dfa: null,
+ portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
+ },
+ yields: { expectedPercent: null, daily: null, dailyPercent: null },
+ cash: [],
+ blockedCash: [],
+ asOf: '2026-06-26T00:00:00.000Z',
+}
+
+describe('BrokerDashboard', () => {
+ it('renders the dashboard sections', () => {
+ render()
+
+ expect(screen.getByText('Основной счёт')).toBeInTheDocument()
+ expect(screen.getByText('События')).toBeInTheDocument()
+ expect(screen.getByText('Доходы')).toBeInTheDocument()
+ expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
+ expect(screen.getByText('Аллокация')).toBeInTheDocument()
+ expect(screen.getByText('allocation chart')).toBeInTheDocument()
+ })
+})
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx
new file mode 100644
index 0000000..a9b430b
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx
@@ -0,0 +1,91 @@
+import { Box } from '@mui/material'
+import { useState } from 'react'
+import { useBrokerAnalytics } from '@/entities/broker-analytics'
+import { useBrokerEvents } from '@/entities/broker-event'
+import { useBrokerOperations } from '@/entities/broker-operation'
+import type { BrokerPortfolio } from '@/shared/api'
+import { useCursorPagination } from '@/shared/lib/useCursorPagination'
+import {
+ defaultEventsFilters,
+ defaultIncomeFilters,
+ incomeTypesToOperationTypes,
+} from '../lib/dashboardFilters'
+import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
+import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
+import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
+import { BrokerDashboardHero } from './BrokerDashboardHero'
+import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
+
+export function BrokerDashboard({
+ accountId,
+ portfolio,
+}: {
+ accountId: string
+ portfolio: BrokerPortfolio
+}) {
+ const [eventFilters, _setEventFilters] = useState(defaultEventsFilters)
+ const [eventPage, setEventPage] = useState(1)
+ const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters)
+ const incomePagination = useCursorPagination()
+ const analytics = useBrokerAnalytics(accountId)
+ const events = useBrokerEvents(accountId, {
+ from: eventFilters.from,
+ to: eventFilters.to,
+ types: eventFilters.types.join(','),
+ })
+ const eventItems = events.data?.items ?? []
+ const eventPageSize = 10
+ const eventPageItems = eventItems.slice(
+ (eventPage - 1) * eventPageSize,
+ eventPage * eventPageSize,
+ )
+ const operations = useBrokerOperations(accountId, {
+ from: incomeFilters.from,
+ to: incomeFilters.to,
+ operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
+ cursor: incomePagination.cursor,
+ limit: 10,
+ })
+
+ const nextCursor: string | undefined = operations.data?.nextCursor
+ ? (operations.data.nextCursor as unknown as string)
+ : undefined
+
+ return (
+
+
+
+ 1}
+ canGoForward={eventPage * eventPageSize < eventItems.length}
+ onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
+ onNextPage={() => setEventPage((page) => page + 1)}
+ />
+ 1}
+ canGoForward={operations.data?.hasNext ?? false}
+ onPreviousPage={incomePagination.handlePrevious}
+ onNextPage={() => incomePagination.handleNext(nextCursor)}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx
new file mode 100644
index 0000000..a97c376
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx
@@ -0,0 +1,19 @@
+import { Text } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+import type { BrokerPortfolio } from '@/shared/api'
+import { formatBrokerMoney } from '@/shared/lib/formatters'
+import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
+import { BrokerDashboardCard } from './BrokerDashboardCard'
+
+export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
+ return (
+ {formatBrokerMoney(portfolio.totals.portfolio)}}
+ >
+
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx
new file mode 100644
index 0000000..3ace3ad
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx
@@ -0,0 +1,45 @@
+import { Metric, Text } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+import type { BrokerAnalytics } from '@/shared/api'
+import { BrokerDashboardCard } from './BrokerDashboardCard'
+
+function amount(value: number, currency: string): string {
+ return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`
+}
+
+export function BrokerDashboardAnalyticsCard({
+ data,
+ isLoading,
+ isError,
+}: {
+ data: BrokerAnalytics | undefined
+ isLoading: boolean
+ isError: boolean
+}) {
+ return (
+
+ {isError ? (
+ Не удалось загрузить аналитику
+ ) : isLoading ? (
+ Загрузка аналитики…
+ ) : !data ? (
+ Нет данных для аналитики
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx
new file mode 100644
index 0000000..b572c95
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx
@@ -0,0 +1,50 @@
+import { Heading } from '@moex-vibe/design-system'
+import { Box, type SxProps, type Theme } from '@mui/material'
+import type { ReactNode } from 'react'
+
+type BrokerDashboardCardProps = {
+ title: string
+ action?: ReactNode
+ filters?: ReactNode
+ children: ReactNode
+ sx?: SxProps
+}
+
+export function BrokerDashboardCard({
+ title,
+ action,
+ filters,
+ children,
+ sx,
+}: BrokerDashboardCardProps) {
+ return (
+
+
+ {title}
+ {action}
+
+ {filters && {filters}}
+ {children}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx
new file mode 100644
index 0000000..5d99e99
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx
@@ -0,0 +1,131 @@
+import { Button, Chip, Text } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+import { Link } from '@tanstack/react-router'
+import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
+import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
+import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
+import { BrokerDashboardCard } from './BrokerDashboardCard'
+
+type BrokerDashboardEventsCardProps = {
+ accountId: string
+ data: BrokerEventsData | undefined
+ isLoading: boolean
+ isError: boolean
+ page: number
+ onPreviousPage: () => void
+ onNextPage: () => void
+ canGoBack: boolean
+ canGoForward: boolean
+}
+
+function eventAmount(event: BrokerEventItem): string {
+ const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount
+ if (amount === null || amount === undefined) return '\u2014'
+ const prefix = event.source === 'actual' ? '+' : '~'
+ return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}`
+}
+
+export function BrokerDashboardEventsCard({
+ accountId,
+ data,
+ isLoading,
+ isError,
+ page,
+ onPreviousPage,
+ onNextPage,
+ canGoBack,
+ canGoForward,
+}: BrokerDashboardEventsCardProps) {
+ const events = data?.items ?? []
+
+ return (
+ Все события}
+ >
+
+
+
+
+
+
+ {isError ? (
+ Не удалось загрузить события
+ ) : isLoading ? (
+ Загрузка событий…
+ ) : events.length === 0 ? (
+ В ближайшем периоде событий нет
+ ) : (
+
+
+
+
+ {events.map((event) => (
+
+
+ {formatBrokerDate(event.eventDate)}
+
+
+ {event.ticker ?? event.name ?? '\u2014'}
+
+
+ {eventTypeLabel(event.type)}
+
+
+ {eventAmount(event)}
+
+
+ {eventStatusLabel(event)}
+
+
+ ))}
+
+
+
+
+
+
+ {page}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx
new file mode 100644
index 0000000..db43dee
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx
@@ -0,0 +1,55 @@
+import { Metric, Text } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
+import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
+
+type BrokerDashboardHeroProps = {
+ portfolio: BrokerPortfolio
+ analytics: BrokerAnalytics | undefined
+}
+
+function percentValue(value: unknown): string {
+ return typeof value === 'number' ? formatBrokerPercent(value) : '—'
+}
+
+export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
+ const accountName = portfolio.account.name || 'Брокерский счёт'
+ const totalReceived = analytics
+ ? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
+ : '—'
+ const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
+
+ return (
+
+
+
+ Инвестиционный дашборд
+
+
+ {accountName}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx
new file mode 100644
index 0000000..0f4682c
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx
@@ -0,0 +1,117 @@
+import { Button, Chip, Text } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+import { Link } from '@tanstack/react-router'
+import type { BrokerOperationsPage } from '@/shared/api'
+import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
+import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
+import { BrokerDashboardCard } from './BrokerDashboardCard'
+
+type BrokerDashboardIncomeCardProps = {
+ accountId: string
+ page: BrokerOperationsPage | undefined
+ isLoading: boolean
+ isError: boolean
+ pageNumber: number
+ canGoBack: boolean
+ canGoForward: boolean
+ onPreviousPage: () => void
+ onNextPage: () => void
+}
+
+export function BrokerDashboardIncomeCard({
+ accountId,
+ page,
+ isLoading,
+ isError,
+ pageNumber,
+ canGoBack,
+ canGoForward,
+ onPreviousPage,
+ onNextPage,
+}: BrokerDashboardIncomeCardProps) {
+ const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
+ const total = sumDashboardIncome(rows)
+
+ return (
+ Все операции}
+ >
+
+
+
+
+ {isError ? (
+ Не удалось загрузить доходные операции
+ ) : isLoading ? (
+ Загрузка доходов…
+ ) : rows.length === 0 ? (
+ Дивидендов и купонов в последних операциях нет
+ ) : (
+
+
+
+
+ {rows.map((row) => (
+
+
+ {formatBrokerDate(row.date)}
+
+
+ {row.instrument}
+
+
+ {row.typeLabel}
+
+
+ +{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)}
+
+
+ ))}
+
+
+
+
+ Показано: {rows.length} · Итого:{' '}
+ {total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
+
+
+
+
+ {pageNumber}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx
new file mode 100644
index 0000000..edaf886
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx
@@ -0,0 +1,18 @@
+import { Skeleton } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+
+export function BrokerDashboardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md
index 7d09bc0..e6a19e2 100644
--- a/docs/features/broker-dashboard-redesign/tasks.md
+++ b/docs/features/broker-dashboard-redesign/tasks.md
@@ -16,32 +16,32 @@
## Реализация
-- [ ] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
-- [ ] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
-- [ ] Добавить dashboard presentation helpers для fallback, event labels и statuses.
-- [ ] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
-- [ ] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
-- [ ] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
-- [ ] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
+- [x] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
+- [x] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
+- [x] Добавить dashboard presentation helpers для fallback, event labels и statuses.
+- [x] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
+- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
+- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
+- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`.
-- [ ] Добавить локальную пагинацию по 10 событий в `События`.
+- [x] Добавить локальную пагинацию по 10 событий в `События`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`.
-- [ ] Добавить cursor-пагинацию по 10 операций в `Доходы`.
-- [ ] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
-- [ ] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
-- [ ] Добавить `BrokerDashboardSkeleton`.
-- [ ] Добавить `BrokerDashboard` как top-level composition widget.
-- [ ] Заменить текущий vertical overview в `BrokerAccountOverviewPage` на `BrokerDashboard`.
-- [ ] Добавить unit/component tests для helpers и dashboard composition.
+- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
+- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
+- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
+- [x] Добавить `BrokerDashboardSkeleton`.
+- [x] Добавить `BrokerDashboard` как top-level composition widget.
+- [x] Заменить текущий vertical overview в `BrokerAccountOverviewPage` на `BrokerDashboard`.
+- [x] Добавить unit/component tests для helpers и dashboard composition.
- [ ] Проверить desktop layout `/broker/2084014113`.
- [ ] Проверить mobile layout `/broker/2084014113`.
## Definition of Done
-- [ ] `rtk npm run test:frontend` проходит.
-- [ ] `rtk npm run test:design-system` проходит, если DS изменялась.
-- [ ] `rtk npm run lint -w apps/frontend` проходит.
-- [ ] `rtk npm run build:frontend` проходит.
-- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
-- [ ] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
-- [ ] `graphify update .` выполнен после code changes.
+- [x] `rtk npm run test:frontend` проходит (31 files, 133 tests).
+- [x] `rtk npm run test:design-system` проходит (28 files, 160 tests).
+- [x] `rtk npm run lint -w apps/frontend` проходит.
+- [x] `rtk npm run build:frontend` проходит.
+- [x] Dashboard соответствует acceptance criteria из `spec.md`.
+- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
+- [x] `graphify update .` выполнен после code changes.