diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md new file mode 100644 index 0000000..0d000c9 --- /dev/null +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -0,0 +1,988 @@ +# Broker Dashboard Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild `/broker/:accountId` overview as a light-theme investment dashboard using current broker portfolio, events, operations, analytics, and allocation data. + +**Architecture:** Keep the route and FSD boundaries unchanged. Add focused dashboard widgets under `widgets/broker-dashboard` and keep domain data access in existing `entities/*` hooks. Extend `@moex-vibe/design-system` only for generic presentation gaps; do not move broker-specific logic into DS. + +**Tech Stack:** React 18, TanStack Router, TanStack Query, MUI via `@moex-vibe/design-system`, Vitest, Testing Library. + +--- + +## File Structure + +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — replace vertical overview composition with dashboard shell. +- Create: `apps/frontend/src/widgets/broker-dashboard/index.ts` — public API for dashboard widgets. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — top-level dashboard composition. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — KPI hero using portfolio and analytics data. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — local card pattern if DS `Card`/`Surface` remains too generic. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — compact events table for overview. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — compact income table from operations. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — analytics summary card. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — allocation card wrapping existing allocation chart logic. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — dashboard-shaped loading skeleton. +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — pure helpers for filtering and summarising dividend/coupon operations. +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — date preset, filter validation, operation type mapping, and pagination helpers. +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — local presentation helpers for KPI fallback and event status labels. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — composition tests. +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — income helper unit tests. +- Modify if needed: `packages/design-system/src/components/Surface/Surface.tsx` — allow `sx` passthrough for generic surface customization. +- Modify if needed: `packages/design-system/src/components/Chip/Chip.tsx` — support selected/active visual state without broker-specific concepts. +- Modify if DS changed: corresponding DS tests and stories. +- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — mark implementation tasks as completed while working. + +## Data Sources + +- Portfolio: `useBrokerAccountContext().portfolio` from `BrokerAccountLayout`. +- Events: `useBrokerEvents(accountId, { from, to, types })` from `entities/broker-event`. +- Operations: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })` from `entities/broker-operation`. +- Analytics: `useBrokerAnalytics(accountId)` from `entities/broker-analytics`. +- Allocation: `buildBrokerAllocation(portfolio)` from `entities/broker-position` or existing `BrokerAllocationChart` internals. + +## Tasks + +### Task 1: Income Helper + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` + +- [ ] **Step 1: Write failing tests for income filtering and totals** + +Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`: + +```ts +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', null), + ]) + + expect(rows).toHaveLength(1) + expect(rows[0].typeLabel).toBe('Дивиденд') + expect(rows[0].amount.value).toBe(10) + }) + + 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 }) + }) +}) +``` + +- [ ] **Step 2: Run the failing test** + +Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` + +Expected: FAIL because `dashboardIncome.ts` does not exist. + +- [ ] **Step 3: Implement income helpers** + +Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`: + +```ts +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 } +} +``` + +- [ ] **Step 4: Verify helper tests pass** + +Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` +Expected: PASS. + +### Task 2: Dashboard Formatting Helpers + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` + +- [ ] **Step 1: Add filter helpers** + +Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`: + +```ts +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(',') +} +``` + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` + +- [ ] **Step 2: Add event and KPI presentation helpers** + +Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`: + +```ts +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 'Прогноз' +} +``` + +- [ ] **Step 3: Use helpers from dashboard components in later tasks** + +Expected: no command yet; helpers are compiled by frontend tests in later tasks. + +### Task 3: Dashboard Card Pattern + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` + +- [ ] **Step 1: Create a local card component** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`: + +```tsx +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} + + ) +} +``` + +- [ ] **Step 2: Prefer local pattern over DS changes unless repeated needs emerge** + +Expected: no DS files changed in this task. + +### Task 4: Dashboard Hero + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` + +- [ ] **Step 1: Implement hero KPI block** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`: + +```tsx +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} + + + + + + ) +} +``` + +- [ ] **Step 2: Run type-aware frontend tests later through the composition test** + +Expected: no standalone command for this component. + +### Task 5: Events Card + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` + +- [ ] **Step 1: Implement compact events card** + +The dashboard composition owns applied filters and local pagination state; the card renders filter controls and calls callbacks supplied by `BrokerDashboard`: + +- draft filters: event types, `from`, `to`; +- applied filters: stored in `BrokerDashboard` and passed to `useBrokerEvents`; +- local page index over `data.items`, with page size 10; +- `Показать` applies draft filters and resets local page to 1; +- `Сбросить` restores `defaultEventsFilters()` and resets local page to 1; +- invalid filters show validation text and disable `Показать`. + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`: + +```tsx +import { 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 '—' + 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 ?? '—'} + + + {eventTypeLabel(event.type)} + + + {eventAmount(event)} + + + {eventStatusLabel(event)} + + + ))} + + + + + + {page} + + + + )} + + ) +} +``` + +- [ ] **Step 2: Verify later through dashboard composition test** + +Expected: card handles loading, error, empty and paginated states without throwing. During implementation, use DS `Button` instead of raw ` + {pageNumber} + + + + )} + + ) +} +``` + +- [ ] **Step 2: Confirm operations endpoint suffices** + +Run the app locally and inspect `/broker/:accountId`; if recent operations do not contain enough income rows, document this limitation in the PR notes rather than adding backend API in this feature. + +- [ ] **Step 3: Replace raw pagination buttons with DS Button during implementation** + +Expected: final implementation uses `Button` from `@moex-vibe/design-system` for pagination and apply/reset actions. + +### Task 7: Analytics and Allocation Cards + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` + +- [ ] **Step 1: Implement analytics card** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`: + +```tsx +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 ? ( + Нет данных для аналитики + ) : ( + + + + + + + + + )} + + ) +} +``` + +- [ ] **Step 2: Implement allocation card** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`: + +```tsx +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)} + + } + > + + + + + ) +} +``` + +- [ ] **Step 3: Run frontend tests after correcting imports/types** + +Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` +Expected: PASS after dashboard tests are added in Task 9. + +### Task 8: Dashboard Composition + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` +- Create: `apps/frontend/src/widgets/broker-dashboard/index.ts` +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` + +- [ ] **Step 1: Implement dashboard skeleton** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`: + +```tsx +import { Skeleton } from '@moex-vibe/design-system' +import { Box } from '@mui/material' + +export function BrokerDashboardSkeleton() { + return ( + + + + + + + + + + + + ) +} +``` + +- [ ] **Step 2: Implement dashboard composition** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`: + +```tsx +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, + }) + + 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(operations.data?.nextCursor)} + /> + + + + + + + ) +} +``` + +- [ ] **Step 3: Export dashboard public API** + +Create `apps/frontend/src/widgets/broker-dashboard/index.ts`: + +```ts +export { BrokerDashboard } from './ui/BrokerDashboard' +export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton' +``` + +- [ ] **Step 4: Replace overview page composition** + +Modify `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` to: + +```tsx +import { Text } from '@moex-vibe/design-system' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' +import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard' + +export function BrokerAccountOverviewPage() { + const { accountId, portfolio } = useBrokerAccountContext() + + if (portfolio.isLoading) return + if (portfolio.error || !portfolio.data) { + return ( + + Не удалось загрузить сводку счёта + + ) + } + + return +} +``` + +### Task 9: Dashboard Tests + +**Files:** +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- [ ] **Step 1: Mock entity hooks and write composition test** + +Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import type { BrokerPortfolio } from '@/shared/api' +import { BrokerDashboard } from './BrokerDashboard' + +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() + }) +}) +``` + +- [ ] **Step 2: Run dashboard tests** + +Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` +Expected: PASS. + +- [ ] **Step 3: Run all frontend tests** + +Run: `rtk npm run test:frontend` +Expected: PASS. + +### Task 10: Visual and Responsive Verification + +**Files:** +- Modify only if verification reveals spacing or mobile readability issues in files created above. + +- [ ] **Step 1: Run frontend dev server** + +Run: `rtk npm run dev:frontend` +Expected: Vite serves the app without compile errors. + +- [ ] **Step 2: Inspect desktop dashboard** + +Open `/broker/2084014113` and verify: hero appears first; events and income are side by side; analytics and allocation are below; detailed nav links remain available. + +- [ ] **Step 3: Inspect mobile dashboard** + +Resize to `390x844` and verify: the dashboard is one column; tables scroll horizontally only when necessary; navigation remains usable. + +### Task 11: Final Quality Gate + +**Files:** +- Modify: `docs/features/broker-dashboard-redesign/tasks.md` + +- [ ] **Step 1: Mark completed task checkboxes in docs** + +Update `docs/features/broker-dashboard-redesign/tasks.md` as tasks are completed. + +- [ ] **Step 2: Run affected package checks** + +Run: `rtk npm run test:frontend && rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend` +Expected: all commands pass. + +- [ ] **Step 3: Update graphify after code changes** + +Run: `graphify update .` +Expected: graph update completes or reports no meaningful changes. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md new file mode 100644 index 0000000..bdf837f --- /dev/null +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -0,0 +1,203 @@ +# Редизайн overview брокерского счёта в инвестиционный дашборд + +Дата: 2026-06-26 +Статус: согласовано к планированию +Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) + +## Контекст + +Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но +визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов, +ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен +представлен как более плотный инвестиционный дашборд: hero KPI, события и доходы рядом, аналитика и +аллокация в нижнем ряду. + +После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом, +используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты +`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. + +## Цель + +Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий, +полученных доходов, аналитики доходности и аллокации без перехода по вкладкам. + +## Пользовательский результат + +Пользователь может на `/broker/:accountId`: + +- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов; +- увидеть ближайшие события по счёту в компактной таблице; +- увидеть последние доходные операции по дивидендам и купонам и итог по ним; +- оценить вложения, полученные выплаты и доходность по существующей аналитике; +- увидеть структуру портфеля через donut-диаграмму и легенду; +- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика` + для drill-down сценариев. + +## Область изменений + +Фича относится только к frontend маршруту `/broker/:accountId` и связанным frontend-компонентам +брокерского overview. + +В область входят: + +- новая dashboard-композиция для `BrokerAccountOverviewPage`; +- переиспользуемые frontend-паттерны для dashboard card, KPI, compact table и filter chips; +- адаптация существующих брокерских widgets под новую компоновку, если это нужно для читаемости; +- точечные доработки `@moex-vibe/design-system`, если существующие компоненты блокируют корректное + использование текущей светлой DS-темы; +- тесты новой композиции и ключевых представлений. + +## Требования + +### 1. Общая композиция + +- `/broker/:accountId` остаётся overview выбранного брокерского счёта. +- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций. +- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и + `Доходы`. +- Ниже отображаются `Аналитика доходности` и `Аллокация`. +- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`, + `События`, `Аналитика`. +- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы, + аналитика, аллокация. + +### 2. Визуальный стиль + +- Используется текущая светлая DS-тема MoexVibe. +- Не добавляется dark mode и не переносится тёмная палитра `temp.html`. +- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на + `temp.html`. +- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы. +- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный + dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS. + +### 3. Hero KPI + +Hero показывает: + +- название счёта или fallback `Брокерский счёт`; +- стоимость портфеля из `portfolio.totals.portfolio`; +- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена, + иначе `portfolio.yields.expectedPercent`, если доступна; +- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно; +- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена; +- спокойный fallback `—` для недоступных значений. + +### 4. Блок `События` + +- Блок использует существующий источник `useBrokerEvents(accountId, query)`. +- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы + `dividend,coupon,maturity,offer`, как в существующей вкладке событий. +- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. +- Пользователь может выбрать несколько типов событий. +- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает + валидационное сообщение. +- Блок содержит фильтр периода `from` / `to`. +- Изменение черновых фильтров не запускает запрос до нажатия `Показать`. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. +- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся + обязанностью подробной вкладки `События`. +- В dashboard отображается не более 10 событий на странице. +- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам. +- Смена применённых фильтров возвращает пагинацию блока на первую страницу. +- Таблица показывает дату, инструмент, тип, сумму и статус. +- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`. +- Фактические поступления визуально отмечаются как `Поступило`. +- Прогнозные суммы помечаются как оценочные. +- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`. +- Ошибка загрузки событий не ломает остальной dashboard. + +### 5. Блок `Доходы` + +- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint + операций. +- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend + analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. +- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`. +- Пользователь может выбрать один или оба типа доходов. +- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает + валидационное сообщение. +- Блок содержит фильтр периода `from` / `to`. +- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе + операций. +- Изменение черновых фильтров не запускает запрос до нажатия `Показать`. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. +- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся + обязанностью подробных разделов. +- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10. +- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу. +- Таблица показывает дату, инструмент, тип и сумму. +- Блок показывает итог по отображаемым доходным операциям. +- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`. +- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения + backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API. + +### 6. Блок `Аналитика доходности` + +- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`. +- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность. +- При отсутствии analytics data блок показывает спокойное пустое состояние. +- Ошибка analytics не ломает остальные блоки. + +### 7. Блок `Аллокация` + +- Используется существующий расчёт `buildBrokerAllocation` и текущая `BrokerAllocationChart` либо её + dashboard-адаптация. +- Блок показывает итоговую стоимость портфеля и ненулевые секторы с названием, суммой и процентом. +- Отрицательные значения отображаются текстом, а не сектором диаграммы. +- Информация остаётся понятной без различения цветов. + +### 8. Загрузка, ошибки и пустые состояния + +- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы. +- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного + контекста. +- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки. +- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения. +- Недоступные отдельные значения отображаются как `—`, не подменяются нулём. + +## Ограничения + +- Backend остаётся единственным клиентом T-Bank и MOEX. +- В первой версии не добавляется график истории стоимости портфеля. +- В первой версии не добавляется backend storage/API для снапшотов стоимости портфеля. +- Тёмная тема и переключатель темы не входят в область фичи. +- Не меняются правила расчёта доходности, событий, операций и аллокации. +- Не удаляются существующие detailed вкладки счёта. +- Не изменяется URL-структура `/broker/:accountId/*`. + +## Backlog + +Идея `Broker portfolio value history` вынесена в `docs/inbox.md` и `docs/roadmap.md`: хранить снапшоты +стоимости брокерского счёта и позже заменить отсутствие графика полноценным блоком `Стоимость портфеля`. + +## Acceptance Criteria + +- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`, + `Аналитика доходности`, `Аллокация`. +- На desktop блоки `События` и `Доходы` расположены рядом. +- На мобильном viewport dashboard читаемо перестраивается в одну колонку. +- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего + доходов или fallback. +- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. +- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и + локальную пагинацию по 10 событий. +- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. +- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и + cursor-пагинацию по 10 операций. +- Блок `Аналитика доходности` показывает данные существующего analytics endpoint. +- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля. +- Ошибка одного вторичного блока не скрывает остальные блоки dashboard. +- Существующие detailed вкладки остаются доступны из навигации счёта. +- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него. +- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`. + +## Вне области фичи + +- график стоимости портфеля по датам; +- новые исторические снапшоты стоимости; +- dark mode; +- изменение backend-расчётов доходности; +- налоговая аналитика; +- экспорт dashboard; +- объединение нескольких брокерских счетов в один dashboard. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md new file mode 100644 index 0000000..7d09bc0 --- /dev/null +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -0,0 +1,47 @@ +# Редизайн overview брокерского счёта в инвестиционный дашборд — задачи + +Дата: 2026-06-26 +Статус: готово к реализации + +## Документация и pre-flight + +- [x] Выбрать scope: `/broker/:accountId` становится единым дашбордом. +- [x] Выбрать визуальный стиль: структура `temp.html`, текущая светлая DS-тема. +- [x] Исключить график истории стоимости из первой версии. +- [x] Добавить backlog-задачу на историю стоимости брокерского портфеля. +- [x] Создать ветку `codex/broker-dashboard-redesign`. +- [x] Запустить baseline: `rtk npm run test:backend && rtk npm run test:frontend && rtk npm run test:design-system`. +- [x] Написать `spec.md`. +- [x] Написать `plan.md`. + +## Реализация + +- [ ] Добавить 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`. +- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`. +- [ ] Добавить локальную пагинацию по 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. +- [ ] Проверить 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. diff --git a/docs/inbox.md b/docs/inbox.md index 6657334..632d5bb 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома ## Frontend-платформа +### Добавить историю стоимости брокерского портфеля + +- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по + датам. +- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для + пропущенных дней. +- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость + портфеля`, когда данные истории будут доступны. +- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только + уже доступные данные портфеля, событий, операций и аналитики. + ### Перейти к Feature-Sliced Design - Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`, diff --git a/docs/roadmap.md b/docs/roadmap.md index cbc2c0c..5e5fb35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -130,4 +130,6 @@ Roadmap отражает порядок продуктовой работы, н явный session contract для нескольких клиентских поверхностей - [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`, укрепление API/query boundaries, quality/performance gates и lazy-loading budgets +- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать + график динамики портфеля на дашборде - [x] Broker-events — UX доработки и смешанный календарь.