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.