From 49dac140ff462e3c4c2ab5ddd5de6c7a1a38e8cf Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 26 Jun 2026 09:45:04 +0300 Subject: [PATCH 01/19] docs: plan broker dashboard redesign --- .../broker-dashboard-redesign/plan.md | 988 ++++++++++++++++++ .../broker-dashboard-redesign/spec.md | 203 ++++ .../broker-dashboard-redesign/tasks.md | 47 + docs/inbox.md | 11 + docs/roadmap.md | 2 + 5 files changed, 1251 insertions(+) create mode 100644 docs/features/broker-dashboard-redesign/plan.md create mode 100644 docs/features/broker-dashboard-redesign/spec.md create mode 100644 docs/features/broker-dashboard-redesign/tasks.md 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 доработки и смешанный календарь. -- 2.47.2 From c429d6a43ab329d4241e23812a0fe618fe533e66 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 26 Jun 2026 10:18:57 +0300 Subject: [PATCH 02/19] feat(frontend): redesign broker account overview as investment dashboard Replace vertical overview with dashboard composition: - BrokerDashboardHero with portfolio KPI, return, daily change, total income - BrokerDashboardEventsCard with compact events table and local pagination - BrokerDashboardIncomeCard with income operations table and cursor pagination - BrokerDashboardAnalyticsCard with analytics Metric grid - BrokerDashboardAllocationCard wrapping existing donut chart - BrokerDashboardSkeleton matching dashboard shape - BrokerDashboardCard local card pattern - Pure lib helpers: dashboardIncome, dashboardFilters, dashboardFormatters - Unit tests for income helpers (6) and dashboard composition (1) --- .../ui/BrokerAccountOverviewPage.tsx | 36 +---- .../src/widgets/broker-dashboard/index.ts | 2 + .../broker-dashboard/lib/dashboardFilters.ts | 67 +++++++++ .../lib/dashboardFormatters.ts | 24 ++++ .../lib/dashboardIncome.test.ts | 84 +++++++++++ .../broker-dashboard/lib/dashboardIncome.ts | 49 +++++++ .../ui/BrokerDashboard.test.tsx | 93 +++++++++++++ .../broker-dashboard/ui/BrokerDashboard.tsx | 91 ++++++++++++ .../ui/BrokerDashboardAllocationCard.tsx | 19 +++ .../ui/BrokerDashboardAnalyticsCard.tsx | 45 ++++++ .../ui/BrokerDashboardCard.tsx | 50 +++++++ .../ui/BrokerDashboardEventsCard.tsx | 131 ++++++++++++++++++ .../ui/BrokerDashboardHero.tsx | 55 ++++++++ .../ui/BrokerDashboardIncomeCard.tsx | 117 ++++++++++++++++ .../ui/BrokerDashboardSkeleton.tsx | 18 +++ .../broker-dashboard-redesign/tasks.md | 44 +++--- 16 files changed, 870 insertions(+), 55 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/index.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx 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. -- 2.47.2 From 8df94a51ddd11517713cc8ec32a0710feb0b7b84 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 26 Jun 2026 10:47:08 +0300 Subject: [PATCH 03/19] feat: broker dashboard redesign with top tabs, single-column layout, and clickable chip filters - Move navigation from left sidebar to horizontal top tabs in BrokerAccountLayout - Change dashboard blocks to single-column layout (Events, Income, Analytics, Allocation) - Extend DS Chip with onClick/selected/disabled/aria-pressed - Add clickable chip filters to Events and Income cards with immediate filtering - Disable backend query when all types deselected (show validation message) - Add useBrokerEvents/useBrokerOperations options.enabled param - Update dashboard skeleton to single-column shape - Add userEvent tests for chip filter interactions --- .../broker-event/model/useBrokerEvents.ts | 8 +- .../model/useBrokerOperations.ts | 3 +- .../ui/BrokerAccountLayout.tsx | 70 ++++------ .../ui/BrokerDashboard.test.tsx | 79 ++++++++--- .../broker-dashboard/ui/BrokerDashboard.tsx | 124 +++++++++++------- .../ui/BrokerDashboardEventsCard.tsx | 33 ++++- .../ui/BrokerDashboardIncomeCard.tsx | 29 +++- .../ui/BrokerDashboardSkeleton.tsx | 12 +- .../broker-dashboard-redesign/spec.md | 27 ++-- .../broker-dashboard-redesign/tasks.md | 12 +- .../src/components/Chip/Chip.test.tsx | 18 +++ .../src/components/Chip/Chip.tsx | 18 ++- 12 files changed, 291 insertions(+), 142 deletions(-) diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts index 5efa7fe..0206d56 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts @@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query' import type { BrokerEventsData } from '@/shared/api' import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' -export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { +export function useBrokerEvents( + accountId: string | undefined, + query: BrokerEventsQuery, + options: { enabled?: boolean } = {}, +) { const { from, to, types } = query return useQuery({ queryKey: ['broker', 'events', accountId, from, to, types], - enabled: Boolean(accountId), + enabled: Boolean(accountId) && (options.enabled ?? true), queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data, staleTime: 300_000, retry: 2, diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts index 20335c4..80de165 100644 --- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts @@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe export function useBrokerOperations( accountId: string | undefined, query: BrokerOperationQuery = {}, + options: { enabled?: boolean } = {}, ) { return useQuery({ queryKey: ['broker', 'operations', accountId, query], - enabled: Boolean(accountId), + enabled: Boolean(accountId) && (options.enabled ?? true), queryFn: async () => (await getBrokerOperations(accountId!, query)).data, staleTime: 300_000, retry: 2, diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index e029550..f06e171 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -47,51 +47,39 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) { - - {links.map((link) => ( - - {link.label} - - ))} - - - {children} + {links.map((link) => ( + + {link.label} + + ))} + + {children} ) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index a800466..f6d182b 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -1,8 +1,14 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrokerPortfolio } from '@/shared/api' import { BrokerDashboard } from './BrokerDashboard' +const hookMocks = vi.hoisted(() => ({ + useBrokerEvents: vi.fn(), + useBrokerOperations: vi.fn(), +})) + vi.mock('@tanstack/react-router', () => ({ Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( {children} @@ -27,25 +33,11 @@ vi.mock('@/entities/broker-analytics', () => ({ })) vi.mock('@/entities/broker-event', () => ({ - useBrokerEvents: () => ({ - data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, - isLoading: false, - isError: false, - }), + useBrokerEvents: hookMocks.useBrokerEvents, })) 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, - }), + useBrokerOperations: hookMocks.useBrokerOperations, })) vi.mock('@/widgets/broker-allocation-chart', () => ({ @@ -80,6 +72,25 @@ const portfolio: BrokerPortfolio = { } describe('BrokerDashboard', () => { + beforeEach(() => { + hookMocks.useBrokerEvents.mockReturnValue({ + data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, + isLoading: false, + isError: false, + }) + hookMocks.useBrokerOperations.mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-26T00:00:00.000Z', + }, + isLoading: false, + isError: false, + }) + }) + it('renders the dashboard sections', () => { render() @@ -90,4 +101,36 @@ describe('BrokerDashboard', () => { expect(screen.getByText('Аллокация')).toBeInTheDocument() expect(screen.getByText('allocation chart')).toBeInTheDocument() }) + + it('uses event chips as request filters', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button', { name: 'Купоны' })[0]) + + await waitFor(() => { + expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith( + 'acc-1', + expect.objectContaining({ types: 'dividend,maturity,offer' }), + { enabled: true }, + ) + }) + }) + + it('uses income chips as request filters', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button', { name: 'Купоны' })[1]) + + await waitFor(() => { + expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith( + 'acc-1', + expect.objectContaining({ + operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT', + }), + { enabled: true }, + ) + }) + }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index a9b430b..5bbdb2b 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -6,6 +6,8 @@ import { useBrokerOperations } from '@/entities/broker-operation' import type { BrokerPortfolio } from '@/shared/api' import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { + type DashboardEventType, + type DashboardIncomeType, defaultEventsFilters, defaultIncomeFilters, incomeTypesToOperationTypes, @@ -23,69 +25,99 @@ export function BrokerDashboard({ accountId: string portfolio: BrokerPortfolio }) { - const [eventFilters, _setEventFilters] = useState(defaultEventsFilters) + const [eventFilters, setEventFilters] = useState(defaultEventsFilters) const [eventPage, setEventPage] = useState(1) - const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters) + 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 hasEventTypes = eventFilters.types.length > 0 + const hasIncomeTypes = incomeFilters.types.length > 0 + const events = useBrokerEvents( + accountId, + { + from: eventFilters.from, + to: eventFilters.to, + types: eventFilters.types.join(','), + }, + { enabled: hasEventTypes }, + ) 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 operations = useBrokerOperations( + accountId, + { + from: incomeFilters.from, + to: incomeFilters.to, + operationTypes: incomeTypesToOperationTypes(incomeFilters.types), + cursor: incomePagination.cursor, + limit: 10, + }, + { enabled: hasIncomeTypes }, + ) const nextCursor: string | undefined = operations.data?.nextCursor ? (operations.data.nextCursor as unknown as string) : undefined + function toggleEventType(type: DashboardEventType) { + setEventFilters((filters) => ({ + ...filters, + types: filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type], + })) + setEventPage(1) + } + + function toggleIncomeType(type: DashboardIncomeType) { + setIncomeFilters((filters) => ({ + ...filters, + types: filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type], + })) + incomePagination.reset() + } + 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)} - /> - - - - - + 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/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index 5d99e99..a189431 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -3,14 +3,28 @@ 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 type { DashboardEventType } from '../lib/dashboardFilters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { BrokerDashboardCard } from './BrokerDashboardCard' +const EVENT_FILTERS: Array<{ + type: DashboardEventType + label: string + tone: 'success' | 'info' | 'warning' | 'neutral' +}> = [ + { type: 'dividend', label: 'Дивиденды', tone: 'success' }, + { type: 'coupon', label: 'Купоны', tone: 'info' }, + { type: 'maturity', label: 'Погашения', tone: 'warning' }, + { type: 'offer', label: 'Оферты', tone: 'neutral' }, +] + type BrokerDashboardEventsCardProps = { accountId: string data: BrokerEventsData | undefined isLoading: boolean isError: boolean + selectedTypes: DashboardEventType[] + onToggleType: (type: DashboardEventType) => void page: number onPreviousPage: () => void onNextPage: () => void @@ -30,6 +44,8 @@ export function BrokerDashboardEventsCard({ data, isLoading, isError, + selectedTypes, + onToggleType, page, onPreviousPage, onNextPage, @@ -44,12 +60,19 @@ export function BrokerDashboardEventsCard({ action={Все события} > - - - - + {EVENT_FILTERS.map((filter) => ( + onToggleType(filter.type)} + /> + ))} - {isError ? ( + {selectedTypes.length === 0 ? ( + Выберите хотя бы один тип событий + ) : isError ? ( Не удалось загрузить события ) : isLoading ? ( Загрузка событий… diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 0f4682c..b3e882d 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -3,14 +3,26 @@ 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 type { DashboardIncomeType } from '../lib/dashboardFilters' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { BrokerDashboardCard } from './BrokerDashboardCard' +const INCOME_FILTERS: Array<{ + type: DashboardIncomeType + label: string + tone: 'success' | 'info' +}> = [ + { type: 'dividend', label: 'Дивиденды', tone: 'success' }, + { type: 'coupon', label: 'Купоны', tone: 'info' }, +] + type BrokerDashboardIncomeCardProps = { accountId: string page: BrokerOperationsPage | undefined isLoading: boolean isError: boolean + selectedTypes: DashboardIncomeType[] + onToggleType: (type: DashboardIncomeType) => void pageNumber: number canGoBack: boolean canGoForward: boolean @@ -23,6 +35,8 @@ export function BrokerDashboardIncomeCard({ page, isLoading, isError, + selectedTypes, + onToggleType, pageNumber, canGoBack, canGoForward, @@ -38,10 +52,19 @@ export function BrokerDashboardIncomeCard({ action={Все операции} > - - + {INCOME_FILTERS.map((filter) => ( + onToggleType(filter.type)} + /> + ))} - {isError ? ( + {selectedTypes.length === 0 ? ( + Выберите хотя бы один тип доходов + ) : isError ? ( Не удалось загрузить доходные операции ) : isLoading ? ( Загрузка доходов… diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx index edaf886..a9c6181 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx @@ -5,14 +5,10 @@ export function BrokerDashboardSkeleton() { return ( - - - - - - - - + + + + ) } diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md index bdf837f..272c0b5 100644 --- a/docs/features/broker-dashboard-redesign/spec.md +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -53,13 +53,13 @@ - `/broker/:accountId` остаётся overview выбранного брокерского счёта. - Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций. -- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и - `Доходы`. -- Ниже отображаются `Аналитика доходности` и `Аллокация`. +- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать + левую колонку и оставить больше пространства для таблиц dashboard. +- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`, + `Доходы`, `Аналитика доходности`, `Аллокация`. - Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`. -- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы, - аналитика, аллокация. +- На мобильном viewport сохраняется тот же порядок блоков в одну колонку. ### 2. Визуальный стиль @@ -88,10 +88,12 @@ Hero показывает: - Блок использует существующий источник `useBrokerEvents(accountId, query)`. - По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы `dividend,coupon,maturity,offer`, как в существующей вкладке событий. -- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. +- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Пользователь может выбрать несколько типов событий. - Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает валидационное сообщение. +- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую + страницу. - Блок содержит фильтр периода `from` / `to`. - Изменение черновых фильтров не запускает запрос до нажатия `Показать`. - Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. @@ -113,10 +115,11 @@ Hero показывает: операций. - В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. -- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`. +- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`. - Пользователь может выбрать один или оба типа доходов. - Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает валидационное сообщение. +- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию. - Блок содержит фильтр периода `from` / `to`. - По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе операций. @@ -175,16 +178,16 @@ Hero показывает: - `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. -- На desktop блоки `События` и `Доходы` расположены рядом. +- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом. +- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по + одному блоку на строке. - На мобильном viewport dashboard читаемо перестраивается в одну колонку. - Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего доходов или fallback. - Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. -- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и - локальную пагинацию по 10 событий. +- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. -- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и - cursor-пагинацию по 10 операций. +- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций. - Блок `Аналитика доходности` показывает данные существующего analytics endpoint. - Блок `Аллокация` показывает donut/легенду существующей структуры портфеля. - Ошибка одного вторичного блока не скрывает остальные блоки dashboard. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index e6a19e2..f8912ab 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -23,9 +23,13 @@ - [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics. - [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`. - [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`. -- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`. +- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом. +- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. +- [x] Добавить кликабельные chip-фильтры типов для `События`. +- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `События`. - [x] Добавить локальную пагинацию по 10 событий в `События`. -- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`. +- [x] Добавить кликабельные chip-фильтры типов для `Доходы`. +- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `Доходы`. - [x] Добавить cursor-пагинацию по 10 операций в `Доходы`. - [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`. - [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации. @@ -38,8 +42,8 @@ ## Definition of Done -- [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 test:frontend` проходит (31 files, 135 tests). +- [x] `rtk npm run test:design-system` проходит (28 files, 162 tests). - [x] `rtk npm run lint -w apps/frontend` проходит. - [x] `rtk npm run build:frontend` проходит. - [x] Dashboard соответствует acceptance criteria из `spec.md`. diff --git a/packages/design-system/src/components/Chip/Chip.test.tsx b/packages/design-system/src/components/Chip/Chip.test.tsx index d5d91ea..8317ce5 100644 --- a/packages/design-system/src/components/Chip/Chip.test.tsx +++ b/packages/design-system/src/components/Chip/Chip.test.tsx @@ -59,4 +59,22 @@ describe('Chip', () => { renderWithTheme(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + it('calls onClick when clickable chip clicked', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + renderWithTheme(); + + await user.click(screen.getByRole('button', { name: 'Clickable' })); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders unselected clickable chip as outlined', () => { + renderWithTheme( {}} selected={false} />); + + const chip = screen.getByText('Inactive').closest('.MuiChip-root')!; + expect(chip.classList.contains('MuiChip-outlined')).toBe(true); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/packages/design-system/src/components/Chip/Chip.tsx b/packages/design-system/src/components/Chip/Chip.tsx index f6b328e..48d9c37 100644 --- a/packages/design-system/src/components/Chip/Chip.tsx +++ b/packages/design-system/src/components/Chip/Chip.tsx @@ -1,5 +1,5 @@ -import { Chip as MuiChip } from '@mui/material'; import CancelIcon from '@mui/icons-material/Cancel'; +import { Chip as MuiChip } from '@mui/material'; type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error'; @@ -15,14 +15,28 @@ export interface ChipProps { label: string; tone?: Tone; onDelete?: () => void; + onClick?: () => void; + selected?: boolean; + disabled?: boolean; } -export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) { +export function Chip({ + label, + tone = 'neutral', + onDelete, + onClick, + selected = true, + disabled, +}: ChipProps) { return ( } : {})} /> ); -- 2.47.2 From d804d4361615e7caf5e375129ee77e22e9798843 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 10:45:20 +0300 Subject: [PATCH 04/19] docs: align broker dashboard redesign docs --- .../broker-dashboard-redesign/plan.md | 1139 +++-------------- .../broker-dashboard-redesign/spec.md | 33 +- .../broker-dashboard-redesign/tasks.md | 27 +- 3 files changed, 196 insertions(+), 1003 deletions(-) diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md index 0d000c9..156fbae 100644 --- a/docs/features/broker-dashboard-redesign/plan.md +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -1,988 +1,169 @@ -# 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. +> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`. -**Goal:** Rebuild `/broker/:accountId` overview as a light-theme investment dashboard using current broker portfolio, events, operations, analytics, and allocation data. +**Цель:** превратить `/broker/:accountId` в компактный инвестиционный дашборд на текущей светлой теме без изменения backend-контрактов и URL-структуры. -**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. +**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system. -**Tech Stack:** React 18, TanStack Router, TanStack Query, MUI via `@moex-vibe/design-system`, Vitest, Testing Library. +**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@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. +- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — горизонтальные вкладки над контентом обзора и подробных разделов. +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — точка входа обзора через dashboard. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/index.ts` — публичный API dashboard-виджета. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — верхнеуровневая композиция и управление filter state. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемый фильтр дат с draft/apply поведением. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — skeleton-форма dashboard. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — компонентные тесты композиции и фильтров. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — чистые helpers доходных операций. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels. +- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения. + +### Источники данных + +- Портфель: `useBrokerAccountContext().portfolio`. +- События: `useBrokerEvents(accountId, { from, to, types })`. +- Операции: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })`. +- Аналитика: `useBrokerAnalytics(accountId)`. +- Аллокация: `buildBrokerAllocation(portfolio)`. + +## Технические решения + +### 1. Композиция страницы + +- Dashboard на desktop и mobile строится в одну колонку: `Hero`, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. +- Двухколоночный layout из прототипа не переносится. +- Навигация счёта находится над контентом в `BrokerAccountLayout` и не дублируется внутри dashboard. + +### 2. Hero KPI + +- Hero показывает название счёта, стоимость портфеля, доходность, дневное изменение и всего полученных доходов. +- Приоритет доходности: `analytics.totalReturnPercent`, затем `portfolio.yields.expectedPercent`, иначе `—`. +- Дневное изменение берётся из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, при недоступности показывается `—`. +- Все `Metric` должны иметь одинаковую высоту; supporting text не должен ломать вертикальный ритм. + +### 3. События + +- Типы событий переключаются chip-фильтрами с немедленным применением. +- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по `Показать`. +- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. +- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. + +### 4. Доходы + +- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. +- Типы доходов переключаются chip-фильтрами с немедленным применением. +- Диапазон дат использует тот же draft/apply паттерн, что и события. +- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров. + +### 5. Аналитика и аллокация + +- Карточка аналитики использует существующий analytics endpoint и показывает спокойное empty state при отсутствии данных. +- Карточка аллокации не использует donut chart. Она строит список горизонтальных bar rows по `buildBrokerAllocation`. +- Отрицательные значения показываются текстом без полосы. + +### 6. Ошибки и пустые состояния + +- Ошибка `portfolio` роняет весь обзор. +- Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам. +- Пустые данные показываются отдельными сообщениями, а не нулевыми значениями. + +## Задачи + +### Задача 1: Базовые helpers и локальные dashboard-patterns + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` + +- [ ] Убедиться, что income helpers покрывают только допустимые типы доходов и умеют считать итог отображаемых строк. +- [ ] Убедиться, что filter helpers содержат default state, date presets, validate и mapping income type -> operation types. +- [ ] Убедиться, что formatters покрывают fallback-значения, label типов событий и label статусов. +- [ ] Использовать локальный `BrokerDashboardCard` как основной контейнер карточек; design system расширять только если без этого нельзя реализовать требования спецификации. + +### Задача 2: Hero и layout overview + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` +- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` + +- [ ] Собрать обзор через `BrokerDashboard` и `BrokerDashboardSkeleton`. +- [ ] Оставить навигацию счёта в `BrokerAccountLayout` как горизонтальные вкладки над контентом. +- [ ] Исправить hero так, чтобы все `Metric` были одной высоты и поддерживающий текст не поднимал одну ячейку выше остальных. +- [ ] Проверить, что dashboard остаётся одноколоночным и на desktop, и на mobile. + +### Задача 3: Карточка событий + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` + +- [ ] Добавить переиспользуемый `BrokerDashboardDateFilter` с preset chips, полями `from/to`, действиями `Сбросить` и `Показать`. +- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по `Показать`. +- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. +- [ ] Оставить локальные loading/error/empty states внутри карточки. + +### Задача 4: Карточка доходов + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` + +- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state. +- [ ] Оставить chip-фильтры типов доходов с немедленным применением. +- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров. +- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи. + +### Задача 5: Карточки аналитики и аллокации + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` + +- [ ] Довести карточку аналитики до соответствия спецификации по полям и состояниям. +- [ ] Заменить donut chart на горизонтальные бары, построенные из `buildBrokerAllocation`. +- [ ] Отрицательные значения аллокации выводить отдельно текстом без bar. + +### Задача 6: Тесты и верификация + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` +- `docs/features/broker-dashboard-redesign/tasks.md` + +- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states. +- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`. +- [ ] Прогнать `rtk npm run test:frontend`. +- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`. +- [ ] Проверить вручную desktop layout `/broker/2084014113`. +- [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`. +- [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`. + +## Проверка покрытия спецификации + +- Общая композиция и горизонтальная навигация: задачи 2 и 6. +- Hero KPI и equal-height `Metric`: задача 2. +- События с chip filters, date filters и локальной пагинацией: задача 3. +- Доходы с chip filters, date filters и cursor pagination: задача 4. +- Аналитика и аллокация с горизонтальными барами: задача 5. +- Ошибки, empty states, тесты и ручная верификация: задача 6. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md index 272c0b5..1fd5590 100644 --- a/docs/features/broker-dashboard-redesign/spec.md +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -1,6 +1,6 @@ -# Редизайн overview брокерского счёта в инвестиционный дашборд +# Редизайн обзора брокерского счёта в инвестиционный дашборд -Дата: 2026-06-26 +Дата: 2026-06-26 (обновлено 2026-06-27) Статус: согласовано к планированию Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) @@ -9,12 +9,14 @@ Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов, ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен -представлен как более плотный инвестиционный дашборд: hero KPI, события и доходы рядом, аналитика и -аллокация в нижнем ряду. +представлен как более плотный инвестиционный дашборд с hero KPI, компактными таблицами и аналитическими +карточками. После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом, используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты -`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. +`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. При этом +двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки +dashboard идут по одному на строке в фиксированном порядке. ## Цель @@ -29,13 +31,13 @@ - увидеть ближайшие события по счёту в компактной таблице; - увидеть последние доходные операции по дивидендам и купонам и итог по ним; - оценить вложения, полученные выплаты и доходность по существующей аналитике; -- увидеть структуру портфеля через donut-диаграмму и легенду; +- увидеть структуру портфеля через горизонтальные полосы аллокации и подписи к ним; - перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика` для drill-down сценариев. ## Область изменений -Фича относится только к frontend маршруту `/broker/:accountId` и связанным frontend-компонентам +Фича относится только к frontend-маршруту `/broker/:accountId` и связанным frontend-компонентам брокерского overview. В область входят: @@ -82,6 +84,7 @@ Hero показывает: - дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно; - всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена; - спокойный fallback `—` для недоступных значений. +- Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края. ### 4. Блок `События` @@ -144,10 +147,14 @@ Hero показывает: ### 7. Блок `Аллокация` -- Используется существующий расчёт `buildBrokerAllocation` и текущая `BrokerAllocationChart` либо её - dashboard-адаптация. -- Блок показывает итоговую стоимость портфеля и ненулевые секторы с названием, суммой и процентом. -- Отрицательные значения отображаются текстом, а не сектором диаграммы. +- Используется существующий расчёт `buildBrokerAllocation`. +- Вместо donut-диаграммы используется горизонтальный bar chart: каждый сектор — полоса с процентом, + подписью и суммой. +- Сверху блока показывается итоговая стоимость портфеля. +- Каждая полоса содержит: название сектора, долю в процентах, сумму в валюте. +- Цвета полос соответствуют существующей палитре аллокации (акции, облигации, ETF, деньги, прочие). +- Отрицательные значения отображаются текстом без полосы. +- Текст подписей контрастный и читаемый на всех цветах фона. - Информация остаётся понятной без различения цветов. ### 8. Загрузка, ошибки и пустые состояния @@ -184,12 +191,14 @@ Hero показывает: - На мобильном viewport dashboard читаемо перестраивается в одну колонку. - Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего доходов или fallback. +- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса. - Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций. - Блок `Аналитика доходности` показывает данные существующего analytics endpoint. -- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля. +- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также + итоговую стоимость портфеля. - Ошибка одного вторичного блока не скрывает остальные блоки dashboard. - Существующие detailed вкладки остаются доступны из навигации счёта. - Первая версия не содержит график истории стоимости портфеля и не добавляет API для него. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index f8912ab..13a6842 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -1,7 +1,7 @@ -# Редизайн overview брокерского счёта в инвестиционный дашборд — задачи +# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи Дата: 2026-06-26 -Статус: готово к реализации +Статус: в реализации ## Документация и pre-flight @@ -26,26 +26,29 @@ - [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом. - [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. - [x] Добавить кликабельные chip-фильтры типов для `События`. -- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `События`. +- [ ] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать). +- [ ] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием. - [x] Добавить локальную пагинацию по 10 событий в `События`. - [x] Добавить кликабельные chip-фильтры типов для `Доходы`. -- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `Доходы`. +- [ ] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием. - [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. +- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`. +- [x] Добавить unit/component tests для helpers и базовой dashboard composition. +- [ ] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. +- [ ] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. - [ ] Проверить desktop layout `/broker/2084014113`. - [ ] Проверить mobile layout `/broker/2084014113`. ## Definition of Done -- [x] `rtk npm run test:frontend` проходит (31 files, 135 tests). -- [x] `rtk npm run test:design-system` проходит (28 files, 162 tests). -- [x] `rtk npm run lint -w apps/frontend` проходит. -- [x] `rtk npm run build:frontend` проходит. -- [x] Dashboard соответствует acceptance criteria из `spec.md`. +- [ ] `rtk npm run test:frontend` проходит. +- [ ] `rtk npm run test:design-system` проходит. +- [ ] `rtk npm run lint -w apps/frontend` проходит. +- [ ] `rtk npm run build:frontend` проходит. +- [ ] Dashboard соответствует acceptance criteria из `spec.md`. - [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны. -- [x] `graphify update .` выполнен после code changes. +- [ ] `graphify update .` выполнен после code changes. -- 2.47.2 From 0116c34a9fcbcdc373aa4eb8b86b4538531b021d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 11:11:25 +0300 Subject: [PATCH 05/19] feat: improove design --- .../broker-dashboard/lib/dashboardFilters.ts | 8 +- .../ui/BrokerDashboard.test.tsx | 32 +++- .../broker-dashboard/ui/BrokerDashboard.tsx | 179 +++++++++++++++--- .../ui/BrokerDashboardAllocationCard.tsx | 69 ++++++- .../ui/BrokerDashboardDateFilter.tsx | 156 +++++++++++++++ .../ui/BrokerDashboardEventsCard.tsx | 67 +++++-- .../ui/BrokerDashboardHero.tsx | 22 ++- .../ui/BrokerDashboardIncomeCard.tsx | 67 +++++-- .../broker-dashboard-redesign/tasks.md | 10 +- 9 files changed, 528 insertions(+), 82 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts index 0733692..faa16db 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -11,6 +11,7 @@ export type DashboardFilterState = { from: string to: string types: T[] + preset: DashboardDatePreset } export function defaultEventsFilters(): DashboardFilterState { @@ -19,6 +20,7 @@ export function defaultEventsFilters(): DashboardFilterState from: now.subtract(7, 'day').format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'), types: [...DASHBOARD_EVENT_TYPES], + preset: '7d', } } @@ -28,6 +30,7 @@ export function defaultIncomeFilters(): DashboardFilterState( preset: DashboardDatePreset, ): DashboardFilterState { const now = dayjs() - if (preset === 'all') return { ...filters, from: '', to: now.format('YYYY-MM-DD') } + const next = { ...filters, preset } + if (preset === 'all') return { ...next, from: '', to: '' } const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10) const unit = preset === '1y' ? 'year' : 'day' return { - ...filters, + ...next, from: now.subtract(amount, unit).format('YYYY-MM-DD'), to: now.format('YYYY-MM-DD'), } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index f6d182b..50d6a85 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -40,10 +40,6 @@ vi.mock('@/entities/broker-operation', () => ({ useBrokerOperations: hookMocks.useBrokerOperations, })) -vi.mock('@/widgets/broker-allocation-chart', () => ({ - BrokerAllocationChart: () =>
allocation chart
, -})) - const portfolio: BrokerPortfolio = { account: { id: 'acc-1', @@ -99,14 +95,14 @@ describe('BrokerDashboard', () => { expect(screen.getByText('Доходы')).toBeInTheDocument() expect(screen.getByText('Аналитика доходности')).toBeInTheDocument() expect(screen.getByText('Аллокация')).toBeInTheDocument() - expect(screen.getByText('allocation chart')).toBeInTheDocument() }) - it('uses event chips as request filters', async () => { + it('applies event type chips immediately to useBrokerEvents', async () => { const user = userEvent.setup() render() - await user.click(screen.getAllByRole('button', { name: 'Купоны' })[0]) + const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) + await user.click(couponChips[0]) await waitFor(() => { expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith( @@ -117,11 +113,12 @@ describe('BrokerDashboard', () => { }) }) - it('uses income chips as request filters', async () => { + it('applies income type chips immediately to useBrokerOperations', async () => { const user = userEvent.setup() render() - await user.click(screen.getAllByRole('button', { name: 'Купоны' })[1]) + const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) + await user.click(couponChips[1]) await waitFor(() => { expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith( @@ -133,4 +130,21 @@ describe('BrokerDashboard', () => { ) }) }) + + it('shows date filter toggle button and expandable panel', async () => { + const user = userEvent.setup() + render() + + const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ }) + expect(filterButtons).toHaveLength(2) + + await user.click(filterButtons[0]) + + expect(screen.getByText('7д')).toBeInTheDocument() + expect(screen.getByText('30д')).toBeInTheDocument() + expect(screen.getByText('90д')).toBeInTheDocument() + expect(screen.getByText('1г')).toBeInTheDocument() + expect(screen.getByText('Всё')).toBeInTheDocument() + expect(screen.getByText('Сбросить')).toBeInTheDocument() + }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index 5bbdb2b..d4cac32 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -1,11 +1,14 @@ import { Box } from '@mui/material' -import { useState } from 'react' +import dayjs from 'dayjs' +import { useCallback, 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 { + applyDatePreset, + type DashboardDatePreset, type DashboardEventType, type DashboardIncomeType, defaultEventsFilters, @@ -18,6 +21,15 @@ import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard' import { BrokerDashboardHero } from './BrokerDashboardHero' import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard' +function formatDateLabel(from: string, to: string): string | null { + if (!from && !to) return 'За всё время' + const fmt = (d: string) => dayjs(d).format('D MMM') + if (from && to) return `${fmt(from)} – ${fmt(to)}` + if (from) return `с ${fmt(from)}` + if (to) return `до ${fmt(to)}` + return null +} + export function BrokerDashboard({ accountId, portfolio, @@ -25,34 +37,39 @@ export function BrokerDashboard({ accountId: string portfolio: BrokerPortfolio }) { - const [eventFilters, setEventFilters] = useState(defaultEventsFilters) + const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters) + const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters) + const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false) const [eventPage, setEventPage] = useState(1) - const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters) + + const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters) + const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters) + const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false) const incomePagination = useCursorPagination() + const analytics = useBrokerAnalytics(accountId) - const hasEventTypes = eventFilters.types.length > 0 - const hasIncomeTypes = incomeFilters.types.length > 0 + const hasEventTypes = appliedEventFilters.types.length > 0 + const hasIncomeTypes = appliedIncomeFilters.types.length > 0 + const hasDraftEventTypes = draftEventFilters.types.length > 0 + const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0 + const eventPageSize = 10 + const events = useBrokerEvents( accountId, { - from: eventFilters.from, - to: eventFilters.to, - types: eventFilters.types.join(','), + from: appliedEventFilters.from, + to: appliedEventFilters.to, + types: appliedEventFilters.types.join(','), }, { enabled: hasEventTypes }, ) - 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), + from: appliedIncomeFilters.from, + to: appliedIncomeFilters.to, + operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types), cursor: incomePagination.cursor, limit: 10, }, @@ -63,26 +80,106 @@ export function BrokerDashboard({ ? (operations.data.nextCursor as unknown as string) : undefined + const eventItems = events.data?.items ?? [] + const eventPageItems = eventItems.slice( + (eventPage - 1) * eventPageSize, + eventPage * eventPageSize, + ) + function toggleEventType(type: DashboardEventType) { - setEventFilters((filters) => ({ - ...filters, - types: filters.types.includes(type) + setAppliedEventFilters((filters) => { + const nextTypes = filters.types.includes(type) ? filters.types.filter((item) => item !== type) - : [...filters.types, type], - })) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + setDraftEventFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) setEventPage(1) } function toggleIncomeType(type: DashboardIncomeType) { - setIncomeFilters((filters) => ({ - ...filters, - types: filters.types.includes(type) + setAppliedIncomeFilters((filters) => { + const nextTypes = filters.types.includes(type) ? filters.types.filter((item) => item !== type) - : [...filters.types, type], - })) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + setDraftIncomeFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) incomePagination.reset() } + const applyEventFilters = useCallback(() => { + setAppliedEventFilters((prev) => ({ + ...prev, + from: draftEventFilters.from, + to: draftEventFilters.to, + preset: draftEventFilters.preset, + })) + setEventFilterPanelOpen(false) + setEventPage(1) + }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) + + const resetEventFilters = useCallback(() => { + const defaults = defaultEventsFilters() + setAppliedEventFilters(defaults) + setDraftEventFilters(defaults) + setEventFilterPanelOpen(false) + setEventPage(1) + }, []) + + const applyIncomeFilters = useCallback(() => { + setAppliedIncomeFilters((prev) => ({ + ...prev, + from: draftIncomeFilters.from, + to: draftIncomeFilters.to, + preset: draftIncomeFilters.preset, + })) + setIncomeFilterPanelOpen(false) + incomePagination.reset() + }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) + + const resetIncomeFilters = useCallback(() => { + const defaults = defaultIncomeFilters() + setAppliedIncomeFilters(defaults) + setDraftIncomeFilters(defaults) + setIncomeFilterPanelOpen(false) + incomePagination.reset() + }, [incomePagination]) + + function handleDraftEventPresetChange(preset: DashboardDatePreset) { + setDraftEventFilters((filters) => applyDatePreset(filters, preset)) + } + + function handleDraftEventFromChange(value: string) { + setDraftEventFilters((filters) => ({ ...filters, from: value })) + } + + function handleDraftEventToChange(value: string) { + setDraftEventFilters((filters) => ({ ...filters, to: value })) + } + + function handleDraftIncomePresetChange(preset: DashboardDatePreset) { + setDraftIncomeFilters((filters) => applyDatePreset(filters, preset)) + } + + function handleDraftIncomeFromChange(value: string) { + setDraftIncomeFilters((filters) => ({ ...filters, from: value })) + } + + function handleDraftIncomeToChange(value: string) { + setDraftIncomeFilters((filters) => ({ ...filters, to: value })) + } + return ( @@ -91,8 +188,20 @@ export function BrokerDashboard({ data={events.data ? { ...events.data, items: eventPageItems } : undefined} isLoading={events.isLoading} isError={events.isError} - selectedTypes={eventFilters.types} + selectedTypes={draftEventFilters.types} onToggleType={toggleEventType} + appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)} + draftPreset={draftEventFilters.preset} + draftFrom={draftEventFilters.from} + draftTo={draftEventFilters.to} + onDraftPresetChange={handleDraftEventPresetChange} + onDraftFromChange={handleDraftEventFromChange} + onDraftToChange={handleDraftEventToChange} + onApplyFilters={applyEventFilters} + onResetFilters={resetEventFilters} + dateFilterOpen={eventFilterPanelOpen} + onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)} + hasDraftTypes={hasDraftEventTypes} page={eventPage} canGoBack={eventPage > 1} canGoForward={eventPage * eventPageSize < eventItems.length} @@ -104,8 +213,20 @@ export function BrokerDashboard({ page={operations.data} isLoading={operations.isLoading} isError={operations.isError} - selectedTypes={incomeFilters.types} + selectedTypes={draftIncomeFilters.types} onToggleType={toggleIncomeType} + appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)} + draftPreset={draftIncomeFilters.preset} + draftFrom={draftIncomeFilters.from} + draftTo={draftIncomeFilters.to} + onDraftPresetChange={handleDraftIncomePresetChange} + onDraftFromChange={handleDraftIncomeFromChange} + onDraftToChange={handleDraftIncomeToChange} + onApplyFilters={applyIncomeFilters} + onResetFilters={resetIncomeFilters} + dateFilterOpen={incomeFilterPanelOpen} + onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)} + hasDraftTypes={hasDraftIncomeTypes} pageNumber={incomePagination.pageNumber} canGoBack={incomePagination.pageNumber > 1} canGoForward={operations.data?.hasNext ?? false} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx index a97c376..679e78f 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx @@ -1,19 +1,78 @@ import { Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' +import { buildBrokerAllocation } from '@/entities/broker-position' import type { BrokerPortfolio } from '@/shared/api' -import { formatBrokerMoney } from '@/shared/lib/formatters' -import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart' +import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters' import { BrokerDashboardCard } from './BrokerDashboardCard' +const ALLOCATION_COLORS: Record = { + shares: '#4969f5', + bonds: '#e5a33c', + etf: '#62b889', + cash: '#7b63cf', + other: '#aeb6c5', +} + export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { + const { total, sectors, negative } = buildBrokerAllocation(portfolio) + const currency = + total >= 0 + ? (portfolio.totals.portfolio?.currency ?? + Object.values(portfolio.totals).find((t) => t?.currency)?.currency ?? + 'RUB') + : 'RUB' + return ( {formatBrokerMoney(portfolio.totals.portfolio)}} > - - - + {sectors.length === 0 && negative.length === 0 ? ( + Нет данных для распределения + ) : ( + + {sectors.map((sector) => ( + + + {sector.label} + + {formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}% + + + + + + + ))} + {negative.length > 0 && ( + + {negative.map((item) => ( + + {item.label}: + + {formatBrokerCurrencyValue(currency, item.value)} + + + ))} + + )} + + )} ) } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx new file mode 100644 index 0000000..dd27c8e --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx @@ -0,0 +1,156 @@ +import { Button, Chip, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { DashboardDatePreset } from '../lib/dashboardFilters' + +const PRESETS: { key: DashboardDatePreset; label: string }[] = [ + { key: '7d', label: '7д' }, + { key: '30d', label: '30д' }, + { key: '90d', label: '90д' }, + { key: '1y', label: '1г' }, + { key: 'all', label: 'Всё' }, +] + +type BrokerDashboardDateFilterProps = { + appliedLabel: string | null + preset: DashboardDatePreset + draftFrom: string + draftTo: string + hasDraftTypes: boolean + onPresetChange: (preset: DashboardDatePreset) => void + onFromChange: (value: string) => void + onToChange: (value: string) => void + onReset: () => void + onApply: () => void + isOpen: boolean + onToggle: () => void +} + +export function BrokerDashboardDateFilter({ + appliedLabel, + preset, + draftFrom, + draftTo, + hasDraftTypes, + onPresetChange, + onFromChange, + onToChange, + onReset, + onApply, + isOpen, + onToggle, +}: BrokerDashboardDateFilterProps) { + return ( + + + 📅 Фильтр дат + {appliedLabel && ( + + {appliedLabel} + + )} + + + {isOpen && ( + + + {PRESETS.map((p) => ( + onPresetChange(p.key)} + /> + ))} + + + onFromChange(e.target.value)} + style={{ + border: '1px solid #ccc', + borderRadius: 6, + padding: '4px 8px', + fontSize: 13, + flex: '0 1 auto', + }} + /> + + — + + onToChange(e.target.value)} + style={{ + border: '1px solid #ccc', + borderRadius: 6, + padding: '4px 8px', + fontSize: 13, + flex: '0 1 auto', + }} + /> + + Сбросить + + + + )} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index a189431..c0e3510 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -3,9 +3,10 @@ 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 type { DashboardEventType } from '../lib/dashboardFilters' +import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { BrokerDashboardCard } from './BrokerDashboardCard' +import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' const EVENT_FILTERS: Array<{ type: DashboardEventType @@ -25,6 +26,18 @@ type BrokerDashboardEventsCardProps = { isError: boolean selectedTypes: DashboardEventType[] onToggleType: (type: DashboardEventType) => void + appliedDateLabel: string | null + draftPreset: DashboardDatePreset + draftFrom: string + draftTo: string + onDraftPresetChange: (preset: DashboardDatePreset) => void + onDraftFromChange: (value: string) => void + onDraftToChange: (value: string) => void + onApplyFilters: () => void + onResetFilters: () => void + dateFilterOpen: boolean + onToggleDateFilter: () => void + hasDraftTypes: boolean page: number onPreviousPage: () => void onNextPage: () => void @@ -46,6 +59,18 @@ export function BrokerDashboardEventsCard({ isError, selectedTypes, onToggleType, + appliedDateLabel, + draftPreset, + draftFrom, + draftTo, + onDraftPresetChange, + onDraftFromChange, + onDraftToChange, + onApplyFilters, + onResetFilters, + dateFilterOpen, + onToggleDateFilter, + hasDraftTypes, page, onPreviousPage, onNextPage, @@ -58,18 +83,36 @@ export function BrokerDashboardEventsCard({ Все события} - > - - {EVENT_FILTERS.map((filter) => ( - onToggleType(filter.type)} + filters={ + + + {EVENT_FILTERS.map((filter) => ( + onToggleType(filter.type)} + /> + ))} + + - ))} - + + } + > {selectedTypes.length === 0 ? ( Выберите хотя бы один тип событий ) : isError ? ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx index db43dee..82c7768 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx @@ -32,7 +32,7 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' }, - alignItems: 'center', + alignItems: 'stretch', }} > @@ -43,13 +43,19 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer {accountName} - - - + + + + + + + + + ) } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index b3e882d..2cfd755 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -3,9 +3,10 @@ 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 type { DashboardIncomeType } from '../lib/dashboardFilters' +import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { BrokerDashboardCard } from './BrokerDashboardCard' +import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' const INCOME_FILTERS: Array<{ type: DashboardIncomeType @@ -23,6 +24,18 @@ type BrokerDashboardIncomeCardProps = { isError: boolean selectedTypes: DashboardIncomeType[] onToggleType: (type: DashboardIncomeType) => void + appliedDateLabel: string | null + draftPreset: DashboardDatePreset + draftFrom: string + draftTo: string + onDraftPresetChange: (preset: DashboardDatePreset) => void + onDraftFromChange: (value: string) => void + onDraftToChange: (value: string) => void + onApplyFilters: () => void + onResetFilters: () => void + dateFilterOpen: boolean + onToggleDateFilter: () => void + hasDraftTypes: boolean pageNumber: number canGoBack: boolean canGoForward: boolean @@ -37,6 +50,18 @@ export function BrokerDashboardIncomeCard({ isError, selectedTypes, onToggleType, + appliedDateLabel, + draftPreset, + draftFrom, + draftTo, + onDraftPresetChange, + onDraftFromChange, + onDraftToChange, + onApplyFilters, + onResetFilters, + dateFilterOpen, + onToggleDateFilter, + hasDraftTypes, pageNumber, canGoBack, canGoForward, @@ -50,18 +75,36 @@ export function BrokerDashboardIncomeCard({ Все операции} - > - - {INCOME_FILTERS.map((filter) => ( - onToggleType(filter.type)} + filters={ + + + {INCOME_FILTERS.map((filter) => ( + onToggleType(filter.type)} + /> + ))} + + - ))} - + + } + > {selectedTypes.length === 0 ? ( Выберите хотя бы один тип доходов ) : isError ? ( diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index 13a6842..8423510 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -26,11 +26,11 @@ - [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом. - [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. - [x] Добавить кликабельные chip-фильтры типов для `События`. -- [ ] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать). -- [ ] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием. +- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать). +- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием. - [x] Добавить локальную пагинацию по 10 событий в `События`. - [x] Добавить кликабельные chip-фильтры типов для `Доходы`. -- [ ] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием. +- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием. - [x] Добавить cursor-пагинацию по 10 операций в `Доходы`. - [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`. - [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации. @@ -38,8 +38,8 @@ - [x] Добавить `BrokerDashboard` как top-level composition widget. - [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`. - [x] Добавить unit/component tests для helpers и базовой dashboard composition. -- [ ] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. -- [ ] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. +- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. +- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. - [ ] Проверить desktop layout `/broker/2084014113`. - [ ] Проверить mobile layout `/broker/2084014113`. -- 2.47.2 From d4d1dd980eee8cc568799375354dcf6c981d2568 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 11:48:10 +0300 Subject: [PATCH 06/19] feat: replace native date inputs with single DateCalendar field in broker dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace two MUI DatePicker components with a single visual range field - Click opens Popover with community DateCalendar (no DateRangePicker/pro) - First click sets start, second sets end; if end < start, range resets - Remove isOpen/onToggle props — Popover managed locally - Add BrokerDashboardTableSkeleton for events/income loading - Set default weekly range (today-7d / today) for both events and income - Remove 'Фильтр' label, rename 'Показать' → 'Применить период' - Install @mui/x-date-pickers@7 - Update tests for new UI and skeleton loading states - Align spec.md, plan.md, tasks.md with implementation --- apps/frontend/package.json | 2 +- .../broker-dashboard/lib/dashboardFilters.ts | 6 +- .../ui/BrokerDashboard.test.tsx | 58 +++- .../broker-dashboard/ui/BrokerDashboard.tsx | 6 - .../ui/BrokerDashboardDateFilter.tsx | 276 +++++++++++------- .../ui/BrokerDashboardEventsCard.tsx | 9 +- .../ui/BrokerDashboardIncomeCard.tsx | 9 +- .../ui/BrokerDashboardTableSkeleton.tsx | 29 ++ .../broker-dashboard-redesign/plan.md | 38 ++- .../broker-dashboard-redesign/spec.md | 40 ++- .../broker-dashboard-redesign/tasks.md | 18 +- package-lock.json | 87 ++++++ 12 files changed, 420 insertions(+), 158 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2f85446..ca8fc95 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", + "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.20.0", "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", @@ -43,7 +44,6 @@ "@biomejs/biome": "^2.5.0", "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@tanstack/router-devtools": "^1.167.0", - "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts index faa16db..39abe1e 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -18,7 +18,7 @@ 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'), + to: now.format('YYYY-MM-DD'), types: [...DASHBOARD_EVENT_TYPES], preset: '7d', } @@ -27,10 +27,10 @@ export function defaultEventsFilters(): DashboardFilterState export function defaultIncomeFilters(): DashboardFilterState { const now = dayjs() return { - from: now.startOf('year').format('YYYY-MM-DD'), + from: now.subtract(7, 'day').format('YYYY-MM-DD'), to: now.format('YYYY-MM-DD'), types: [...DASHBOARD_INCOME_TYPES], - preset: '1y', + preset: '7d', } } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index 50d6a85..38f65c6 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -1,5 +1,8 @@ +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrokerPortfolio } from '@/shared/api' import { BrokerDashboard } from './BrokerDashboard' @@ -67,6 +70,10 @@ const portfolio: BrokerPortfolio = { asOf: '2026-06-26T00:00:00.000Z', } +function renderWithProviders(ui: ReactNode) { + return render({ui}) +} + describe('BrokerDashboard', () => { beforeEach(() => { hookMocks.useBrokerEvents.mockReturnValue({ @@ -88,7 +95,7 @@ describe('BrokerDashboard', () => { }) it('renders the dashboard sections', () => { - render() + renderWithProviders() expect(screen.getByText('Основной счёт')).toBeInTheDocument() expect(screen.getByText('События')).toBeInTheDocument() @@ -99,7 +106,7 @@ describe('BrokerDashboard', () => { it('applies event type chips immediately to useBrokerEvents', async () => { const user = userEvent.setup() - render() + renderWithProviders() const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) await user.click(couponChips[0]) @@ -115,7 +122,7 @@ describe('BrokerDashboard', () => { it('applies income type chips immediately to useBrokerOperations', async () => { const user = userEvent.setup() - render() + renderWithProviders() const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) await user.click(couponChips[1]) @@ -131,14 +138,17 @@ describe('BrokerDashboard', () => { }) }) - it('shows date filter toggle button and expandable panel', async () => { + it('shows date filter toggle button with apply action', async () => { const user = userEvent.setup() - render() + renderWithProviders() - const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ }) - expect(filterButtons).toHaveLength(2) + const applyButtons = screen.getAllByRole('button', { name: /Применить период/ }) + expect(applyButtons).toHaveLength(2) - await user.click(filterButtons[0]) + const toggleButtons = screen.getAllByRole('button', { name: /📅/ }) + expect(toggleButtons).toHaveLength(2) + + await user.click(toggleButtons[0]) expect(screen.getByText('7д')).toBeInTheDocument() expect(screen.getByText('30д')).toBeInTheDocument() @@ -147,4 +157,36 @@ describe('BrokerDashboard', () => { expect(screen.getByText('Всё')).toBeInTheDocument() expect(screen.getByText('Сбросить')).toBeInTheDocument() }) + + it('shows skeleton table while events are loading', () => { + hookMocks.useBrokerEvents.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + + renderWithProviders() + + const skeletons = screen.getAllByTestId('dashboard-table-skeleton') + expect(skeletons.length).toBeGreaterThanOrEqual(1) + }) + + it('shows skeleton table while income operations are loading', () => { + hookMocks.useBrokerOperations.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + + renderWithProviders() + + const skeletons = screen.getAllByTestId('dashboard-table-skeleton') + expect(skeletons.length).toBeGreaterThanOrEqual(1) + }) + + it('does not show skeleton when data is loaded', () => { + renderWithProviders() + + expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() + }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index d4cac32..8315dd2 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -39,12 +39,10 @@ export function BrokerDashboard({ }) { const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters) const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters) - const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false) const [eventPage, setEventPage] = useState(1) const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters) const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters) - const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false) const incomePagination = useCursorPagination() const analytics = useBrokerAnalytics(accountId) @@ -199,8 +197,6 @@ export function BrokerDashboard({ onDraftToChange={handleDraftEventToChange} onApplyFilters={applyEventFilters} onResetFilters={resetEventFilters} - dateFilterOpen={eventFilterPanelOpen} - onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)} hasDraftTypes={hasDraftEventTypes} page={eventPage} canGoBack={eventPage > 1} @@ -224,8 +220,6 @@ export function BrokerDashboard({ onDraftToChange={handleDraftIncomeToChange} onApplyFilters={applyIncomeFilters} onResetFilters={resetIncomeFilters} - dateFilterOpen={incomeFilterPanelOpen} - onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)} hasDraftTypes={hasDraftIncomeTypes} pageNumber={incomePagination.pageNumber} canGoBack={incomePagination.pageNumber > 1} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx index dd27c8e..08bd69a 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx @@ -1,5 +1,10 @@ -import { Button, Chip, Text } from '@moex-vibe/design-system' -import { Box } from '@mui/material' +import { Chip, Text } from '@moex-vibe/design-system' +import { Box, Popover } from '@mui/material' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' import type { DashboardDatePreset } from '../lib/dashboardFilters' const PRESETS: { key: DashboardDatePreset; label: string }[] = [ @@ -21,8 +26,6 @@ type BrokerDashboardDateFilterProps = { onToChange: (value: string) => void onReset: () => void onApply: () => void - isOpen: boolean - onToggle: () => void } export function BrokerDashboardDateFilter({ @@ -36,121 +39,178 @@ export function BrokerDashboardDateFilter({ onToChange, onReset, onApply, - isOpen, - onToggle, }: BrokerDashboardDateFilterProps) { + const [anchorEl, setAnchorEl] = useState(null) + const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from') + const isOpen = Boolean(anchorEl) + + const handleToggle = useCallback((e: React.MouseEvent) => { + setAnchorEl((prev) => { + if (prev) return null + return e.currentTarget + }) + setSelectingStage('from') + }, []) + + const handleClose = useCallback(() => { + setAnchorEl(null) + setSelectingStage('from') + }, []) + + const handleCalendarChange = useCallback( + (date: dayjs.Dayjs | null) => { + if (!date) return + if (selectingStage === 'from') { + onFromChange(date.format('YYYY-MM-DD')) + setSelectingStage('to') + } else { + if (draftFrom && date.isBefore(dayjs(draftFrom))) { + onFromChange(date.format('YYYY-MM-DD')) + onToChange('') + } else { + onToChange(date.format('YYYY-MM-DD')) + } + setSelectingStage('from') + } + }, + [selectingStage, draftFrom, onFromChange, onToChange], + ) + + function handlePresetClick(p: { key: DashboardDatePreset }) { + onPresetChange(p.key) + setSelectingStage('from') + } + + function handleReset() { + onReset() + setSelectingStage('from') + } + + const selectingLabel = + selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' + return ( - - - 📅 Фильтр дат - {appliedLabel && ( - - {appliedLabel} - - )} - - - {isOpen && ( + + - - {PRESETS.map((p) => ( - onPresetChange(p.key)} - /> - ))} - - - onFromChange(e.target.value)} - style={{ - border: '1px solid #ccc', - borderRadius: 6, - padding: '4px 8px', - fontSize: 13, - flex: '0 1 auto', - }} - /> - - — - - onToChange(e.target.value)} - style={{ - border: '1px solid #ccc', - borderRadius: 6, - padding: '4px 8px', - fontSize: 13, - flex: '0 1 auto', - }} - /> + 📅 + {appliedLabel && ( - Сбросить + {appliedLabel} + + )} + {isOpen ? '▲' : '▼'} + + + Применить период + + + + + {PRESETS.map((p) => ( + handlePresetClick(p)} + /> + ))} + + + {selectingLabel} + + + + + Сбросить + - - )} - + + + ) } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index c0e3510..1920e67 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFi import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' +import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' const EVENT_FILTERS: Array<{ type: DashboardEventType @@ -35,8 +36,6 @@ type BrokerDashboardEventsCardProps = { onDraftToChange: (value: string) => void onApplyFilters: () => void onResetFilters: () => void - dateFilterOpen: boolean - onToggleDateFilter: () => void hasDraftTypes: boolean page: number onPreviousPage: () => void @@ -68,8 +67,6 @@ export function BrokerDashboardEventsCard({ onDraftToChange, onApplyFilters, onResetFilters, - dateFilterOpen, - onToggleDateFilter, hasDraftTypes, page, onPreviousPage, @@ -107,8 +104,6 @@ export function BrokerDashboardEventsCard({ onToChange={onDraftToChange} onReset={onResetFilters} onApply={onApplyFilters} - isOpen={dateFilterOpen} - onToggle={onToggleDateFilter} /> } @@ -118,7 +113,7 @@ export function BrokerDashboardEventsCard({ ) : isError ? ( Не удалось загрузить события ) : isLoading ? ( - Загрузка событий… + ) : events.length === 0 ? ( В ближайшем периоде событий нет ) : ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 2cfd755..606993e 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardF import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' +import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' const INCOME_FILTERS: Array<{ type: DashboardIncomeType @@ -33,8 +34,6 @@ type BrokerDashboardIncomeCardProps = { onDraftToChange: (value: string) => void onApplyFilters: () => void onResetFilters: () => void - dateFilterOpen: boolean - onToggleDateFilter: () => void hasDraftTypes: boolean pageNumber: number canGoBack: boolean @@ -59,8 +58,6 @@ export function BrokerDashboardIncomeCard({ onDraftToChange, onApplyFilters, onResetFilters, - dateFilterOpen, - onToggleDateFilter, hasDraftTypes, pageNumber, canGoBack, @@ -99,8 +96,6 @@ export function BrokerDashboardIncomeCard({ onToChange={onDraftToChange} onReset={onResetFilters} onApply={onApplyFilters} - isOpen={dateFilterOpen} - onToggle={onToggleDateFilter} /> } @@ -110,7 +105,7 @@ export function BrokerDashboardIncomeCard({ ) : isError ? ( Не удалось загрузить доходные операции ) : isLoading ? ( - Загрузка доходов… + ) : rows.length === 0 ? ( Дивидендов и купонов в последних операциях нет ) : ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx new file mode 100644 index 0000000..6c73682 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from '@moex-vibe/design-system' +import { Box } from '@mui/material' + +type BrokerDashboardTableSkeletonProps = { + rows?: number + columns?: number +} + +export function BrokerDashboardTableSkeleton({ + rows = 10, + columns = 5, +}: BrokerDashboardTableSkeletonProps) { + return ( + + {Array.from({ length: rows }, (_, i) => ( + + {Array.from({ length: columns }, (_, j) => ( + + ))} + + ))} + + ) +} diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md index 156fbae..c5d44db 100644 --- a/docs/features/broker-dashboard-redesign/plan.md +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -6,7 +6,7 @@ **Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system. -**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, Vitest, Testing Library. +**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library. --- @@ -21,7 +21,7 @@ - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий. -- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемый фильтр дат с draft/apply поведением. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами. @@ -31,6 +31,7 @@ - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels. +- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять. - Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения. ### Источники данных @@ -59,16 +60,26 @@ ### 3. События - Типы событий переключаются chip-фильтрами с немедленным применением. -- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по `Показать`. +- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`. +- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода. +- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты. +- Native `` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. +- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard. - При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. - Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. +- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. ### 4. Доходы - Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. - Типы доходов переключаются chip-фильтрами с немедленным применением. +- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии. - Диапазон дат использует тот же draft/apply паттерн, что и события. +- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`. +- Native `` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. +- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard. - Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров. +- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма. ### 5. Аналитика и аллокация @@ -118,21 +129,32 @@ - `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` - `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` - `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` +- `apps/frontend/package.json` -- [ ] Добавить переиспользуемый `BrokerDashboardDateFilter` с preset chips, полями `from/to`, действиями `Сбросить` и `Показать`. -- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по `Показать`. +- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter. +- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`. +- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. +- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. +- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. +- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`. +- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода. - [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. -- [ ] Оставить локальные loading/error/empty states внутри карточки. +- [ ] Заменить текстовую загрузку событий на skeleton таблицы. +- [ ] Оставить локальные error/empty states внутри карточки. ### Задача 4: Карточка доходов **Файлы:** - `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` - `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` - [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state. +- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года. - [ ] Оставить chip-фильтры типов доходов с немедленным применением. - [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров. +- [ ] Заменить текстовую загрузку доходов на skeleton таблицы. - [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи. ### Задача 5: Карточки аналитики и аллокации @@ -152,6 +174,9 @@ - `docs/features/broker-dashboard-redesign/tasks.md` - [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states. +- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода. +- [ ] Добавить/обновить тесты default weekly range для событий и доходов. +- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов. - [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`. - [ ] Прогнать `rtk npm run test:frontend`. - [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`. @@ -165,5 +190,6 @@ - Hero KPI и equal-height `Metric`: задача 2. - События с chip filters, date filters и локальной пагинацией: задача 3. - Доходы с chip filters, date filters и cursor pagination: задача 4. +- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6. - Аналитика и аллокация с горизонтальными барами: задача 5. - Ошибки, empty states, тесты и ручная верификация: задача 6. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md index 1fd5590..8ce7f67 100644 --- a/docs/features/broker-dashboard-redesign/spec.md +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -89,7 +89,7 @@ Hero показывает: ### 4. Блок `События` - Блок использует существующий источник `useBrokerEvents(accountId, query)`. -- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы +- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы `dividend,coupon,maturity,offer`, как в существующей вкладке событий. - Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Пользователь может выбрать несколько типов событий. @@ -98,8 +98,17 @@ Hero показывает: - Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую страницу. - Блок содержит фильтр периода `from` / `to`. -- Изменение черновых фильтров не запускает запрос до нажатия `Показать`. -- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. +- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`. +- Изменение черновых фильтров не запускает запрос до применения периода пользователем. +- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как + управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и + основное действие применения периода с более понятным текстом, чем `Показать`. +- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`. +- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из + `@mui/x-date-pickers` и ручной логикой выбора начала/конца периода. +- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются. +- Native `` не используется. - Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся обязанностью подробной вкладки `События`. - В dashboard отображается не более 10 событий на странице. @@ -124,10 +133,18 @@ Hero показывает: валидационное сообщение. - Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию. - Блок содержит фильтр периода `from` / `to`. -- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе - операций. -- Изменение черновых фильтров не запускает запрос до нажатия `Показать`. -- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. +- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал + слишком много операций при первом открытии. +- Изменение черновых фильтров не запускает запрос до применения периода пользователем. +- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как + управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и + основное действие применения периода с более понятным текстом, чем `Показать`. +- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`. +- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из + `@mui/x-date-pickers` и ручной логикой выбора начала/конца периода. +- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются. +- Native `` не используется. - Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся обязанностью подробных разделов. - Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10. @@ -162,6 +179,8 @@ Hero показывает: - Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы. - Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного контекста. +- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не + только текстовую строку загрузки. - Ошибка событий, доходов или analytics отображается внутри соответствующей карточки. - Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения. - Недоступные отдельные значения отображаются как `—`, не подменяются нулём. @@ -194,8 +213,15 @@ Hero показывает: - Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса. - Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. +- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле + периода с popover-календарём на базе community `DateCalendar`. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций. +- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле + периода с popover-календарём на базе community `DateCalendar`. +- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется + `Показать`. +- Загрузка событий и доходов отображается skeleton-таблицей. - Блок `Аналитика доходности` показывает данные существующего analytics endpoint. - Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также итоговую стоимость портфеля. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index 8423510..816167c 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -40,15 +40,23 @@ - [x] Добавить unit/component tests для helpers и базовой dashboard composition. - [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. - [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. +- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках. +- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров. +- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`. +- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`. +- [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`. +- [x] Заменить текстовую загрузку `События` на skeleton таблицы. +- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. +- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. - [ ] Проверить desktop layout `/broker/2084014113`. - [ ] Проверить mobile layout `/broker/2084014113`. ## Definition of Done -- [ ] `rtk npm run test:frontend` проходит. -- [ ] `rtk npm run test:design-system` проходит. -- [ ] `rtk npm run lint -w apps/frontend` проходит. -- [ ] `rtk npm run build:frontend` проходит. +- [x] `rtk npm run test:frontend` проходит. +- [x] `rtk npm run test:design-system` проходит. +- [x] `rtk npm run lint -w apps/frontend` проходит. +- [x] `rtk npm run build:frontend` проходит. - [ ] Dashboard соответствует acceptance criteria из `spec.md`. - [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны. -- [ ] `graphify update .` выполнен после code changes. +- [x] `graphify update .` выполнен после code changes. diff --git a/package-lock.json b/package-lock.json index f32785e..b8aa3c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", + "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.20.0", "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", @@ -7426,6 +7427,92 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, + "node_modules/@mui/x-date-pickers": { + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", + "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", -- 2.47.2 From ae207b0cb3d27dee9b311cd41962feb36b9b7fcb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 12:33:58 +0300 Subject: [PATCH 07/19] feat: improove design, add change spec --- .../broker-dashboard-redesign/plan.md | 106 ++ .../broker-dashboard-redesign/spec.md | 54 +- .../broker-dashboard-redesign/tasks.md | 35 +- .../2026-06-27-broker-account-redesign.html | 1220 +++++++++++++++++ 4 files changed, 1411 insertions(+), 4 deletions(-) create mode 100644 docs/research/2026-06-27-broker-account-redesign.html diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md index c5d44db..87edd2b 100644 --- a/docs/features/broker-dashboard-redesign/plan.md +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -8,6 +8,9 @@ **Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library. +**HTML parity update:** согласованный визуальный эталон находится в `docs/research/2026-06-27-broker-account-redesign.html`. +Следующая итерация переносит его детали в реальную страницу без изменения backend-контрактов. + --- ## Область реализации @@ -31,6 +34,8 @@ - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types. - Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` — локальные helpers визуальной семантики dashboard: tone сумм, tone типов, отображение инструмента, символ валюты. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` — unit-тесты визуальных helpers. - Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять. - Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения. @@ -93,6 +98,32 @@ - Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам. - Пустые данные показываются отдельными сообщениями, а не нулевыми значениями. +### 7. HTML parity visual layer + +- Реальная страница `/broker/:accountId` должна визуально соответствовать `docs/research/2026-06-27-broker-account-redesign.html`, + но использовать существующие React-компоненты и FSD-границы. +- Не менять backend и OpenAPI: блок `Доходы` остаётся на текущем endpoint операций и текущем наборе + income-типов. Отрицательный tone должен поддерживаться для строк, которые уже отображаются или будут + отображаться без расширения контракта. +- Ввести локальные helpers в `widgets/broker-dashboard/lib/dashboardVisual.ts`: + `moneyTone(value, source?) -> 'positive' | 'negative' | 'planned' | 'neutral'`, + `eventTypeTone(type)`, `incomeTypeTone(typeLabel)`, `formatDashboardCurrency(moneyOrValue)`, + `instrumentDisplay({ ticker, name, description })`. +- `formatDashboardCurrency` для RUB должен выводить `₽`. Для неизвестных валют использовать код валюты. +- `instrumentDisplay` должен возвращать основную строку и опциональную подпись: для событий приоритет + `ticker/isin` как main и `name` как subtitle; для операций приоритет `ticker` как main и + `name/description` как subtitle. Если ticker отсутствует, main берётся из name/description, subtitle не + дублируется. +- `BrokerDashboardCard` должен поддержать компактный заголовок карточки уровня HTML-прототипа, не + используя крупный `Heading size="title"`. +- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять + единый toolbar-паттерн для `События` и `Доходы`. +- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии + названия и semantic amount colors. +- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`. +- Skeleton таблиц событий и доходов должен использовать один компонент/паттерн и различаться только + числом колонок. + ## Задачи ### Задача 1: Базовые helpers и локальные dashboard-patterns @@ -184,6 +215,79 @@ - [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`. - [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`. +### Задача 7: Visual helpers для HTML parity + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` + +- [ ] Добавить `moneyTone`, который возвращает `negative` для отрицательных значений, `positive` для + положительных фактических значений, `planned` для прогнозных/нефактических значений и `neutral` для + нуля/недоступного значения. +- [ ] Добавить `formatDashboardCurrency`, который для `RUB` выводит `₽`, а для неизвестной валюты + оставляет код валюты. +- [ ] Добавить `instrumentDisplay` с приоритетами main/subtitle из технического решения 7. +- [ ] Добавить type tone helpers для event types и income labels. +- [ ] Расширить `DashboardIncomeRow`: хранить `instrumentMain` и `instrumentSubtitle`, сохранив + совместимость через существующий `instrument` только если это нужно текущим тестам. +- [ ] Покрыть helpers unit-тестами: RUB symbol, unknown currency fallback, negative/positive/planned + tones, event/income type tones, отсутствие дублирования subtitle. + +### Задача 8: Hero, карточка и toolbar parity + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- [ ] Применить semantic color к hero: отрицательная доходность красная, положительная зелёная, + дневное изменение окрашивается по знаку, `Всего доходов` зелёный при значении больше нуля. +- [ ] Сделать заголовки dashboard-card компактными, соответствующими HTML-прототипу. +- [ ] Собрать filters area карточек в единый toolbar: слева типы, справа поле периода и действие. +- [ ] Заменить текстовую стрелку раскрытия периода на иконку: использовать уже подключённые + `CalendarTodayRounded` и `ExpandMoreRounded` из `@mui/icons-material`, без добавления новой icon + dependency. +- [ ] Обновить component tests: проверять отсутствие текста `RUB` для RUB-значений, отсутствие символа + `v` в period button и наличие accessible name у управления периодом. + +### Задача 9: Таблицы событий и доходов как в HTML + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- [ ] Добавить `thead` в обе dashboard-таблицы с колонками из спецификации. +- [ ] В колонке инструмента выводить main и subtitle из `instrumentDisplay`. +- [ ] Заменить текстовые типы на компактные бейджи с tone из visual helpers. +- [ ] Окрашивать суммы через `moneyTone`: поступления зелёным, списания красным, прогноз/ожидание серым. +- [ ] Окрашивать статусные бейджи: `Поступило` зелёный, прогноз/ожидание серый. +- [ ] Сохранить горизонтальный scroll только внутри таблицы на mobile, без общего page overflow. +- [ ] Обновить skeleton так, чтобы `События` и `Доходы` использовали один визуальный паттерн строк. +- [ ] Обновить tests на наличие type badges, subtitle инструмента, semantic amount classes и skeleton. + +### Задача 10: Analytics parity и визуальная проверка + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` +- `docs/features/broker-dashboard-redesign/tasks.md` + +- [ ] Отображать RUB как `₽` во всех analytics KPI. +- [ ] Окрашивать analytics cards: positive для пополнений/дивидендов/купонов/всего получено, + negative для выводов и отрицательного нетто, neutral для нулевых/недоступных значений. +- [ ] Обновить component tests для analytics: `₽` вместо `RUB`, positive/negative tone cards. +- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`. +- [ ] Прогнать `rtk npm run test:frontend`. +- [ ] Прогнать `rtk npm run lint -w apps/frontend && rtk npm run build:frontend`. +- [ ] Проверить `/broker/2084014113` вручную на desktop и mobile `390x844` против + `docs/research/2026-06-27-broker-account-redesign.html`. +- [ ] После проверки обновить `docs/features/broker-dashboard-redesign/tasks.md`. + ## Проверка покрытия спецификации - Общая композиция и горизонтальная навигация: задачи 2 и 6. @@ -193,3 +297,5 @@ - Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6. - Аналитика и аллокация с горизонтальными барами: задача 5. - Ошибки, empty states, тесты и ручная верификация: задача 6. +- HTML parity: visual helpers — задача 7, hero/card/toolbar — задача 8, dashboard tables — задача 9, + analytics — задача 10. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md index 8ce7f67..d1d882f 100644 --- a/docs/features/broker-dashboard-redesign/spec.md +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -1,6 +1,6 @@ # Редизайн обзора брокерского счёта в инвестиционный дашборд -Дата: 2026-06-26 (обновлено 2026-06-27) +Дата: 2026-06-26 (обновлено 2026-06-27, HTML parity) Статус: согласовано к планированию Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) @@ -18,6 +18,11 @@ двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки dashboard идут по одному на строке в фиксированном порядке. +После интерактивной дизайн-итерации согласован статический эталон +`docs/research/2026-06-27-broker-account-redesign.html`. Реальная React-страница должна визуально соответствовать этому HTML: +компактные заголовки карточек, единая шапка таблиц, бейджи типов, подписи инструментов, семантические +цвета денежных значений и единый skeleton таблиц. + ## Цель Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий, @@ -68,10 +73,17 @@ dashboard идут по одному на строке в фиксированн - Используется текущая светлая DS-тема MoexVibe. - Не добавляется dark mode и не переносится тёмная палитра `temp.html`. - Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на - `temp.html`. + `docs/research/2026-06-27-broker-account-redesign.html`. - Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы. - Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS. +- Заголовки dashboard-карточек не должны использовать page-level размер `Heading size="title"`; + визуально они должны соответствовать компактному card heading из HTML-прототипа. +- В карточках `События` и `Доходы` фильтры должны быть собраны в единый toolbar: группа типов, единое + поле периода с иконкой календаря и chevron-иконкой, действие обновления/применения периода. +- В поле периода не используется текстовый символ `v`; раскрытие обозначается иконкой. +- Валюта в dashboard отображается символом `₽`, а не строкой `RUB`, кроме случаев, где backend вернул + другую валюту и formatter проекта не знает её символ. ### 3. Hero KPI @@ -85,6 +97,10 @@ Hero показывает: - всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена; - спокойный fallback `—` для недоступных значений. - Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края. +- Доходность в hero имеет цветовую семантику: положительная — зелёная, отрицательная — красная, + недоступная/нулевая — нейтральная. +- Дневное изменение в supporting text hero тоже окрашивается по знаку значения. +- `Всего доходов` окрашивается как положительный финансовый показатель, если значение больше нуля. ### 4. Блок `События` @@ -115,9 +131,19 @@ Hero показывает: - Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам. - Смена применённых фильтров возвращает пагинацию блока на первую страницу. - Таблица показывает дату, инструмент, тип, сумму и статус. +- Таблица содержит компактную строку заголовков колонок. +- В колонке `Инструмент` показывается тикер/ISIN и дополнительная строка с названием инструмента, если + оно доступно из `event.name`; если названия нет, дополнительная строка не занимает место. +- В колонке `Тип` значения отображаются небольшими бейджами с разными тонами для купона, дивиденда, + погашения и оферты. - Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`. - Фактические поступления визуально отмечаются как `Поступило`. - Прогнозные суммы помечаются как оценочные. +- Суммы окрашиваются по смыслу: фактическое положительное поступление зелёным, отрицательное списание + красным, прогноз/план серым. Цвет не является единственным носителем смысла: прогнозные строки также + имеют статусный текст. +- Статус отображается компактным бейджем. `Поступило` использует зелёный тон, прогноз/ожидание — + нейтральный серый тон. - Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`. - Ошибка загрузки событий не ломает остальной dashboard. @@ -150,15 +176,27 @@ Hero показывает: - Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10. - Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу. - Таблица показывает дату, инструмент, тип и сумму. +- Таблица содержит компактную строку заголовков колонок. +- В колонке `Инструмент` показывается тикер и дополнительная строка с названием операции/инструмента, + если оно доступно из `operation.name` или `operation.description`. +- В колонке `Тип` значения отображаются небольшими бейджами. +- Суммы окрашиваются по знаку: положительные поступления зелёным, отрицательные списания красным, + плановые/нефактические значения серым, если такие строки отображаются в блоке. - Блок показывает итог по отображаемым доходным операциям. - Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`. - Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API. +- В этой итерации блок `Доходы` не расширяется до полной истории комиссий/налогов; цветовая семантика + отрицательных сумм должна быть готова для отображаемых строк, но API-контракт не меняется. ### 6. Блок `Аналитика доходности` - Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`. - Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность. +- Денежные значения analytics отображаются с символом валюты `₽` для RUB. +- Карточки analytics используют цветовую семантику: положительные потоки и полученные доходы — + зелёный тон, отрицательные выводы и отрицательное нетто — красный тон, нейтральные/нулевые значения — + нейтральный тон. - При отсутствии analytics data блок показывает спокойное пустое состояние. - Ошибка analytics не ломает остальные блоки. @@ -181,6 +219,9 @@ Hero показывает: контекста. - Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не только текстовую строку загрузки. +- Skeleton таблиц событий и доходов должен иметь единый визуальный паттерн: строки соответствуют + геометрии таблицы, колонка инструмента шире остальных, для `Доходы` используется тот же паттерн без + лишней колонки статуса. - Ошибка событий, доходов или analytics отображается внутри соответствующей карточки. - Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения. - Недоступные отдельные значения отображаются как `—`, не подменяются нулём. @@ -211,18 +252,27 @@ Hero показывает: - Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего доходов или fallback. - Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса. +- Hero KPI использует цветовую семантику для доходности, дневного изменения и всего полученных доходов. - Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. +- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается + бейджем, сумма и статус имеют семантические цвета. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле периода с popover-календарём на базе community `DateCalendar`. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. +- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается + бейджем, сумма имеет семантический цвет. - Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций. - Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле периода с popover-календарём на базе community `DateCalendar`. - В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется `Показать`. +- Шапки фильтров `События` и `Доходы` визуально соответствуют единому toolbar из + `docs/research/2026-06-27-broker-account-redesign.html`. +- Поле периода использует chevron-иконку, а не текстовый символ `v`. - Загрузка событий и доходов отображается skeleton-таблицей. - Блок `Аналитика доходности` показывает данные существующего analytics endpoint. +- Блок `Аналитика доходности` отображает RUB как `₽` и использует цветовую семантику карточек. - Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также итоговую стоимость портфеля. - Ошибка одного вторичного блока не скрывает остальные блоки dashboard. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index 816167c..282b7f9 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -1,7 +1,7 @@ # Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи -Дата: 2026-06-26 -Статус: в реализации +Дата: 2026-06-26 (обновлено 2026-06-27) +Статус: в реализации, итерация HTML parity ## Документация и pre-flight @@ -51,6 +51,26 @@ - [ ] Проверить desktop layout `/broker/2084014113`. - [ ] Проверить mobile layout `/broker/2084014113`. +## Итерация HTML parity + +- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`. +- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`. +- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты. +- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. +- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. +- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. +- [ ] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. +- [ ] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. +- [ ] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. +- [ ] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. +- [ ] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. +- [ ] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. +- [ ] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек. +- [ ] Обновить component tests dashboard под HTML parity. +- [ ] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов. +- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`. +- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`. + ## Definition of Done - [x] `rtk npm run test:frontend` проходит. @@ -60,3 +80,14 @@ - [ ] Dashboard соответствует acceptance criteria из `spec.md`. - [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны. - [x] `graphify update .` выполнен после code changes. + +## Definition of Done для HTML parity + +- [ ] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. +- [ ] `rtk npm run test:frontend` проходит после HTML parity изменений. +- [ ] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений. +- [ ] `rtk npm run build:frontend` проходит после HTML parity изменений. +- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов, + цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`. +- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц. +- [ ] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения. diff --git a/docs/research/2026-06-27-broker-account-redesign.html b/docs/research/2026-06-27-broker-account-redesign.html new file mode 100644 index 0000000..b529407 --- /dev/null +++ b/docs/research/2026-06-27-broker-account-redesign.html @@ -0,0 +1,1220 @@ + + + + + + MoexVibe - брокерский счет + + + +
+
+
+
MoexVibe
+ +
+ +
+ Sergey + +
+
+ +
+
+

Брокерский счёт

+
+ + +
+
+ + + +
+
+
Инвестиционный дашборд
+
Брокерский счёт
+
+
+
+
Стоимость портфеля
+
1 884 891,72 ₽
+
+
+
+
+
Доходность
+
-6,84%
+
+
За день: +1 688,04 ₽
+
+
+
+
Всего доходов
+
186 087,53 ₽
+
+
+
+ +
+
+
+

События

+ 10 из 48 +
+ Все события +
+
+
+ Тип +
+ + + + +
+
+
+ +
+
+ + + + + +
+ +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
ДатаИнструментТипСуммаСтатус
13.05.2026
RU000A10AEF9
РЖД 001Р-37R
Купон+43,56 ₽Поступило
13.05.2026
RU000A10B024
Газпром капитал 2Р-09
Купон+326,92 ₽Поступило
13.05.2026
RU000A10B883
ЕвроТранс 001Р-06
Купон+13,97 ₽Поступило
14.05.2026
RU000A10A3Z4
Система 001Р-31
Купон+1 212,45 ₽Поступило
14.05.2026
RU000A10B461
Самолет БО-П14
Купон-87,00 ₽Налог
15.05.2026
RU000A10A4C1
ОФЗ 26241
Погашение+150,00 ₽Поступило
15.05.2026
HEAD
HeadHunter Group
Дивиденд+1 631,00 ₽Поступило
18.05.2026
RU000A109VK0
Сегежа 003P-04R
Купон~197,56 ₽Ожидается
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Доходы

+ 10 операций +
+ Все операции +
+
+
+ Тип +
+ + +
+
+
+ +
+
+ + + + + +
+ +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
ДатаИнструментТипСумма
26.06.2026
RU000A103C53
Европлан 001Р-07
Купон+649,25 ₽
26.06.2026
CNRU
Cian PLC
Дивиденд+1 060,00 ₽
25.06.2026
RU000A10AST0
Сэтл Групп 002Р-03
Купон+683,88 ₽
25.06.2026
BROKER
Брокерская комиссия
Комиссия-35,00 ₽
24.06.2026
SU26249RMFS1
ОФЗ 26249
Купон+109,70 ₽
24.06.2026
RU000A106UW3
Самолет БО-П11
Купон+90,08 ₽
24.06.2026
IRAO
Интер РАО
Дивиденд+2 860,69 ₽
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Аналитика доходности

+
+
+
+
Пополнения
113 773,03 ₽
+
Выводы
-684 753,92 ₽
+
Нетто
-570 980,89 ₽
+
Дивиденды
27 137,88 ₽
+
Купоны
158 949,65 ₽
+
Всего получено
186 087,53 ₽
+
+
+ +
+
+
+

Аллокация

+
+
+
+ Структура портфеля + 1 884 891,72 ₽ +
+
+
+
Акции539 344,64 ₽ · 28,6%
+
+
+
+
Облигации1 330 791,92 ₽ · 70,6%
+
+
+
+
Деньги14 755,16 ₽ · 0,8%
+
+
+
+
+
+
+ + + + -- 2.47.2 From af0aaeda9d82a57dfed86a184a5884302ceac2dc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 12:46:39 +0300 Subject: [PATCH 08/19] feat(frontend): add dashboard visual helpers for HTML parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce dashboardVisual.ts with moneyTone, formatDashboardCurrency, eventTypeTone, incomeTypeTone, and instrumentDisplay helpers used by the broker dashboard to match the HTML reference prototype. Extend dashboardIncome.ts: typeLabel now distinguishes DIV_EXT ('Дивиденд (внешний)'), and rows expose instrumentMain/instrumentSubtitle via instrumentDisplay while keeping instrument as a derived alias for existing callers. Add resetDashboardFilters factory in dashboardFilters.ts so Task 3 can wire the reset action without re-churning filter types. --- .../broker-dashboard/lib/dashboardFilters.ts | 6 + .../lib/dashboardIncome.test.ts | 33 ++++ .../broker-dashboard/lib/dashboardIncome.ts | 43 +++-- .../lib/dashboardVisual.test.ts | 151 ++++++++++++++++++ .../broker-dashboard/lib/dashboardVisual.ts | 71 ++++++++ 5 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts index 39abe1e..f5ba43a 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -69,3 +69,9 @@ export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): strin if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON') return [...operationTypes].join(',') } + +export function resetDashboardFilters( + factory: () => DashboardFilterState, +): DashboardFilterState { + return factory() +} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts index 7c33f73..53e3a24 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts @@ -56,6 +56,39 @@ describe('dashboardIncome', () => { expect(rows[1].amount.value).toBe(5) }) + it('marks OPERATION_TYPE_DIV_EXT as "Дивиденд (внешний)"', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIV_EXT', 12)]) + + expect(rows).toHaveLength(1) + expect(rows[0].typeLabel).toBe('Дивиденд (внешний)') + expect(rows[0].amount.value).toBe(12) + }) + + it('splits instrument into main and subtitle, keeping the legacy alias', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') + expect(rows[0].instrument).toBe('AAPL') + }) + + it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { + const base = operation('OPERATION_TYPE_COUPON', 7) + const rows = getDashboardIncomeRows([{ ...base, ticker: null, name: null, description: null }]) + + expect(rows[0].instrumentMain).toBe('—') + expect(rows[0].instrumentSubtitle).toBeNull() + expect(rows[0].instrument).toBe('—') + }) + + it('does not duplicate subtitle when name equals ticker', () => { + const base = operation('OPERATION_TYPE_COUPON', 3) + const rows = getDashboardIncomeRows([{ ...base, ticker: 'AAPL', name: 'AAPL' }]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBeNull() + }) + it('sums displayed income rows by currency', () => { const rows = getDashboardIncomeRows([ operation('OPERATION_TYPE_DIVIDEND', 10), diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts index bdd8485..8d58e10 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts @@ -1,4 +1,5 @@ import type { BrokerMoney, BrokerOperation } from '@/shared/api' +import { instrumentDisplay } from './dashboardVisual' const INCOME_TYPES = new Set([ 'OPERATION_TYPE_DIVIDEND', @@ -6,11 +7,15 @@ const INCOME_TYPES = new Set([ 'OPERATION_TYPE_COUPON', ]) +export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' + export type DashboardIncomeRow = { id: string date: string | null + instrumentMain: string + instrumentSubtitle: string | null instrument: string - typeLabel: 'Дивиденд' | 'Купон' + typeLabel: DashboardIncomeTypeLabel amount: BrokerMoney } @@ -18,25 +23,29 @@ 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' ? 'Купон' : 'Дивиденд' +function typeLabel(type: string): DashboardIncomeTypeLabel { + if (type === 'OPERATION_TYPE_COUPON') return 'Купон' + if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)' + return 'Дивиденд' } 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!, - })) + return operations.filter(isDashboardIncomeOperation).map((operation) => { + const { main, subtitle } = instrumentDisplay({ + ticker: operation.ticker, + name: operation.name, + description: operation.description, + }) + return { + id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`), + date: typeof operation.date === 'string' ? operation.date : null, + instrumentMain: main, + instrumentSubtitle: subtitle, + instrument: main, + typeLabel: typeLabel(operation.type), + amount: operation.payment!, + } + }) } export function sumDashboardIncome( diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts new file mode 100644 index 0000000..a37421d --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import type { BrokerMoney } from '@/shared/api' +import { + type DashboardMoneyLike, + eventTypeTone, + formatDashboardCurrency, + incomeTypeTone, + instrumentDisplay, + moneyTone, +} from './dashboardVisual' + +function money(currency: string, value: number): BrokerMoney { + return { currency, units: String(Math.trunc(value)), nano: 0, value } +} + +describe('formatDashboardCurrency', () => { + it('renders RUB with the ₽ symbol', () => { + const result = formatDashboardCurrency(money('RUB', 1234.56)) + expect(result).toContain('₽') + expect(result).not.toContain('RUB') + }) + + it('falls back to currency code for unknown currencies', () => { + const result = formatDashboardCurrency(money('USD', 42)) + expect(result).toContain('USD') + expect(result).not.toContain('₽') + }) + + it('falls back to currency code via the value-only shape', () => { + const result = formatDashboardCurrency({ currency: 'EUR', value: 9.99 } as DashboardMoneyLike) + expect(result).toContain('EUR') + }) + + it('returns "—" for null or undefined', () => { + expect(formatDashboardCurrency(null)).toBe('—') + expect(formatDashboardCurrency(undefined)).toBe('—') + }) +}) + +describe('moneyTone', () => { + it('returns positive for positive actual values', () => { + expect(moneyTone(10, 'actual')).toBe('positive') + }) + + it('treats undefined source as actual and returns positive', () => { + expect(moneyTone(10)).toBe('positive') + expect(moneyTone(10, undefined)).toBe('positive') + }) + + it('returns planned for positive forecast values', () => { + expect(moneyTone(10, 'forecast')).toBe('planned') + }) + + it('returns negative for negative values regardless of source', () => { + expect(moneyTone(-10, 'actual')).toBe('negative') + expect(moneyTone(-10, 'forecast')).toBe('negative') + expect(moneyTone(-0.01)).toBe('negative') + }) + + it('returns neutral for zero', () => { + expect(moneyTone(0)).toBe('neutral') + expect(moneyTone(0, 'actual')).toBe('neutral') + expect(moneyTone(0, 'forecast')).toBe('neutral') + }) + + it('returns neutral for null and undefined', () => { + expect(moneyTone(null)).toBe('neutral') + expect(moneyTone(undefined)).toBe('neutral') + expect(moneyTone(null, 'forecast')).toBe('neutral') + }) +}) + +describe('eventTypeTone', () => { + it('maps each event type to a stable tone', () => { + expect(eventTypeTone('dividend')).toBeTruthy() + expect(eventTypeTone('coupon')).toBeTruthy() + expect(eventTypeTone('maturity')).toBeTruthy() + expect(eventTypeTone('offer')).toBeTruthy() + }) + + it('returns success for dividend events', () => { + expect(eventTypeTone('dividend')).toBe('success') + }) +}) + +describe('incomeTypeTone', () => { + it('returns a tone for each supported label', () => { + expect(incomeTypeTone('Дивиденд')).toBeTruthy() + expect(incomeTypeTone('Дивиденд (внешний)')).toBeTruthy() + expect(incomeTypeTone('Купон')).toBeTruthy() + }) + + it('marks plain dividends as success', () => { + expect(incomeTypeTone('Дивиденд')).toBe('success') + }) +}) + +describe('instrumentDisplay', () => { + it('uses ticker as main and skips subtitle when ticker is the only field', () => { + expect(instrumentDisplay({ ticker: 'AAPL' })).toEqual({ main: 'AAPL', subtitle: null }) + }) + + it('uses ticker as main and name as subtitle when both are present', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.' })).toEqual({ + main: 'AAPL', + subtitle: 'Apple Inc.', + }) + }) + + it('prefers name over description when both are provided alongside ticker', () => { + expect( + instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.', description: 'Apple computer' }), + ).toEqual({ main: 'AAPL', subtitle: 'Apple Inc.' }) + }) + + it('uses name as main and skips subtitle when no ticker is provided', () => { + expect(instrumentDisplay({ name: 'Apple Inc.' })).toEqual({ + main: 'Apple Inc.', + subtitle: null, + }) + }) + + it('uses description as main when neither ticker nor name are provided', () => { + expect(instrumentDisplay({ description: 'Apple computer' })).toEqual({ + main: 'Apple computer', + subtitle: null, + }) + }) + + it('returns "—" as main when no fields are provided', () => { + expect(instrumentDisplay({})).toEqual({ main: '—', subtitle: null }) + expect(instrumentDisplay({ ticker: null, name: null, description: null })).toEqual({ + main: '—', + subtitle: null, + }) + }) + + it('does not duplicate subtitle when name equals ticker', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'AAPL' })).toEqual({ + main: 'AAPL', + subtitle: null, + }) + }) + + it('treats empty strings as missing', () => { + expect(instrumentDisplay({ ticker: '', name: '', description: '' })).toEqual({ + main: '—', + subtitle: null, + }) + }) +}) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts new file mode 100644 index 0000000..e1de1cb --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -0,0 +1,71 @@ +import type { BrokerEventItem, BrokerMoney } from '@/shared/api' + +export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral' + +export type TypeTone = 'neutral' | 'info' | 'success' | 'warning' + +export type DashboardMoneyLike = BrokerMoney | { currency: string; value: number } + +export type MoneySource = 'actual' | 'forecast' + +export function moneyTone(value: number | null | undefined, source?: MoneySource): MoneyTone { + if (value === null || value === undefined) return 'neutral' + if (value === 0) return 'neutral' + if (value < 0) return 'negative' + return source === 'forecast' ? 'planned' : 'positive' +} + +export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { + if (!money) return '—' + const value = new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 2, + }).format(money.value) + if (money.currency === 'RUB') return `${value}\u00a0₽` + return `${value}\u00a0${money.currency}` +} + +export function eventTypeTone(type: BrokerEventItem['type']): TypeTone { + switch (type) { + case 'dividend': + return 'success' + case 'coupon': + return 'info' + case 'maturity': + return 'warning' + case 'offer': + return 'neutral' + } +} + +type IncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' + +export function incomeTypeTone(label: IncomeTypeLabel): TypeTone { + switch (label) { + case 'Дивиденд': + return 'success' + case 'Дивиденд (внешний)': + return 'info' + case 'Купон': + return 'warning' + } +} + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +export function instrumentDisplay(input: { + ticker?: string | null + name?: string | null + description?: string | null +}): { main: string; subtitle: string | null } { + const ticker = nonEmpty(input.ticker) + const name = nonEmpty(input.name) + const description = nonEmpty(input.description) + + const main = ticker ?? name ?? description ?? '—' + const subtitleCandidate = name ?? description + const subtitle = subtitleCandidate && subtitleCandidate !== main ? subtitleCandidate : null + + return { main, subtitle } +} -- 2.47.2 From 1a2937ad58fcdda02c2c443665c7c240cae08a7b Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 12:57:03 +0300 Subject: [PATCH 09/19] refactor(frontend): tighten dashboard visual helpers per code review --- .../broker-dashboard/lib/dashboardFilters.ts | 6 --- .../lib/dashboardVisual.test.ts | 38 +++++++++---------- .../broker-dashboard/lib/dashboardVisual.ts | 25 ++++++++---- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts index f5ba43a..39abe1e 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -69,9 +69,3 @@ export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): strin if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON') return [...operationTypes].join(',') } - -export function resetDashboardFilters( - factory: () => DashboardFilterState, -): DashboardFilterState { - return factory() -} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts index a37421d..aa1c4a3 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { BrokerMoney } from '@/shared/api' import { - type DashboardMoneyLike, eventTypeTone, formatDashboardCurrency, incomeTypeTone, @@ -14,21 +13,28 @@ function money(currency: string, value: number): BrokerMoney { } describe('formatDashboardCurrency', () => { - it('renders RUB with the ₽ symbol', () => { + it('renders RUB with the ₽ symbol via the shared formatter', () => { const result = formatDashboardCurrency(money('RUB', 1234.56)) expect(result).toContain('₽') expect(result).not.toContain('RUB') }) - it('falls back to currency code for unknown currencies', () => { + it('renders known non-RUB currencies via the shared formatter', () => { const result = formatDashboardCurrency(money('USD', 42)) - expect(result).toContain('USD') + expect(result).toContain('$') + expect(result).not.toContain('USD') expect(result).not.toContain('₽') }) + it('falls back to currency code for unknown currencies', () => { + const result = formatDashboardCurrency(money('XYZ', 100)) + expect(result).toContain('XYZ') + expect(result).not.toContain('¤') + }) + it('falls back to currency code via the value-only shape', () => { - const result = formatDashboardCurrency({ currency: 'EUR', value: 9.99 } as DashboardMoneyLike) - expect(result).toContain('EUR') + const result = formatDashboardCurrency({ currency: 'XYZ', value: 9.99 }) + expect(result).toContain('XYZ') }) it('returns "—" for null or undefined', () => { @@ -72,26 +78,18 @@ describe('moneyTone', () => { describe('eventTypeTone', () => { it('maps each event type to a stable tone', () => { - expect(eventTypeTone('dividend')).toBeTruthy() - expect(eventTypeTone('coupon')).toBeTruthy() - expect(eventTypeTone('maturity')).toBeTruthy() - expect(eventTypeTone('offer')).toBeTruthy() - }) - - it('returns success for dividend events', () => { expect(eventTypeTone('dividend')).toBe('success') + expect(eventTypeTone('coupon')).toBe('info') + expect(eventTypeTone('maturity')).toBe('warning') + expect(eventTypeTone('offer')).toBe('neutral') }) }) describe('incomeTypeTone', () => { - it('returns a tone for each supported label', () => { - expect(incomeTypeTone('Дивиденд')).toBeTruthy() - expect(incomeTypeTone('Дивиденд (внешний)')).toBeTruthy() - expect(incomeTypeTone('Купон')).toBeTruthy() - }) - - it('marks plain dividends as success', () => { + it('maps each income label to a stable tone', () => { expect(incomeTypeTone('Дивиденд')).toBe('success') + expect(incomeTypeTone('Дивиденд (внешний)')).toBe('info') + expect(incomeTypeTone('Купон')).toBe('warning') }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts index e1de1cb..59dcdf3 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -1,4 +1,6 @@ import type { BrokerEventItem, BrokerMoney } from '@/shared/api' +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters' +import type { DashboardIncomeTypeLabel } from './dashboardIncome' export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral' @@ -17,11 +19,22 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { if (!money) return '—' - const value = new Intl.NumberFormat('ru-RU', { + try { + const formatted = formatBrokerCurrencyValue(money.currency, money.value) + if (formatted.includes('¤')) { + return formatCurrencyCodeFallback(money.value, money.currency) + } + return formatted + } catch { + return formatCurrencyCodeFallback(money.value, money.currency) + } +} + +function formatCurrencyCodeFallback(value: number, currency: string): string { + const numberPart = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2, - }).format(money.value) - if (money.currency === 'RUB') return `${value}\u00a0₽` - return `${value}\u00a0${money.currency}` + }).format(value) + return `${numberPart}\u00a0${currency}` } export function eventTypeTone(type: BrokerEventItem['type']): TypeTone { @@ -37,9 +50,7 @@ export function eventTypeTone(type: BrokerEventItem['type']): TypeTone { } } -type IncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' - -export function incomeTypeTone(label: IncomeTypeLabel): TypeTone { +export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone { switch (label) { case 'Дивиденд': return 'success' -- 2.47.2 From 0a1ae0552c13b6c929ebf34ece6b62f8efa84307 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 13:07:06 +0300 Subject: [PATCH 10/19] feat(frontend): apply hero, card and toolbar HTML parity --- .../ui/BrokerDashboard.test.tsx | 26 +++++++- .../broker-dashboard/ui/BrokerDashboard.tsx | 4 -- .../ui/BrokerDashboardCard.tsx | 4 +- .../ui/BrokerDashboardDateFilter.tsx | 59 +++++++++++++------ .../ui/BrokerDashboardEventsCard.tsx | 16 ++++- .../ui/BrokerDashboardHero.tsx | 43 ++++++++++++-- .../ui/BrokerDashboardIncomeCard.tsx | 16 ++++- 7 files changed, 133 insertions(+), 35 deletions(-) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index 38f65c6..944d1ed 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -138,14 +138,14 @@ describe('BrokerDashboard', () => { }) }) - it('shows date filter toggle button with apply action', async () => { + it('shows date filter toggle button with apply action and accessible name', async () => { const user = userEvent.setup() renderWithProviders() const applyButtons = screen.getAllByRole('button', { name: /Применить период/ }) expect(applyButtons).toHaveLength(2) - const toggleButtons = screen.getAllByRole('button', { name: /📅/ }) + const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) expect(toggleButtons).toHaveLength(2) await user.click(toggleButtons[0]) @@ -158,6 +158,28 @@ describe('BrokerDashboard', () => { expect(screen.getByText('Сбросить')).toBeInTheDocument() }) + it('does not render a text chevron glyph inside the period toggle button', () => { + renderWithProviders() + + const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) + expect(toggleButtons).toHaveLength(2) + for (const button of toggleButtons) { + const text = button.textContent ?? '' + expect(text).not.toMatch(/[▼▲vV]/) + expect(button.querySelector('svg')).not.toBeNull() + } + }) + + it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => { + renderWithProviders() + + expect(screen.getByText('Всего доходов')).toBeInTheDocument() + const hero = screen.getByLabelText('Ключевые показатели брокерского счёта') + const heroText = hero.textContent ?? '' + expect(heroText).toContain('₽') + expect(heroText).not.toContain('RUB') + }) + it('shows skeleton table while events are loading', () => { hookMocks.useBrokerEvents.mockReturnValue({ data: undefined, diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index 8315dd2..ffcee25 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -123,7 +123,6 @@ export function BrokerDashboard({ to: draftEventFilters.to, preset: draftEventFilters.preset, })) - setEventFilterPanelOpen(false) setEventPage(1) }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) @@ -131,7 +130,6 @@ export function BrokerDashboard({ const defaults = defaultEventsFilters() setAppliedEventFilters(defaults) setDraftEventFilters(defaults) - setEventFilterPanelOpen(false) setEventPage(1) }, []) @@ -142,7 +140,6 @@ export function BrokerDashboard({ to: draftIncomeFilters.to, preset: draftIncomeFilters.preset, })) - setIncomeFilterPanelOpen(false) incomePagination.reset() }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) @@ -150,7 +147,6 @@ export function BrokerDashboard({ const defaults = defaultIncomeFilters() setAppliedIncomeFilters(defaults) setDraftIncomeFilters(defaults) - setIncomeFilterPanelOpen(false) incomePagination.reset() }, [incomePagination]) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx index b572c95..abd1a07 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx @@ -40,7 +40,9 @@ export function BrokerDashboardCard({ mb: 1.5, }} > - {title} + + {title} + {action} {filters && {filters}} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx index 08bd69a..6384840 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx @@ -1,4 +1,6 @@ import { Chip, Text } from '@moex-vibe/design-system' +import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded' +import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded' import { Box, Popover } from '@mui/material' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { DateCalendar } from '@mui/x-date-pickers/DateCalendar' @@ -88,6 +90,7 @@ export function BrokerDashboardDateFilter({ const selectingLabel = selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' + const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период' return ( @@ -96,30 +99,38 @@ export function BrokerDashboardDateFilter({ component="button" type="button" onClick={handleToggle} + aria-label={periodAriaLabel} + aria-expanded={isOpen} sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, - bgcolor: 'grey.800', - color: 'common.white', - border: 'none', - borderRadius: 1.5, - px: 1.5, - py: 0.75, - fontSize: 13, + bgcolor: 'background.paper', + color: 'text.primary', + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + px: 1.25, + py: 0.5, + fontSize: 12, + fontWeight: 600, cursor: 'pointer', - '&:hover': { bgcolor: 'grey.700' }, + '&:hover': { borderColor: 'primary.main' }, }} > - 📅 + {appliedLabel && ( )} - {isOpen ? '▲' : '▼'} + Все события} filters={ - - + + {EVENT_FILTERS.map((filter) => ( + {percentValue(returnPercent)} + + } + supportingText={ + + За день: {formatBrokerMoney(portfolio.yields.daily)} + + } /> - + + {totalReceivedDisplay} + + } + /> ) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 606993e..6c32cae 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -73,8 +73,20 @@ export function BrokerDashboardIncomeCard({ title="Доходы" action={Все операции} filters={ - - + + {INCOME_FILTERS.map((filter) => ( Date: Sat, 27 Jun 2026 13:21:15 +0300 Subject: [PATCH 11/19] fix(frontend): align dashboard card heading hierarchy BrokerDashboardCard previously rendered , which produced an

under the

account name in BrokerAccountLayout, skipping the

level. Use level={2} so the semantic HTML is a proper

while keeping the compact "section" visual size. Also extract the byte-identical toolbar wrapper (grid + chips + date filter slot) shared by BrokerDashboardEventsCard and BrokerDashboardIncomeCard into BrokerDashboardTableToolbar to remove duplicated sx config. --- .../ui/BrokerDashboardCard.tsx | 2 +- .../ui/BrokerDashboardEventsCard.tsx | 36 +++++++------------ .../ui/BrokerDashboardIncomeCard.tsx | 36 +++++++------------ .../ui/BrokerDashboardTableToolbar.tsx | 28 +++++++++++++++ 4 files changed, 53 insertions(+), 49 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx index abd1a07..301ec6f 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx @@ -40,7 +40,7 @@ export function BrokerDashboardCard({ mb: 1.5, }} > - + {title} {action} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index ed6c469..26cb870 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -8,6 +8,7 @@ import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' +import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const EVENT_FILTERS: Array<{ type: DashboardEventType @@ -81,30 +82,17 @@ export function BrokerDashboardEventsCard({ title="События" action={Все события} filters={ - ( + onToggleType(filter.type)} + /> + ))} > - - {EVENT_FILTERS.map((filter) => ( - onToggleType(filter.type)} - /> - ))} - - + } > {selectedTypes.length === 0 ? ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 6c32cae..412fcc4 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -8,6 +8,7 @@ import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardInco import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' +import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const INCOME_FILTERS: Array<{ type: DashboardIncomeType @@ -73,30 +74,17 @@ export function BrokerDashboardIncomeCard({ title="Доходы" action={Все операции} filters={ - ( + onToggleType(filter.type)} + /> + ))} > - - {INCOME_FILTERS.map((filter) => ( - onToggleType(filter.type)} - /> - ))} - - + } > {selectedTypes.length === 0 ? ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx new file mode 100644 index 0000000..02b5e36 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx @@ -0,0 +1,28 @@ +import { Box } from '@mui/material' +import type { ReactNode } from 'react' + +type BrokerDashboardTableToolbarProps = { + chips: ReactNode + children: ReactNode +} + +export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboardTableToolbarProps) { + return ( + + {chips} + {children} + + ) +} -- 2.47.2 From 3bad694dce6158e86b5b936c8b74a535412d2c34 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 13:33:02 +0300 Subject: [PATCH 12/19] feat(frontend): align dashboard tables with HTML parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add thead with semantic column headers to events and income tables - Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay - Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT shows distinct label 'Дивиденд (внешний)' - Amount column applies moneyTone (positive=green, negative=red, planned=neutral) via moneyToneToColor; sign prefixes (+/-/~) preserved per spec - Status column: 'Поступило' (success) vs 'Ожидается' (neutral) - Income sum semantics preserved (sum of current page rows) - Skeleton: unified column-width and variant pattern for both tables - Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle) - Extract moneyToneToColor helper from hero (single source of MUI color mapping) - Dashboard tests cover thead, badges, subtitles, signed tones, and that tables show ₽ instead of RUB for RUB amounts --- .../lib/dashboardFormatters.ts | 3 +- .../lib/dashboardIncome.test.ts | 4 +- .../broker-dashboard/lib/dashboardIncome.ts | 2 - .../lib/dashboardVisual.test.ts | 10 + .../broker-dashboard/lib/dashboardVisual.ts | 12 + .../ui/BrokerDashboard.test.tsx | 228 +++++++++++++++++- .../ui/BrokerDashboardEventsCard.tsx | 200 ++++++++++----- .../ui/BrokerDashboardHero.tsx | 23 +- .../ui/BrokerDashboardIncomeCard.tsx | 161 +++++++++---- .../ui/BrokerDashboardTableSkeleton.tsx | 74 +++++- 10 files changed, 580 insertions(+), 137 deletions(-) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts index dbf7f2e..bc05bfb 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts @@ -19,6 +19,5 @@ export function eventTypeLabel(type: BrokerEventItem['type']): string { export function eventStatusLabel(event: BrokerEventItem): string { if (event.source === 'actual') return 'Поступило' - if (event.type === 'offer') return 'Оферта' - 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 index 53e3a24..95f529c 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts @@ -64,12 +64,11 @@ describe('dashboardIncome', () => { expect(rows[0].amount.value).toBe(12) }) - it('splits instrument into main and subtitle, keeping the legacy alias', () => { + it('splits instrument into main and subtitle', () => { const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) expect(rows[0].instrumentMain).toBe('AAPL') expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') - expect(rows[0].instrument).toBe('AAPL') }) it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { @@ -78,7 +77,6 @@ describe('dashboardIncome', () => { expect(rows[0].instrumentMain).toBe('—') expect(rows[0].instrumentSubtitle).toBeNull() - expect(rows[0].instrument).toBe('—') }) it('does not duplicate subtitle when name equals ticker', () => { diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts index 8d58e10..c377546 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts @@ -14,7 +14,6 @@ export type DashboardIncomeRow = { date: string | null instrumentMain: string instrumentSubtitle: string | null - instrument: string typeLabel: DashboardIncomeTypeLabel amount: BrokerMoney } @@ -41,7 +40,6 @@ export function getDashboardIncomeRows(operations: BrokerOperation[]): Dashboard date: typeof operation.date === 'string' ? operation.date : null, instrumentMain: main, instrumentSubtitle: subtitle, - instrument: main, typeLabel: typeLabel(operation.type), amount: operation.payment!, } diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts index aa1c4a3..4082f8d 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -6,6 +6,7 @@ import { incomeTypeTone, instrumentDisplay, moneyTone, + moneyToneToColor, } from './dashboardVisual' function money(currency: string, value: number): BrokerMoney { @@ -85,6 +86,15 @@ describe('eventTypeTone', () => { }) }) +describe('moneyToneToColor', () => { + it('maps each MoneyTone to a stable MUI color', () => { + expect(moneyToneToColor('positive')).toBe('success.main') + expect(moneyToneToColor('negative')).toBe('error.main') + expect(moneyToneToColor('planned')).toBe('text.disabled') + expect(moneyToneToColor('neutral')).toBe('text.disabled') + }) +}) + describe('incomeTypeTone', () => { it('maps each income label to a stable tone', () => { expect(incomeTypeTone('Дивиденд')).toBe('success') diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts index 59dcdf3..325cce5 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -17,6 +17,18 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource return source === 'forecast' ? 'planned' : 'positive' } +export function moneyToneToColor(tone: MoneyTone): string { + switch (tone) { + case 'positive': + return 'success.main' + case 'negative': + return 'error.main' + case 'planned': + case 'neutral': + return 'text.disabled' + } +} + export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { if (!money) return '—' try { diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx index 944d1ed..b60b003 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -1,10 +1,10 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrokerPortfolio } from '@/shared/api' +import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api' import { BrokerDashboard } from './BrokerDashboard' const hookMocks = vi.hoisted(() => ({ @@ -74,6 +74,78 @@ function renderWithProviders(ui: ReactNode) { return render({ui}) } +function buildEvent(overrides: Partial = {}): BrokerEventItem { + return { + id: 'evt-1', + type: 'coupon', + source: 'actual', + category: 'cashflow', + eventDate: '2026-06-15T00:00:00.000Z', + paymentDate: null, + ticker: 'RU000A10AEF9', + name: 'РЖД 001Р-37R', + instrumentUid: null, + instrumentType: 'bond', + quantitySnapshot: null, + payoutPerUnit: null, + estimatedAmount: null, + actualAmount: 43.56, + currency: 'RUB', + estimateMode: null, + ...overrides, + } +} + +function buildOperation(overrides: Partial = {}): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-15T00:00:00.000Z', + type: 'OPERATION_TYPE_COUPON', + category: 'income', + description: null, + name: 'ОФЗ 26241', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: 'SU26249RMFS1', + classCode: null, + instrumentType: 'bond', + payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + ...overrides, + } +} + +function mockEventsLoaded(items: BrokerEventItem[]) { + hookMocks.useBrokerEvents.mockReturnValue({ + data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, + isLoading: false, + isError: false, + }) +} + +function mockOperationsLoaded(items: BrokerOperation[]) { + hookMocks.useBrokerOperations.mockReturnValue({ + data: { + accountId: 'acc-1', + items, + nextCursor: null, + hasNext: false, + asOf: '2026-06-26T00:00:00.000Z', + }, + isLoading: false, + isError: false, + }) +} + describe('BrokerDashboard', () => { beforeEach(() => { hookMocks.useBrokerEvents.mockReturnValue({ @@ -211,4 +283,156 @@ describe('BrokerDashboard', () => { expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() }) + + it('renders events table thead with semantic column headers', () => { + mockEventsLoaded([buildEvent()]) + + renderWithProviders() + + const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + expect(eventsTable.querySelector('thead')).not.toBeNull() + const headers = within(eventsTable).getAllByRole('columnheader') + expect(headers.map((h) => h.textContent)).toEqual([ + 'Дата', + 'Инструмент', + 'Тип', + 'Сумма', + 'Статус', + ]) + }) + + it('renders income table thead without status column', () => { + mockOperationsLoaded([buildOperation()]) + + renderWithProviders() + + const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') + expect(incomeTable.querySelector('thead')).not.toBeNull() + const headers = within(incomeTable).getAllByRole('columnheader') + expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма']) + }) + + it('renders event type badge, instrument subtitle and status pill for loaded events', () => { + mockEventsLoaded([ + buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }), + buildEvent({ + id: 'evt-forecast', + source: 'forecast', + type: 'dividend', + actualAmount: null, + estimatedAmount: 197.56, + }), + ]) + + renderWithProviders() + + const rows = screen.getAllByTestId('dashboard-events-row') + expect(rows).toHaveLength(2) + + const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle') + expect(subtitles).toHaveLength(2) + for (const subtitle of subtitles) { + expect(subtitle.textContent).toBe('РЖД 001Р-37R') + } + + const amounts = screen.getAllByTestId('dashboard-events-amount') + expect(amounts).toHaveLength(2) + expect(amounts[0].getAttribute('data-tone')).toBe('positive') + expect(amounts[0].textContent).toContain('+43,56') + expect(amounts[1].getAttribute('data-tone')).toBe('planned') + expect(amounts[1].textContent).toContain('~197,56') + + expect(screen.getByText('Купон')).toBeInTheDocument() + expect(screen.getByText('Дивиденд')).toBeInTheDocument() + expect(screen.getByText('Поступило')).toBeInTheDocument() + expect(screen.getByText('Ожидается')).toBeInTheDocument() + }) + + it('uses negative tone when event actual amount is negative', () => { + mockEventsLoaded([ + buildEvent({ + id: 'evt-tax', + type: 'coupon', + source: 'actual', + actualAmount: -87.0, + }), + ]) + + renderWithProviders() + + const [amount] = screen.getAllByTestId('dashboard-events-amount') + expect(amount.getAttribute('data-tone')).toBe('negative') + expect(amount.textContent).toContain('-87,00') + }) + + it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => { + mockEventsLoaded([ + buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }), + buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }), + ]) + mockOperationsLoaded([ + buildOperation({ + id: 'op-positive', + type: 'OPERATION_TYPE_COUPON', + ticker: 'SU26249RMFS1', + name: 'ОФЗ 26241', + payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, + }), + buildOperation({ + id: 'op-negative', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '35', nano: 0, value: -35 }, + }), + ]) + + renderWithProviders() + + const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') + for (const table of [eventsTable, incomeTable]) { + expect(table.textContent ?? '').toContain('₽') + expect(table.textContent ?? '').not.toContain('RUB') + } + }) + + it('renders income rows with main + subtitle, type badge and signed amount tone', () => { + mockOperationsLoaded([ + buildOperation({ + id: 'op-div', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 }, + }), + buildOperation({ + id: 'op-div-ext', + type: 'OPERATION_TYPE_DIV_EXT', + ticker: 'AAPL', + name: 'Apple Inc.', + payment: { currency: 'RUB', units: '100', nano: 0, value: -35 }, + }), + ]) + + renderWithProviders() + + const rows = screen.getAllByTestId('dashboard-income-row') + expect(rows).toHaveLength(2) + + const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main') + expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL']) + + const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle') + expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.']) + + expect(screen.getByText('Дивиденд')).toBeInTheDocument() + expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument() + + const amounts = screen.getAllByTestId('dashboard-income-amount') + expect(amounts[0].getAttribute('data-tone')).toBe('positive') + expect(amounts[0].textContent).toContain('+649,25') + expect(amounts[1].getAttribute('data-tone')).toBe('negative') + expect(amounts[1].textContent).toContain('−35,00') + }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index 26cb870..13a3e71 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -2,9 +2,18 @@ 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 { formatBrokerDate } from '@/shared/lib/formatters' import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' +import { + eventTypeTone, + formatDashboardCurrency, + instrumentDisplay, + type MoneyTone, + moneyTone, + moneyToneToColor, + type TypeTone, +} from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' @@ -13,7 +22,7 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const EVENT_FILTERS: Array<{ type: DashboardEventType label: string - tone: 'success' | 'info' | 'warning' | 'neutral' + tone: TypeTone }> = [ { type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'coupon', label: 'Купоны', tone: 'info' }, @@ -21,6 +30,38 @@ const EVENT_FILTERS: Array<{ { type: 'offer', label: 'Оферты', tone: 'neutral' }, ] +const TH_SX = { + textAlign: 'left' as const, + borderBottom: '1px solid', + borderColor: 'divider', + px: 1.25, + py: 1, + color: 'text.secondary', + fontWeight: 600, + fontSize: 12, + letterSpacing: 0.2, + textTransform: 'uppercase' as const, + whiteSpace: 'nowrap' as const, +} + +const TD_SX_LEFT = { + px: 1.25, + py: 1, + borderBottom: '1px solid', + borderColor: 'divider', + verticalAlign: 'middle' as const, +} + +const TD_SX_RIGHT = { + ...TD_SX_LEFT, + textAlign: 'right' as const, +} + +const TD_SX_INSTRUMENT = { + ...TD_SX_LEFT, + minWidth: 180, +} + type BrokerDashboardEventsCardProps = { accountId: string data: BrokerEventsData | undefined @@ -45,11 +86,16 @@ type BrokerDashboardEventsCardProps = { 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)}` +function eventStatusTone(event: BrokerEventItem): TypeTone { + return event.source === 'actual' ? 'success' : 'neutral' +} + +function eventAmountValue(event: BrokerEventItem): number | null { + return event.source === 'actual' ? event.actualAmount : event.estimatedAmount +} + +function eventMoneyTone(event: BrokerEventItem): MoneyTone { + return moneyTone(eventAmountValue(event), event.source) } export function BrokerDashboardEventsCard({ @@ -119,58 +165,96 @@ export function BrokerDashboardEventsCard({ ) : ( - - - {events.map((event) => ( - - - {formatBrokerDate(event.eventDate)} - - - {event.ticker ?? event.name ?? '\u2014'} - - - {eventTypeLabel(event.type)} - - - {eventAmount(event)} - - - {eventStatusLabel(event)} - + + + + + Дата - ))} + + Инструмент + + + Тип + + + Сумма + + + Статус + + + + + {events.map((event) => { + const display = instrumentDisplay({ + ticker: event.ticker, + name: event.name, + }) + const amount = eventAmountValue(event) + const formattedAmount = + amount === null || amount === undefined + ? '—' + : `${event.source === 'actual' ? '+' : '~'}${formatDashboardCurrency({ + currency: event.currency ?? 'RUB', + value: amount, + })}` + return ( + + + {formatBrokerDate(event.eventDate) ?? '—'} + + + + + {display.main} + + {display.subtitle ? ( + + {display.subtitle} + + ) : null} + + + + + + + {formattedAmount} + + + + + + ) + })} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx index 3cd4138..349da27 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx @@ -2,7 +2,7 @@ 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' -import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual' +import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual' type BrokerDashboardHeroProps = { portfolio: BrokerPortfolio @@ -13,18 +13,6 @@ function percentValue(value: unknown): string { return typeof value === 'number' ? formatBrokerPercent(value) : '—' } -function toneToColor(tone: MoneyTone): string { - switch (tone) { - case 'positive': - return 'success.main' - case 'negative': - return 'error.main' - case 'planned': - case 'neutral': - return 'text.disabled' - } -} - export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) { const accountName = portfolio.account.name || 'Брокерский счёт' const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent @@ -66,12 +54,12 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer + {percentValue(returnPercent)} } supportingText={ - + За день: {formatBrokerMoney(portfolio.yields.daily)} } @@ -81,7 +69,10 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer + {totalReceivedDisplay} } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index 412fcc4..2859589 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -2,9 +2,15 @@ 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 { formatBrokerDate } from '@/shared/lib/formatters' import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' +import { + formatDashboardCurrency, + incomeTypeTone, + moneyToneToColor, + type TypeTone, +} from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' @@ -13,12 +19,44 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' const INCOME_FILTERS: Array<{ type: DashboardIncomeType label: string - tone: 'success' | 'info' + tone: TypeTone }> = [ { type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'coupon', label: 'Купоны', tone: 'info' }, ] +const TH_SX = { + textAlign: 'left' as const, + borderBottom: '1px solid', + borderColor: 'divider', + px: 1.25, + py: 1, + color: 'text.secondary', + fontWeight: 600, + fontSize: 12, + letterSpacing: 0.2, + textTransform: 'uppercase' as const, + whiteSpace: 'nowrap' as const, +} + +const TD_SX_LEFT = { + px: 1.25, + py: 1, + borderBottom: '1px solid', + borderColor: 'divider', + verticalAlign: 'middle' as const, +} + +const TD_SX_RIGHT = { + ...TD_SX_LEFT, + textAlign: 'right' as const, +} + +const TD_SX_INSTRUMENT = { + ...TD_SX_LEFT, + minWidth: 200, +} + type BrokerDashboardIncomeCardProps = { accountId: string page: BrokerOperationsPage | undefined @@ -111,54 +149,89 @@ export function BrokerDashboardIncomeCard({ ) : ( - - - {rows.map((row) => ( - - - {formatBrokerDate(row.date)} - - - {row.instrument} - - - {row.typeLabel} - - - +{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)} - + + + + + Дата - ))} + + Инструмент + + + Тип + + + Сумма + + + + + {rows.map((row) => { + const formattedAmount = `${row.amount.value >= 0 ? '+' : '−'}${formatDashboardCurrency( + { currency: row.amount.currency, value: Math.abs(row.amount.value) }, + )}` + const amountTone = row.amount.value >= 0 ? 'positive' : 'negative' + return ( + + + {formatBrokerDate(row.date) ?? '—'} + + + + + {row.instrumentMain} + + {row.instrumentSubtitle ? ( + + {row.instrumentSubtitle} + + ) : null} + + + + + + + {formattedAmount} + + + ) + })} Показано: {rows.length} · Итого:{' '} - {total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'} + {total + ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({ + currency: total.currency, + value: Math.abs(total.value), + })}` + : '—'} - - {page} + + + Показано {events.length} событий за выбранный период - + + + + {page} + + + )} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx index b91902d..8101cc9 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -73,6 +73,7 @@ type BrokerDashboardIncomeCardProps = { onApplyFilters: () => void onResetFilters: () => void hasDraftTypes: boolean + visibleCount: number pageNumber: number canGoBack: boolean canGoForward: boolean @@ -97,6 +98,7 @@ export function BrokerDashboardIncomeCard({ onApplyFilters, onResetFilters, hasDraftTypes, + visibleCount, pageNumber, canGoBack, canGoForward, @@ -109,6 +111,7 @@ export function BrokerDashboardIncomeCard({ return ( 0 ? `${visibleCount} операции` : undefined} action={Все операции} filters={ - - Показано: {rows.length} · Итого:{' '} - {total - ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({ - currency: total.currency, - value: Math.abs(total.value), - })}` - : '—'} - - - - - {pageNumber} + + + Показано {rows.length} · Итого:{' '} + {total + ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({ + currency: total.currency, + value: Math.abs(total.value), + })}` + : '—'} - + + + + {pageNumber} + + + )} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx index 02b5e36..5493ac4 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx @@ -11,7 +11,7 @@ export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboard - {chips} + + + Тип + + {chips} + {children} ) diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md index 87edd2b..fd78c1d 100644 --- a/docs/features/broker-dashboard-redesign/plan.md +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -65,7 +65,8 @@ ### 3. События - Типы событий переключаются chip-фильтрами с немедленным применением. -- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`. +- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал + ближайшие будущие события. - Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода. - В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты. - Native `` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. @@ -73,6 +74,7 @@ - При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. - Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. - При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. +- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона. ### 4. Доходы @@ -118,6 +120,7 @@ используя крупный `Heading size="title"`. - `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять единый toolbar-паттерн для `События` и `Доходы`. +- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём. - Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии названия и semantic amount colors. - `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`. @@ -168,7 +171,7 @@ - [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. - [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. - [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. -- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`. +- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`. - [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода. - [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. - [ ] Заменить текстовую загрузку событий на skeleton таблицы. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md index d1d882f..ac37cc3 100644 --- a/docs/features/broker-dashboard-redesign/spec.md +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -105,7 +105,7 @@ Hero показывает: ### 4. Блок `События` - Блок использует существующий источник `useBrokerEvents(accountId, query)`. -- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы +- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы `dividend,coupon,maturity,offer`, как в существующей вкладке событий. - Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Пользователь может выбрать несколько типов событий. @@ -114,7 +114,8 @@ Hero показывает: - Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую страницу. - Блок содержит фильтр периода `from` / `to`. -- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`. +- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор + сразу показывал ближайшие будущие события. - Изменение черновых фильтров не запускает запрос до применения периода пользователем. - В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. @@ -257,7 +258,7 @@ Hero показывает: - В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается бейджем, сумма и статус имеют семантические цвета. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. -- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле +- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле периода с popover-календарём на базе community `DateCalendar`. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md index 1ebdd33..30661d9 100644 --- a/docs/features/broker-dashboard-redesign/tasks.md +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -44,7 +44,8 @@ - [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров. - [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`. - [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`. -- [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`. +- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на + `сегодня - 7 дней` / `сегодня`. - [x] Заменить текстовую загрузку `События` на skeleton таблицы. - [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. - [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. @@ -56,18 +57,27 @@ - [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`. - [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`. - [x] Обновить `plan.md` под перенос HTML parity в React-компоненты. -- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. -- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. +- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. +- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. - [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. -- [ ] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. -- [ ] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. -- [ ] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. -- [ ] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. -- [ ] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. -- [ ] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. +- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. +- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. +- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. +- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. +- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. + Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`), + `Дивиденд (внешний)` отнесён к `success` (семейство дивидендов). +- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. - [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек. - [x] Обновить component tests dashboard под HTML parity (analytics tones). -- [ ] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов. +- [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов. + Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01`–`2099-12-31` + вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter + кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать + молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`). +- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события. +- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к + `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`. @@ -127,4 +137,3 @@ HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145 - Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow. Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`, `BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном. - -- 2.47.2