diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx new file mode 100644 index 0000000..0db41a6 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx @@ -0,0 +1,135 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; +import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios'; + +vi.mock('../api/broker', () => ({ + getBrokerPortfolio: vi.fn(), +})); + +function createWrapper(queryClient?: QueryClient) { + const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createAccount(id: string): BrokerAccount { + return { + id, + type: 'brokerage', + name: id, + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }; +} + +function createPortfolio(id: string): BrokerPortfolio { + return { + account: createAccount(id), + positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 }, + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + bonds: null, + etf: null, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 }, + }, + yields: { + expectedPercent: 3, + daily: { currency: 'RUB', units: '0', nano: 0, value: 10 }, + dailyPercent: 1, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + }; +} + +describe('useBrokerAccountPortfolios', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps account-to-query mapping regardless of completion order', async () => { + const first = createDeferred<{ + data: BrokerPortfolio; + meta: { fromCache: false; cachedAt: null }; + }>(); + const second = createDeferred<{ + data: BrokerPortfolio; + meta: { fromCache: false; cachedAt: null }; + }>(); + + vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => { + if (accountId === 'acc-1') { + return first.promise; + } + + if (accountId === 'acc-2') { + return second.promise; + } + + throw new Error(`Unexpected account ${accountId}`); + }); + + const accounts = [createAccount('acc-1'), createAccount('acc-2')]; + const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), { + wrapper: createWrapper(), + }); + + expect(getBrokerPortfolio).toHaveBeenCalledTimes(2); + expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1'); + expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2'); + + second.resolve({ + data: createPortfolio('acc-2'), + meta: { fromCache: false, cachedAt: null }, + }); + + await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2')); + expect(result.current[0].account.id).toBe('acc-1'); + expect(result.current[0].query.data).toBeUndefined(); + + first.resolve({ + data: createPortfolio('acc-1'), + meta: { fromCache: false, cachedAt: null }, + }); + + await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1')); + expect(result.current[1].query.data?.account.id).toBe('acc-2'); + }); + + it('reuses the same cache key as broker account overview page', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const cachedPortfolio = createPortfolio('acc-1'); + queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio); + + const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio)); + expect(getBrokerPortfolio).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts new file mode 100644 index 0000000..7feede3 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts @@ -0,0 +1,17 @@ +import { useQueries } from '@tanstack/react-query'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; + +export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { + const queries = useQueries({ + queries: accounts.map((account) => ({ + queryKey: ['broker', 'portfolio', account.id], + queryFn: async (): Promise => (await getBrokerPortfolio(account.id)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + })), + }); + + return accounts.map((account, index) => ({ account, query: queries[index] })); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx new file mode 100644 index 0000000..4a4b82b --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx @@ -0,0 +1,142 @@ +import { Link } from 'react-router-dom'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; +import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; +import { buildBrokerAllocation } from './brokerAllocation'; +import { BrokerAllocationBar } from './BrokerAllocationBar'; +import { + brokerAccountTypeLabel, + formatBrokerDate, + formatBrokerMoney, + formatBrokerSignedCurrencyValue, + formatBrokerSignedPercent, +} from './brokerAccountsOverview'; + +function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) { + return ( +
+
+
+

{typeLabel}

+

{name}

+
+
+
+ {[1, 2, 3, 4].map((item) => ( +
+ + +
+ ))} +
+ + +
+ ); +} + +function BrokerAccountCardError({ + account, + onRetry, +}: { + account: BrokerAccount; + onRetry: () => void; +}) { + return ( +
+
+
+

{brokerAccountTypeLabel(account.type)}

+

{account.name}

+
+
+
+

Не удалось загрузить данные счёта

+ +
+
+ ); +} + +function BrokerAccountCardSuccess({ + account, + portfolio, +}: { + account: BrokerAccount; + portfolio: BrokerPortfolio; +}) { + const typeLabel = brokerAccountTypeLabel(account.type); + const openedAt = formatBrokerDate(account.openedAt); + const allocation = buildBrokerAllocation(portfolio); + + return ( + +
+
+

{typeLabel}

+

{account.name}

+
+ {openedAt ?

Открыт {openedAt}

: null} +
+ +
+
+ Стоимость + {formatBrokerMoney(portfolio.totals.portfolio)} +
+
+ За день + + {formatBrokerSignedCurrencyValue( + portfolio.totals.portfolio?.currency ?? 'RUB', + portfolio.yields.daily?.value ?? null, + )} + +
+
+ Дневная динамика + {formatBrokerSignedPercent(portfolio.yields.dailyPercent)} +
+
+ Ожидаемая доходность + {formatBrokerSignedPercent(portfolio.yields.expectedPercent)} +
+
+ + + + ); +} + +export function BrokerAccountCard({ + account, + portfolio, + isLoading, + error, + onRetry, +}: { + account: BrokerAccount; + portfolio?: BrokerPortfolio; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}) { + if (isLoading && !portfolio) { + return ( + + ); + } + + if (error || !portfolio) { + return ; + } + + return ; +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx new file mode 100644 index 0000000..3ef4508 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx @@ -0,0 +1,293 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; +import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; +import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; +import { BrokerAccountsPage } from './BrokerAccountsPage'; + +function renderPage(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return render( + + {ui} + , + ); +} + +function createAccount( + account: Partial & Pick, +): BrokerAccount { + return { + id: account.id, + name: account.name, + type: account.type ?? 'brokerage', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z', + accessLevel: null, + }; +} + +function createPortfolio( + account: BrokerAccount, + overrides: Partial = {}, +): BrokerPortfolio { + return { + account, + positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 }, + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 600 }, + bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 }, + etf: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 }, + }, + yields: { + expectedPercent: 8, + daily: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + dailyPercent: 11.11, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + ...overrides, + }; +} + +function createQueryState(overrides: Record = {}) { + return { + data: undefined, + isLoading: false, + isFetching: false, + isPending: false, + isError: false, + error: null, + refetch: vi.fn(), + ...overrides, + }; +} + +describe('BrokerAccountsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders heading, aggregate summary, daily result and two linked cards', () => { + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { + account: broker, + query: createQueryState({ data: createPortfolio(broker) }), + }, + { + account: iis, + query: createQueryState({ + data: createPortfolio(iis, { + totals: { + shares: { currency: 'RUB', units: '0', nano: 0, value: 800 }, + bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 }, + etf: { currency: 'RUB', units: '0', nano: 0, value: 200 }, + currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 }, + }, + yields: { + expectedPercent: 12, + daily: { currency: 'RUB', units: '0', nano: 0, value: 140 }, + dailyPercent: 9.59, + }, + cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }], + }), + }), + }, + ] as any); + + renderPage(); + + expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument(); + expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute( + 'href', + '/broker/acc-1', + ); + expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute( + 'href', + '/broker/acc-2', + ); + }); + + it('shows human labels and opened date without exposing technical fields', () => { + const broker = createAccount({ id: 'account one', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { account: iis, query: createQueryState({ data: createPortfolio(iis) }) }, + ] as any); + + renderPage(); + + expect(screen.getByText('Брокерский счёт')).toBeInTheDocument(); + expect(screen.getByText('ИИС')).toBeInTheDocument(); + expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2); + expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument(); + expect(screen.queryByText('account one')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute( + 'href', + '/broker/account%20one', + ); + }); + + it('shows page skeleton while accounts are loading', () => { + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + error: null, + } as any); + + const { container } = renderPage(); + + expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + }); + + it('renders an empty state when there are no accounts', () => { + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any); + + renderPage(); + + expect( + screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/), + ).toBeInTheDocument(); + }); + + it('marks the summary as partial when one account portfolio is unavailable', () => { + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: iis, + query: createQueryState({ isError: true, error: new Error('boom') }), + }, + ] as any); + + renderPage(); + + expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument(); + }); + + it('shows a local alert and retries only the failed account', async () => { + const user = userEvent.setup(); + const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); + const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); + const refetch = vi.fn(); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, iis], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: iis, + query: createQueryState({ isError: true, error: new Error('boom'), refetch }), + }, + ] as any); + + renderPage(); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Не удалось загрузить данные счёта'); + await user.click( + within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }), + ); + expect(refetch).toHaveBeenCalled(); + }); + + it('keeps currencies separate in the overview summary', () => { + const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' }); + const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' }); + + vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + data: [broker, usd], + isLoading: false, + isFetching: false, + error: null, + } as any); + vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, + { + account: usd, + query: createQueryState({ + data: createPortfolio(usd, { + totals: { + shares: { currency: 'USD', units: '0', nano: 0, value: 300 }, + bonds: { currency: 'USD', units: '0', nano: 0, value: 100 }, + etf: null, + currencies: { currency: 'USD', units: '0', nano: 0, value: 100 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 }, + }, + yields: { + expectedPercent: 4, + daily: { currency: 'USD', units: '0', nano: 0, value: 20 }, + dailyPercent: 4.16, + }, + cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }], + }), + }), + }, + ] as any); + + renderPage(); + + const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' }); + expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument(); + expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx index fd8ca3d..ba1d963 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -1,85 +1,118 @@ -import { Link } from 'react-router-dom'; +import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios'; import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; -import { SkeletonBlock } from '../../components/SkeletonBlock'; - -const cardStyle = { - display: 'block', - padding: 20, - background: 'var(--color-surface)', - border: '1px solid #e0e0e0', - borderRadius: 8, - color: 'var(--color-text)', - textDecoration: 'none', - boxShadow: 'var(--shadow)', -} satisfies React.CSSProperties; - -export function BrokerAccountsPage() { - const { data: accounts, isLoading, error } = useBrokerAccounts(); - - if (isLoading) { - return ( -
-
-

Брокерские счета

-
-
- {[1, 2, 3].map((i) => ( -
- -
- -
- -
- -
- ))} -
-
- ); - } - if (error) return

Не удалось загрузить счета

; +import { BrokerAccountCard } from './BrokerAccountCard'; +import { BrokerAccountsSummary } from './BrokerAccountsSummary'; +import { aggregateBrokerAccounts } from './brokerAccountsOverview'; +function BrokerAccountsPageSkeleton() { return ( -
-
-

Брокерские счета

- - {(accounts ?? []).length} - -
- -
- {(accounts ?? []).map((account) => ( - -
{account.name}
-
- {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'} - {account.status} - {account.id} -
- +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+ +
+ {['1', '2', '3'].map((id) => ( + undefined} + /> + ))} +
+
+ ); +} + +export function BrokerAccountsPage() { + const { data: accounts, isLoading, error } = useBrokerAccounts(); + const safeAccounts = accounts ?? []; + const accountQueries = useBrokerAccountPortfolios(safeAccounts); + + if (isLoading) { + return ; + } + + if (error) { + return

Не удалось загрузить счета

; + } + + if (safeAccounts.length === 0) { + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+
+

Пока нет подключённых счетов

+

+ После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу. +

+
+
+ ); + } + + const successfulPortfolios = accountQueries + .map(({ query }) => query.data) + .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)); + const loadingCount = accountQueries.filter( + ({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data, + ).length; + const availableCount = successfulPortfolios.length; + const aggregate = aggregateBrokerAccounts(successfulPortfolios); + + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+

+ {safeAccounts.length} счетов под наблюдением +

+
+ + 0} + /> + +
+ {accountQueries.map(({ account, query }) => ( + { + void query.refetch(); + }} + /> ))}
diff --git a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx new file mode 100644 index 0000000..0329d4d --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx @@ -0,0 +1,164 @@ +import { SkeletonBlock } from '../../components/SkeletonBlock'; +import { buildBrokerAllocation } from './brokerAllocation'; +import { BrokerAllocationBar } from './BrokerAllocationBar'; +import type { BrokerAccountsAggregate } from './brokerAccountsOverview'; +import { + formatBrokerCurrencyValue, + formatBrokerSignedCurrencyValue, + formatBrokerSignedPercent, +} from './brokerAccountsOverview'; + +export function BrokerAccountsSummary({ + aggregate, + availableCount, + totalCount, + isLoading, +}: { + aggregate: BrokerAccountsAggregate; + availableCount: number; + totalCount: number; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+
+ + + +
+
+ {[1, 2, 3].map((item) => ( +
+ + +
+ ))} +
+
+ ); + } + + return ( +
+
+
+

Финансовый обзор

+

Счета в одном кадре

+
+
+ {totalCount} счетов + {availableCount !== totalCount ? ( + + Доступно по {availableCount} из {totalCount} счетов + + ) : null} +
+
+ +
+ {aggregate.portfolios.map((portfolioSummary) => { + const allocation = buildBrokerAllocation({ + account: { + id: 'aggregate', + type: 'brokerage', + name: 'aggregate', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 }, + totals: { + shares: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.shares, + }, + bonds: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.bonds, + }, + etf: + portfolioSummary.allocation.etf > 0 + ? { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.etf, + } + : null, + currencies: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.cash, + }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.total, + }, + }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '', + }); + + return ( +
+
+ + {portfolioSummary.currency} + + + {formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)} + +
+
+
+
За день
+
+ {formatBrokerSignedCurrencyValue( + portfolioSummary.currency, + portfolioSummary.daily, + )} +
+
+
+
Динамика
+
{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}
+
+
+
Свободные деньги
+
+ {formatBrokerCurrencyValue( + portfolioSummary.currency, + aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency) + ?.value ?? 0, + )} +
+
+
+ +
+ ); + })} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx new file mode 100644 index 0000000..d4203f4 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx @@ -0,0 +1,43 @@ +import type { BrokerAllocationItem } from './brokerAllocation'; + +export function BrokerAllocationBar({ + items, + title, +}: { + items: BrokerAllocationItem[]; + title: string; +}) { + const positiveItems = items.filter((item) => item.value > 0); + + if (positiveItems.length === 0) { + return

Нет данных для распределения

; + } + + return ( +
+
+ {positiveItems.map((item) => ( +
+
    + {positiveItems.map((item) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index fb9e536..5c3d63a 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'; import { type ReactElement } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import * as accountHook from '../../hooks/useBrokerAccounts'; import * as operationsHook from '../../hooks/useBrokerOperations'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as positionsHook from '../../hooks/useBrokerPositions'; @@ -12,7 +11,6 @@ import type { BrokerPortfolio, BrokerPosition } from '../../api/responses'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; -import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerPositionsPage } from './BrokerPositionsPage'; import { BrokerOperationsPage } from './BrokerOperationsPage'; @@ -276,37 +274,6 @@ describe('Broker pages', () => { expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); }); - it('renders broker and IIS accounts', () => { - vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ - data: [ - { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - { - id: 'acc-2', - type: 'iis', - name: 'IIS', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - ], - isLoading: false, - isFetching: false, - error: null, - } as any); - - renderWithClient(); - - expect(screen.getByText('Broker')).toBeInTheDocument(); - expect(screen.getByText('IIS')).toBeInTheDocument(); - }); - it('renders the broker account overview with allocation, asset links and recent operations', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts new file mode 100644 index 0000000..5e15834 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerPortfolio } from '../../api/responses'; +import { aggregateBrokerAccounts } from './brokerAccountsOverview'; + +function portfolio( + id: string, + currency: string, + total: number, + daily: number | null, + cash: number, +): BrokerPortfolio { + return { + account: { + id, + type: 'brokerage', + name: id, + status: 'ACCOUNT_STATUS_OPEN', + openedAt: '2022-06-16T00:00:00.000Z', + accessLevel: null, + }, + positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 }, + totals: { + shares: { currency, units: '0', nano: 0, value: total * 0.5 }, + bonds: { currency, units: '0', nano: 0, value: total * 0.3 }, + etf: null, + currencies: { currency, units: '0', nano: 0, value: total * 0.2 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency, units: '0', nano: 0, value: total }, + }, + yields: { + expectedPercent: 10, + daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily }, + dailyPercent: null, + }, + cash: [{ currency, units: '0', nano: 0, value: cash }], + blockedCash: [], + asOf: '2026-06-19T10:00:00.000Z', + }; +} + +describe('aggregateBrokerAccounts', () => { + it('sums comparable portfolios and uses the specified daily percent formula', () => { + const result = aggregateBrokerAccounts([ + portfolio('a', 'RUB', 1_100, 100, 200), + portfolio('b', 'RUB', 2_200, 200, 300), + ]); + + expect(result.portfolios).toEqual([ + expect.objectContaining({ + currency: 'RUB', + total: 3_300, + daily: 300, + dailyPercent: 10, + allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, + }), + ]); + expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]); + }); + + it('keeps different currencies separate', () => { + const result = aggregateBrokerAccounts([ + portfolio('rub', 'RUB', 1_100, 100, 200), + portfolio('usd', 'USD', 550, 50, 25), + ]); + + expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([ + { currency: 'RUB', total: 1_100 }, + { currency: 'USD', total: 550 }, + ]); + }); + + it('does not expose a daily percent when one account lacks daily data', () => { + const result = aggregateBrokerAccounts([ + portfolio('a', 'RUB', 1_100, 100, 200), + portfolio('b', 'RUB', 2_000, null, 300), + ]); + + expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null }); + }); + + it('does not expose a daily percent when start of day is non-positive', () => { + const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]); + + expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null }); + }); + + it('clamps negative residual other allocation to zero', () => { + const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100); + overAllocated.totals.shares!.value = 700; + overAllocated.totals.bonds!.value = 400; + overAllocated.totals.currencies!.value = 100; + + const result = aggregateBrokerAccounts([overAllocated]); + + expect(result.portfolios[0].allocation).toEqual({ + shares: 700, + bonds: 400, + etf: 0, + cash: 100, + other: 0, + }); + }); + + it('returns empty summaries for empty or unsupported portfolios', () => { + const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100); + missingTotal.totals.portfolio = null; + + expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] }); + expect(aggregateBrokerAccounts([missingTotal])).toEqual({ + portfolios: [], + cash: [{ currency: 'RUB', value: 100 }], + }); + }); + + it('groups cash separately by currency', () => { + const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100); + mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 }); + + expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([ + { currency: 'RUB', value: 100 }, + { currency: 'USD', value: 25 }, + ]); + }); +}); diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts new file mode 100644 index 0000000..8226ee2 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts @@ -0,0 +1,198 @@ +import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; + +export interface BrokerCurrencyAllocationSummary { + shares: number; + bonds: number; + etf: number; + cash: number; + other: number; +} + +export interface BrokerCurrencyPortfolioSummary { + currency: string; + total: number; + daily: number | null; + dailyPercent: number | null; + allocation: BrokerCurrencyAllocationSummary; +} + +export interface BrokerCurrencyCashSummary { + currency: string; + value: number; +} + +export interface BrokerAccountsAggregate { + portfolios: BrokerCurrencyPortfolioSummary[]; + cash: BrokerCurrencyCashSummary[]; +} + +interface MutableCurrencySummary { + currency: string; + total: number; + daily: number | null; + dailyComparable: boolean; + allocation: BrokerCurrencyAllocationSummary; +} + +function moneyValue(money: BrokerMoney | null | undefined): number { + return money?.value ?? 0; +} + +export function formatBrokerMoney(value: BrokerMoney | null | undefined): string { + if (!value) { + return '—'; + } + + return formatBrokerCurrencyValue(value.currency, value.value); +} + +export function formatBrokerCurrencyValue(currency: string, value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency || 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerCurrencyValue(currency, Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; +} + +export function formatBrokerSignedPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerPercent(Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerDate(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + return new Date(value).toLocaleDateString('ru-RU'); +} + +export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { + return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; +} + +export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { + const portfolioSummaries = new Map(); + const cashSummaries = new Map(); + + for (const portfolio of portfolios) { + for (const cash of portfolio.cash) { + if (!cash.currency) { + continue; + } + + const existingCash = cashSummaries.get(cash.currency); + + if (existingCash) { + existingCash.value += cash.value; + } else { + cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }); + } + } + + const totalMoney = portfolio.totals.portfolio; + const currency = totalMoney?.currency; + + if (!totalMoney || !currency) { + continue; + } + + const existingSummary = portfolioSummaries.get(currency); + const summary = + existingSummary ?? + ({ + currency, + total: 0, + daily: 0, + dailyComparable: true, + allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, + } satisfies MutableCurrencySummary); + + const total = totalMoney.value; + const shares = moneyValue(portfolio.totals.shares); + const bonds = moneyValue(portfolio.totals.bonds); + const etf = moneyValue(portfolio.totals.etf); + const cash = moneyValue(portfolio.totals.currencies); + const other = Math.max(0, total - shares - bonds - etf - cash); + + summary.total += total; + summary.allocation.shares += shares; + summary.allocation.bonds += bonds; + summary.allocation.etf += etf; + summary.allocation.cash += cash; + summary.allocation.other += other; + + const dailyMoney = portfolio.yields.daily; + const comparableDaily = dailyMoney && dailyMoney.currency === currency; + + if (!comparableDaily) { + summary.daily = null; + summary.dailyComparable = false; + } else if (summary.dailyComparable) { + summary.daily = (summary.daily ?? 0) + dailyMoney.value; + } + + if (!existingSummary) { + portfolioSummaries.set(currency, summary); + } + } + + return { + portfolios: Array.from(portfolioSummaries.values()).map((summary) => { + const daily = summary.dailyComparable ? summary.daily : null; + const startOfDay = daily === null ? null : summary.total - daily; + const dailyPercent = + daily === null || startOfDay === null || startOfDay <= 0 + ? null + : (daily / startOfDay) * 100; + + return { + currency: summary.currency, + total: summary.total, + daily, + dailyPercent, + allocation: summary.allocation, + }; + }), + cash: Array.from(cashSummaries.values()), + }; +} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 0cf783b..343b42d 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -16,6 +16,12 @@ --color-negative: #c62828; --border-radius: 8px; --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + --broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%); + --broker-overview-panel: rgba(15, 51, 36, 0.93); + --broker-overview-panel-soft: rgba(255, 255, 255, 0.09); + --broker-overview-border: rgba(21, 61, 43, 0.12); + --broker-overview-accent: #98c484; + --broker-overview-gold: #d7b268; } .pnl-cell { @@ -219,12 +225,307 @@ a { color: var(--color-negative); } -@media (max-width: 720px) { -.broker-account__header { - padding: 0 0 8px; +.broker-accounts-page { + display: grid; + gap: 24px; } -.broker-account__workspace { +.broker-accounts-page__header { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; +} + +.broker-accounts-page__eyebrow, +.broker-account-card__eyebrow, +.broker-accounts-summary__eyebrow { + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.broker-accounts-page__title, +.broker-account-card__title, +.broker-accounts-summary__title { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; + line-height: 1.05; +} + +.broker-accounts-page__title { + font-size: clamp(2.2rem, 3vw, 3rem); +} + +.broker-accounts-page__caption { + color: var(--color-text-secondary); +} + +.broker-accounts-summary { + padding: 28px; + border-radius: 28px; + background: var(--broker-overview-bg); + box-shadow: + 0 24px 60px rgba(15, 52, 35, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + border: 1px solid rgba(255, 255, 255, 0.7); + display: grid; + gap: 20px; +} + +.broker-accounts-summary__hero { + display: grid; + gap: 12px; + padding: 24px; + border-radius: 22px; + background: + radial-gradient(circle at top right, rgba(152, 196, 132, 0.3), transparent 28%), + linear-gradient(135deg, var(--broker-overview-panel) 0%, #173f2d 100%); + color: #f8f5ec; +} + +.broker-accounts-summary__hero .broker-accounts-summary__eyebrow { + color: rgba(248, 245, 236, 0.72); +} + +.broker-accounts-summary__title { + font-size: clamp(1.9rem, 2.4vw, 2.6rem); +} + +.broker-accounts-summary__status { + display: flex; + flex-wrap: wrap; + gap: 10px; + color: rgba(248, 245, 236, 0.78); +} + +.broker-accounts-summary__status span { + padding: 6px 10px; + border-radius: 999px; + background: var(--broker-overview-panel-soft); +} + +.broker-accounts-summary__currency-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.broker-accounts-summary__currency-card, +.broker-account-card, +.broker-accounts-empty { + border-radius: 24px; + border: 1px solid var(--broker-overview-border); + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 20px 45px rgba(31, 48, 39, 0.08); +} + +.broker-accounts-summary__currency-card { + padding: 22px; + display: grid; + gap: 16px; +} + +.broker-accounts-summary__currency-header { + display: grid; + gap: 6px; +} + +.broker-accounts-summary__currency { + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.broker-accounts-summary__total { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; + font-size: clamp(1.8rem, 2vw, 2.4rem); +} + +.broker-accounts-summary__metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.broker-accounts-summary__metric { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.7); +} + +.broker-accounts-summary__metric dt, +.broker-account-card__stat span { + font-size: 13px; + color: var(--color-text-secondary); +} + +.broker-accounts-summary__metric dd, +.broker-account-card__stat strong { + font-size: 1.05rem; + font-weight: 700; +} + +.broker-allocation-bar { + display: grid; + gap: 12px; +} + +.broker-allocation-bar__track { + min-height: 12px; + border-radius: 999px; + overflow: hidden; + display: flex; + background: rgba(19, 54, 38, 0.08); +} + +.broker-allocation-bar__segment { + min-width: 8px; +} + +.broker-allocation-bar__legend { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; +} + +.broker-allocation-bar__legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(19, 54, 38, 0.05); +} + +.broker-allocation-bar__swatch { + width: 9px; + height: 9px; + border-radius: 999px; +} + +.broker-allocation-bar__empty { + color: var(--color-text-secondary); +} + +.broker-accounts-page__cards { + display: grid; + gap: 16px; +} + +.broker-account-card { + padding: 22px; +} + +.broker-account-card--link { + display: grid; + gap: 18px; + color: inherit; + text-decoration: none; + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease; +} + +.broker-account-card--link:hover { + transform: translateY(-2px); + box-shadow: 0 26px 50px rgba(31, 48, 39, 0.11); + border-color: rgba(38, 92, 55, 0.22); +} + +.broker-account-card--link:focus-visible { + outline: 3px solid rgba(59, 128, 74, 0.3); + outline-offset: 3px; +} + +.broker-account-card__header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.broker-account-card__title { + font-size: clamp(1.4rem, 2vw, 1.8rem); +} + +.broker-account-card__opened { + color: var(--color-text-secondary); + text-align: right; +} + +.broker-account-card__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.broker-account-card__stat { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 239, 0.88)); + border: 1px solid rgba(31, 48, 39, 0.08); +} + +.broker-account-card__stat--wide { + grid-column: span 2; +} + +.broker-account-card__alert { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(198, 40, 40, 0.08); + color: #8e2525; +} + +.broker-account-card__retry { + border: none; + border-radius: 999px; + background: #163f2c; + color: #fff; + padding: 10px 16px; + font: inherit; + cursor: pointer; +} + +.broker-account-card__retry:focus-visible { + outline: 3px solid rgba(59, 128, 74, 0.3); + outline-offset: 3px; +} + +.broker-accounts-empty { + padding: 28px; + display: grid; + gap: 10px; +} + +@media (prefers-reduced-motion: reduce) { + .broker-account-card--link, + .loading-spinner, + .skeleton { + transition: none; + animation: none; + } +} + +@media (max-width: 720px) { + .broker-account__header { + padding: 0 0 8px; + } + + .broker-account__workspace { gap: 16px; grid-template-columns: minmax(0, 1fr); } @@ -255,6 +556,38 @@ a { width: 100%; align-self: center; } + + .broker-accounts-page__header, + .broker-account-card__header { + grid-template-columns: minmax(0, 1fr); + display: grid; + } + + .broker-accounts-summary, + .broker-account-card, + .broker-accounts-empty { + padding: 18px; + border-radius: 20px; + } + + .broker-accounts-summary__hero, + .broker-accounts-summary__metrics, + .broker-account-card__grid { + grid-template-columns: 1fr; + } + + .broker-account-card__stat--wide { + grid-column: auto; + } + + .broker-account-card__opened { + text-align: left; + } + + .broker-account-card__alert { + align-items: stretch; + flex-direction: column; + } } .broker-operations__toolbar { diff --git a/docs/features/broker-accounts-overview/tasks.md b/docs/features/broker-accounts-overview/tasks.md index 3cbff55..45ffc4e 100644 --- a/docs/features/broker-accounts-overview/tasks.md +++ b/docs/features/broker-accounts-overview/tasks.md @@ -1,40 +1,40 @@ # Информативный обзор брокерских счетов — задачи -Статус: готово к реализации +Статус: реализовано Подробные шаги, команды и ожидаемые результаты находятся в [plan.md](plan.md). ## 1. Агрегация -- [ ] Добавить чистую валютно-безопасную агрегацию портфелей. -- [ ] Покрыть формулу дневного процента и edge cases unit-тестами. -- [ ] Не смешивать валюты и не выполнять неявную конвертацию. +- [x] Добавить чистую валютно-безопасную агрегацию портфелей. +- [x] Покрыть формулу дневного процента и edge cases unit-тестами. +- [x] Не смешивать валюты и не выполнять неявную конвертацию. ## 2. Загрузка данных -- [ ] Добавить параллельные portfolio queries для списка счетов. -- [ ] Переиспользовать query keys детальной страницы. -- [ ] Проверить независимое завершение запросов и кеш. +- [x] Добавить параллельные portfolio queries для списка счетов. +- [x] Переиспользовать query keys детальной страницы. +- [x] Проверить независимое завершение запросов и кеш. ## 3. Интерфейс -- [ ] Добавить общую сводку по успешно загруженным счетам. -- [ ] Добавить информативную карточку счёта и allocation bar. -- [ ] Добавить skeleton, empty state и локальную ошибку с retry. -- [ ] Убрать технический ID и сырые T-Bank enum-значения. -- [ ] Обеспечить keyboard navigation и текстовые признаки доходности. +- [x] Добавить общую сводку по успешно загруженным счетам. +- [x] Добавить информативную карточку счёта и allocation bar. +- [x] Добавить skeleton, empty state и локальную ошибку с retry. +- [x] Убрать технический ID и сырые T-Bank enum-значения. +- [x] Обеспечить keyboard navigation и текстовые признаки доходности. ## 4. Визуальная проверка -- [ ] Реализовать согласованное зелёно-нейтральное визуальное направление. -- [ ] Проверить desktop 1280px и mobile 390px без horizontal overflow. -- [ ] Проверить loading и partial-error states в браузере. +- [x] Реализовать согласованное зелёно-нейтральное визуальное направление. +- [x] Проверить desktop 1280px и mobile 390px без horizontal overflow. +- [x] Проверить loading и partial-error states в браузере. ## 5. Definition of Done -- [ ] Frontend tests проходят. -- [ ] Frontend lint проходит. -- [ ] Frontend build проходит. -- [ ] Docusaurus build проходит. -- [ ] Code review завершён. -- [ ] Roadmap отмечает фичу реализованной. +- [x] Frontend tests проходят. +- [x] Frontend lint проходит. +- [x] Frontend build проходит. +- [x] Docusaurus build проходит. +- [x] Code review завершён. +- [x] Roadmap отмечает фичу реализованной. diff --git a/docs/roadmap.md b/docs/roadmap.md index 16f19db..1296abf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,10 +11,10 @@ Roadmap отражает порядок продуктовой работы, н Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций. - [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано. -- [ ] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — согласовано к планированию. +- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано. ## Следующие этапы для активной фичи 1. [x] Проверить и утвердить `spec.md` для планирования. 2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`. -3. [ ] Получить отдельное подтверждение пользователя перед началом реализации. +3. [x] Получить отдельное подтверждение пользователя перед началом реализации.