codex/broker-accounts-overview #22

Merged
ksv741 merged 3 commits from codex/broker-accounts-overview into main 2026-06-19 14:48:37 +03:00
18 changed files with 2311 additions and 130 deletions

View File

@ -0,0 +1,92 @@
# ADR-012: Сводка брокерских счетов агрегируется на frontend
**Статус:** Accepted
**Дата:** 2026-06-19
## Контекст
Страница списка брокерских счетов должна показывать общую стоимость, дневной результат, свободные
деньги и распределение активов, а также подробные показатели каждого счёта. Endpoint
`GET /api/v1/broker/accounts` возвращает только метаданные счетов. Все необходимые финансовые данные
уже доступны через `GET /api/v1/broker/accounts/:accountId/portfolio`.
Текущий продуктовый сценарий предполагает один–три открытых счёта. Backend кеширует ответы портфеля
и ограничивает запросы к T-Bank через общую очередь.
## Рассмотренные варианты
### 1. Агрегация существующих ответов на frontend
Frontend получает список счетов, параллельно запрашивает портфель каждого счёта и строит общую
сводку из успешно загруженных ответов.
Преимущества:
- не появляется новый API-контракт с данными, уже доступными в portfolio endpoint;
- каждый счёт может загружаться и восстанавливаться после ошибки независимо;
- при одном–трёх счетах число запросов остаётся ограниченным;
- используются существующие backend cache и rate limiting.
Недостатки:
- страница выполняет `1 + N` HTTP-запросов;
- ответы могут иметь разные значения `asOf` и не образуют атомарный снимок;
- правила презентационной агрегации находятся на frontend.
### 2. Новый aggregate endpoint
Добавить endpoint, который возвращает список счетов, их портфели и общую сводку одним ответом.
Преимущества:
- один HTTP-запрос с frontend;
- единый серверный контракт агрегации;
- проще переиспользовать сводку другими клиентами.
Недостатки:
- новый контракт дублирует существующие portfolio-данные;
- нужно определить семантику частичных ошибок и кеширования составного ответа;
- endpoint всё равно получает портфели нескольких счетов и не гарантирует атомарность данных T-Bank.
### 3. Расширение endpoint списка счетов
Добавить финансовую сводку каждого счёта в `GET /api/v1/broker/accounts`.
Преимущества:
- frontend получает всё одним запросом;
- карточки становятся простыми потребителями ответа.
Недостатки:
- лёгкий endpoint метаданных превращается в дорогой составной запрос;
- меняются его кеширование, время ответа и семантика ошибок;
- потребители списка счетов вынужденно получают финансовые данные.
## Решение
Использовать вариант 1: агрегировать существующие portfolio-ответы на frontend.
Frontend параллельно запрашивает портфели после получения списка счетов. Общая сводка строится только
из успешно загруженных ответов и явно помечается как частичная, если часть счетов недоступна. Ошибка
одного портфеля не блокирует остальные карточки.
Денежные значения группируются по валюте. Frontend не выполняет неявную валютную конвертацию и не
складывает несопоставимые суммы. Значения `asOf` отдельных ответов сохраняют свой смысл; сводка не
считается транзакционно согласованным снимком.
## Последствия
- Страница выполняет один запрос списка и до трёх параллельных запросов портфелей.
- Backend остаётся источником финансовых данных отдельного счёта, а frontend владеет только их
представлением и агрегацией для этой страницы.
- Частичные ошибки становятся штатным состоянием интерфейса и должны быть покрыты тестами.
- API, OpenAPI и frontend codegen для этой фичи не меняются.
- Решение следует пересмотреть, если выполняется хотя бы одно условие:
- продукт поддерживает более трёх одновременно отображаемых счетов;
- измерения показывают неприемлемую задержку или нагрузку от `1 + N` запросов;
- нескольким клиентам нужна одинаковая серверная сводка;
- появляется требование к согласованному серверному снимку или серверной валютной конвертации;
- правила агрегации становятся самостоятельной бизнес-логикой, а не логикой представления.

View File

@ -1,17 +1,18 @@
# Архитектурные решения (ADR)
| ADR | Статус | Описание |
|---|---|---|
| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX |
| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache |
| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting |
| [ADR-004](ADR-004-feature-modules) | Accepted | Архитектура feature-модулей |
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI codegen для frontend |
| [ADR-006](ADR-006-no-cci) | Deprecated | CCI вынесен за пределы MVP |
| [ADR-007](ADR-007-two-level-caching) | Draft | Двухуровневый cache: backend + frontend |
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
| ADR | Статус | Описание |
| ------------------------------------------------------ | ---------- | ---------------------------------------------- |
| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX |
| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache |
| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting |
| [ADR-004](ADR-004-feature-modules) | Accepted | Архитектура feature-модулей |
| [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI codegen для frontend |
| [ADR-006](ADR-006-no-cci) | Deprecated | CCI вынесен за пределы MVP |
| [ADR-007](ADR-007-two-level-caching) | Draft | Двухуровневый cache: backend + frontend |
| [ADR-008](ADR-008-auth-system) | Accepted | Authentication и Authorization |
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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();
});
});

View File

@ -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<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
})),
});
return accounts.map((account, index) => ({ account, query: queries[index] }));
}

View File

@ -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 (
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{name}</h2>
</div>
</div>
<div className="broker-account-card__grid">
{[1, 2, 3, 4].map((item) => (
<div className="broker-account-card__stat" key={item}>
<SkeletonBlock height={12} width="50%" />
<SkeletonBlock height={24} width="75%" />
</div>
))}
</div>
<SkeletonBlock height={16} width="100%" borderRadius={999} />
<SkeletonBlock height={16} width="65%" />
</article>
);
}
function BrokerAccountCardError({
account,
onRetry,
}: {
account: BrokerAccount;
onRetry: () => void;
}) {
return (
<article className="broker-account-card broker-account-card--error">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
</div>
<div className="broker-account-card__alert" role="alert">
<p>Не удалось загрузить данные счёта</p>
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
Повторить
</button>
</div>
</article>
);
}
function BrokerAccountCardSuccess({
account,
portfolio,
}: {
account: BrokerAccount;
portfolio: BrokerPortfolio;
}) {
const typeLabel = brokerAccountTypeLabel(account.type);
const openedAt = formatBrokerDate(account.openedAt);
const allocation = buildBrokerAllocation(portfolio);
return (
<Link
className="broker-account-card broker-account-card--link"
to={`/broker/${encodeURIComponent(account.id)}`}
>
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
</div>
<div className="broker-account-card__grid">
<div className="broker-account-card__stat broker-account-card__stat--wide">
<span>Стоимость</span>
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
</div>
<div className="broker-account-card__stat">
<span>За день</span>
<strong>
{formatBrokerSignedCurrencyValue(
portfolio.totals.portfolio?.currency ?? 'RUB',
portfolio.yields.daily?.value ?? null,
)}
</strong>
</div>
<div className="broker-account-card__stat">
<span>Дневная динамика</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
</div>
<div className="broker-account-card__stat">
<span>Ожидаемая доходность</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
</div>
</div>
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
</Link>
);
}
export function BrokerAccountCard({
account,
portfolio,
isLoading,
error,
onRetry,
}: {
account: BrokerAccount;
portfolio?: BrokerPortfolio;
isLoading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (isLoading && !portfolio) {
return (
<BrokerAccountCardSkeleton
name={account.name}
typeLabel={brokerAccountTypeLabel(account.type)}
/>
);
}
if (error || !portfolio) {
return <BrokerAccountCardError account={account} onRetry={onRetry} />;
}
return <BrokerAccountCardSuccess account={account} portfolio={portfolio} />;
}

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function createAccount(
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
): 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> = {},
): 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<string, unknown> = {}) {
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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();
});
});

View File

@ -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 (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
boxShadow: 'var(--shadow)',
}}
>
<SkeletonBlock height={20} width="60%" />
<div style={{ height: 10 }} />
<SkeletonBlock height={12} width="40%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="30%" />
<div style={{ height: 6 }} />
<SkeletonBlock height={12} width="50%" />
</div>
))}
</div>
</div>
);
}
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
import { BrokerAccountCard } from './BrokerAccountCard';
import { BrokerAccountsSummary } from './BrokerAccountsSummary';
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
function BrokerAccountsPageSkeleton() {
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
{(accounts ?? []).length}
</span>
</div>
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
}}
>
{(accounts ?? []).map((account) => (
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
<span>{account.status}</span>
<span>{account.id}</span>
</div>
</Link>
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<BrokerAccountsSummary
aggregate={{ portfolios: [], cash: [] }}
availableCount={0}
totalCount={0}
isLoading
/>
<div className="broker-accounts-page__cards">
{['1', '2', '3'].map((id) => (
<BrokerAccountCard
key={id}
account={{
id,
name: 'Загрузка счёта',
type: 'brokerage',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
}}
isLoading
error={null}
onRetry={() => undefined}
/>
))}
</div>
</div>
);
}
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
const safeAccounts = accounts ?? [];
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
if (isLoading) {
return <BrokerAccountsPageSkeleton />;
}
if (error) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
}
if (safeAccounts.length === 0) {
return (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<section className="broker-accounts-empty">
<h2>Пока нет подключённых счетов</h2>
<p>
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
</p>
</section>
</div>
);
}
const successfulPortfolios = accountQueries
.map(({ query }) => query.data)
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => 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 (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
<p className="broker-accounts-page__caption">
{safeAccounts.length} счетов под наблюдением
</p>
</header>
<BrokerAccountsSummary
aggregate={aggregate}
availableCount={availableCount}
totalCount={safeAccounts.length}
isLoading={availableCount === 0 && loadingCount > 0}
/>
<div className="broker-accounts-page__cards">
{accountQueries.map(({ account, query }) => (
<BrokerAccountCard
key={account.id}
account={account}
portfolio={query.data}
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
error={(query.error as Error | null) ?? null}
onRetry={() => {
void query.refetch();
}}
/>
))}
</div>
</div>

View File

@ -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 (
<section className="broker-accounts-summary broker-accounts-summary--loading">
<div className="broker-accounts-summary__hero">
<SkeletonBlock height={18} width="34%" />
<SkeletonBlock height={52} width="48%" />
<SkeletonBlock height={18} width="28%" />
</div>
<div className="broker-accounts-summary__metrics">
{[1, 2, 3].map((item) => (
<div className="broker-accounts-summary__metric" key={item}>
<SkeletonBlock height={14} width="45%" />
<SkeletonBlock height={26} width="70%" />
</div>
))}
</div>
</section>
);
}
return (
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
<div className="broker-accounts-summary__hero">
<div>
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
</div>
<div className="broker-accounts-summary__status">
<span>{totalCount} счетов</span>
{availableCount !== totalCount ? (
<span>
Доступно по {availableCount} из {totalCount} счетов
</span>
) : null}
</div>
</div>
<div className="broker-accounts-summary__currency-grid">
{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 (
<article
className="broker-accounts-summary__currency-card"
key={portfolioSummary.currency}
>
<div className="broker-accounts-summary__currency-header">
<span className="broker-accounts-summary__currency">
{portfolioSummary.currency}
</span>
<strong className="broker-accounts-summary__total">
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
</strong>
</div>
<dl className="broker-accounts-summary__metrics">
<div className="broker-accounts-summary__metric">
<dt>За день</dt>
<dd>
{formatBrokerSignedCurrencyValue(
portfolioSummary.currency,
portfolioSummary.daily,
)}
</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Динамика</dt>
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Свободные деньги</dt>
<dd>
{formatBrokerCurrencyValue(
portfolioSummary.currency,
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
?.value ?? 0,
)}
</dd>
</div>
</dl>
<BrokerAllocationBar
title={`Распределение активов ${portfolioSummary.currency}`}
items={allocation.sectors}
/>
</article>
);
})}
</div>
</section>
);
}

View File

@ -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 <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
}
return (
<div className="broker-allocation-bar">
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
{positiveItems.map((item) => (
<span
key={item.key}
className="broker-allocation-bar__segment"
style={{ width: `${item.percent}%`, background: item.color }}
aria-hidden="true"
/>
))}
</div>
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
{positiveItems.map((item) => (
<li className="broker-allocation-bar__legend-item" key={item.key}>
<span
className="broker-allocation-bar__swatch"
style={{ background: item.color }}
aria-hidden="true"
/>
<span>{item.label}</span>
<strong>{item.percent.toFixed(0)}%</strong>
</li>
))}
</ul>
</div>
);
}

View File

@ -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(<BrokerAccountsPage />);
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(),

View File

@ -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 },
]);
});
});

View File

@ -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<string, MutableCurrencySummary>();
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
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()),
};
}

View File

@ -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 {

View File

@ -27,6 +27,7 @@
- [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md)
- [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md)
- [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано
- [Информативный обзор брокерских счетов](../features/broker-accounts-overview/spec.md)
- [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md)
- [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md)
- [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md)

View File

@ -0,0 +1,439 @@
# Broker Accounts Overview 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:** Превратить `/broker` в информативный обзор одного–трёх брокерских счетов с общей сводкой, независимой загрузкой карточек и устойчивыми состояниями ошибок.
**Architecture:** `BrokerAccountsPage` получает список счетов и передаёт его в новый hook на основе TanStack Query `useQueries`; query key совпадает с `useBrokerPortfolio`, поэтому детальная страница переиспользует кеш. Чистый модуль агрегации группирует денежные значения по валютам и рассчитывает единственный новый финансовый показатель по формуле из spec. UI разбит на общую сводку, карточку счёта и компактную полосу распределения; backend и OpenAPI не меняются согласно ADR-012.
**Tech Stack:** React 18, TypeScript, TanStack Query v5, React Router v6, Vitest, Testing Library, CSS custom properties.
---
## Карта файлов
- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.ts` — чистые типы, агрегация валютных сумм и форматирование дат/денег/процентов.
- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts` — unit-тесты финансовой агрегации и edge cases.
- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts` — параллельные portfolio queries с общими query keys.
- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx` — проверка независимых query-состояний и кеша.
- Create `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx` — общая сводка и частичное состояние.
- Create `apps/frontend/src/pages/broker/BrokerAccountCard.tsx` — успешная карточка, skeleton и локальная ошибка с retry.
- Create `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx` — доступная горизонтальная полоса распределения.
- Create `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx` — page/component tests.
- Modify `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` — orchestration новой страницы.
- Modify `apps/frontend/src/pages/broker/BrokerPages.test.tsx` — удалить старый поверхностный тест списка счетов.
- Modify `apps/frontend/src/styles.css` — визуальная система overview, focus/hover, skeleton и responsive rules.
- Modify `docs/features/broker-accounts-overview/tasks.md` — отмечать выполненные задачи.
- Modify `docs/roadmap.md` — отметить фичу реализованной только после всех проверок.
### Task 1: Чистая модель агрегации
**Files:**
- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts`
- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.ts`
- [ ] **Step 1: Написать падающие unit-тесты**
Покрыть одним набором тестов:
```ts
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 });
});
});
```
- [ ] **Step 2: Запустить unit-тест и подтвердить RED**
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
Expected: FAIL с ошибкой импорта `./brokerAccountsOverview`.
- [ ] **Step 3: Реализовать минимальную чистую модель**
Создать публичные типы `BrokerAccountsAggregate`, `BrokerCurrencyPortfolioSummary`,
`BrokerCurrencyCashSummary` и функцию:
```ts
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate;
```
Правила реализации:
- пропускать портфель без `totals.portfolio` или без currency;
- группировать `totals.portfolio`, `yields.daily` и классы активов по currency портфеля;
- считать `other` как `max(0, portfolio - shares - bonds - etf - currencies)`;
- группировать `portfolio.cash` независимо по валюте;
- если хотя бы в одном портфеле валютной группы нет сопоставимого `yields.daily`, возвращать для
группы `daily: null` и `dailyPercent: null`;
- иначе считать `dailyPercent = daily / (total - daily) * 100`, только если знаменатель положителен;
- сортировать валютные группы по первому появлению во входном массиве, не по алфавиту.
- [ ] **Step 4: Запустить unit-тест и подтвердить GREEN**
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
Expected: PASS, 3 tests.
- [ ] **Step 5: Добавить edge cases**
Добавить тесты для неположительной стоимости начала дня, отрицательного residual `other`, пустого
массива, отсутствующего `totals.portfolio` и cash в нескольких валютах. Реализация должна возвращать
конечные числа и никогда не смешивать валюты.
- [ ] **Step 6: Запустить unit-тесты и commit**
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
Expected: PASS.
```bash
git add apps/frontend/src/pages/broker/brokerAccountsOverview.ts apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
git commit -m "feat: aggregate broker account summaries"
```
### Task 2: Независимые portfolio queries
**Files:**
- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts`
- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx`
- [ ] **Step 1: Написать падающий hook-тест**
Mock `getBrokerPortfolio`, отрендерить hook с двумя счетами и проверить, что вызываются `acc-1` и
`acc-2`, а результат сохраняет соответствие `account → query` независимо от порядка завершения
Promise. Второй тест должен создать `QueryClient`, заранее положить портфель в key
`['broker', 'portfolio', 'acc-1']` и подтвердить, что hook использует то же кешированное значение.
- [ ] **Step 2: Запустить hook-тест и подтвердить RED**
Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend`
Expected: FAIL с ошибкой импорта `useBrokerAccountPortfolios`.
- [ ] **Step 3: Реализовать hook через `useQueries`**
```ts
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<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
})),
});
return accounts.map((account, index) => ({ account, query: queries[index] }));
}
```
- [ ] **Step 4: Запустить hook-тест и подтвердить GREEN**
Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add apps/frontend/src/hooks/useBrokerAccountPortfolios.ts apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
git commit -m "feat: load broker account portfolios in parallel"
```
### Task 3: Компоненты и состояния страницы
**Files:**
- Create: `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx`
- Create: `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx`
- Create: `apps/frontend/src/pages/broker/BrokerAccountCard.tsx`
- Create: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx`
- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
- [ ] **Step 1: Написать page/component tests до реализации**
Mock `useBrokerAccounts` и `useBrokerAccountPortfolios`. Проверить отдельными тестами:
- heading, агрегированную сумму, дневной результат и две карточки;
- подписи `Брокерский счёт` и `ИИС`, форматированную дату открытия и отсутствие ID/raw enum;
- href всей карточки `/broker/:encodedAccountId`;
- skeleton при загрузке списка;
- пустое состояние при `accounts: []`;
- частичную сводку `Доступно по 1 из 2 счетов`;
- локальный alert и кнопку `Повторить` для ошибочного query;
- вызов `query.refetch()` по кнопке retry;
- раздельное отображение RUB и USD без суммирования.
Для денежных assertions использовать regexp с обычным и non-breaking space, например:
```ts
expect(screen.getByText(/3[\s\u00a0]?300[\s\u00a0]?₽/)).toBeInTheDocument();
```
- [ ] **Step 2: Запустить page-тест и подтвердить RED**
Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx -w apps/frontend`
Expected: FAIL, потому что новые компоненты и состояния отсутствуют.
- [ ] **Step 3: Реализовать `BrokerAllocationBar`**
Компонент получает `BrokerAllocationItem[]`, строит сегменты с inline `width: percent%`, добавляет
`role="img"`, осмысленный `aria-label` со всеми долями и текстовую легенду. Нулевые сегменты не
рендерятся; цвет не является единственным способом различить классы.
- [ ] **Step 4: Реализовать `BrokerAccountsSummary`**
Компонент получает aggregate, `loadedCount` и `totalCount`. Он показывает:
- статус `Совокупный капитал · N счетов` либо `Доступно по N из M счетов`;
- по одному блоку стоимости/дневного результата на валюту;
- свободные деньги отдельным списком валют;
- allocation bar только когда доступна одна portfolio currency;
- placeholder `—`, если ни один портфель ещё не загружен.
- [ ] **Step 5: Реализовать `BrokerAccountCard`**
Компонент получает `account` и query result. Три ветки должны иметь стабильную геометрию:
```tsx
if (query.isPending) return <BrokerAccountCardSkeleton account={account} />;
if (query.error || !query.data) {
return <BrokerAccountCardError account={account} onRetry={() => query.refetch()} />;
}
return <BrokerAccountCardContent account={account} portfolio={query.data} />;
```
Успешная карточка — одна `Link` на `/broker/${encodeURIComponent(account.id)}`. Внутри показать
название, тип, дату открытия, стоимость, `yields.daily`, `dailyPercent`, `expectedPercent` и allocation
bar из существующего `buildBrokerAllocation(portfolio).sectors`.
- [ ] **Step 6: Переписать orchestration `BrokerAccountsPage`**
Страница должна:
1. вызвать `useBrokerAccounts()`;
2. передать `accounts ?? []` в `useBrokerAccountPortfolios` без условного вызова hooks;
3. построить aggregate только из `query.data` успешных записей;
4. показать page error только при ошибке списка;
5. показать отдельное empty state для пустого списка;
6. отрендерить summary и вертикальный список карточек.
- [ ] **Step 7: Удалить устаревший тест из `BrokerPages.test.tsx`**
Удалить test case `renders broker and IIS accounts` и неиспользуемые imports `accountHook` и
`BrokerAccountsPage`; покрытие новой страницы живёт в `BrokerAccountsPage.test.tsx`.
- [ ] **Step 8: Запустить component tests и подтвердить GREEN**
Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx src/pages/broker/BrokerPages.test.tsx -w apps/frontend`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx apps/frontend/src/pages/broker/BrokerAccountCard.tsx apps/frontend/src/pages/broker/BrokerAllocationBar.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "feat: add informative broker accounts overview"
```
### Task 4: Визуальная система и responsive QA
**Files:**
- Modify: `apps/frontend/src/styles.css`
- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx`
- [ ] **Step 1: Добавить семантические CSS-классы**
Добавить блоки `.broker-accounts`, `__header`, `__summary`, `__summary-metrics`, `__list`,
`.broker-account-card`, `__topline`, `__value`, `__metrics`, `.broker-allocation-bar`, `__track`,
`__legend`, `__error` и `__empty`.
Визуальное направление:
- тёплый нейтральный фон страницы и глубокий зелёный summary без градиента;
- serif-акцент только для крупных денежных значений, основной текст наследует текущую гарнитуру;
- тонкие границы и мягкая тень карточек, без вложенных «карточек в карточке»;
- один заметный hover карточки: небольшой подъём и усиление тени;
- `:focus-visible` с контрастным outline;
- positive/negative цвета всегда сопровождаются знаком и текстом;
- `prefers-reduced-motion: reduce` отключает transform/transition.
- [ ] **Step 2: Добавить responsive rules**
При `max-width: 720px` summary metrics и card metrics переходят в одну колонку, легенда allocation
переносится, денежные значения уменьшаются через `clamp()`, а карточка и кнопка retry сохраняют
минимальную интерактивную высоту 44px. Горизонтальный overflow на `.broker-accounts` запрещён.
- [ ] **Step 3: Запустить frontend проверки**
Run: `npm run test -w apps/frontend`
Expected: PASS.
Run: `npm run lint -w apps/frontend`
Expected: PASS без warnings.
Run: `npm run build -w apps/frontend`
Expected: успешный TypeScript и Vite build.
- [ ] **Step 4: Проверить страницу в локальном браузере**
Запустить frontend и backend по README. Проверить `/broker` при ширинах 1280px и 390px:
- нет горизонтального overflow;
- summary визуально доминирует, но карточки остаются читаемыми;
- карточки и retry доступны с клавиатуры;
- loading не меняет геометрию страницы;
- partial error не скрывает успешные счета;
- названия, крупные суммы и allocation legend не перекрываются.
- [ ] **Step 5: Commit**
```bash
git add apps/frontend/src/styles.css apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
git commit -m "style: polish broker accounts overview"
```
### Task 5: Финальная документация и Definition of Done
**Files:**
- Modify: `docs/features/broker-accounts-overview/tasks.md`
- Modify: `docs/roadmap.md`
- [ ] **Step 1: Отметить выполненные tasks**
Поставить `[x]` только после соответствующих commit и проверок. Не менять ADR-012: решение уже
Accepted и реализация ему соответствует.
- [ ] **Step 2: Обновить roadmap**
Изменить строку фичи на:
```md
- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано.
```
- [ ] **Step 3: Выполнить полный verification gate**
Run: `npm run test -w apps/frontend && npm run lint -w apps/frontend && npm run build -w apps/frontend`
Expected: все команды завершаются с exit code 0.
Run: `npm run build -w apps/docs`
Expected: Docusaurus build succeeds.
- [ ] **Step 4: Провести code review**
Использовать `superpowers:requesting-code-review`. Исправить замечания только после технической
проверки; при изменении поведения сначала синхронизировать spec/plan.
- [ ] **Step 5: Финальный commit документации**
```bash
git add docs/features/broker-accounts-overview/tasks.md docs/roadmap.md
git commit -m "docs: complete broker accounts overview"
```
## Definition of Done
- Все Acceptance Criteria из `spec.md` имеют component или unit coverage.
- Frontend tests, lint и build проходят.
- Docusaurus build проходит.
- Desktop и mobile UI проверены в браузере.
- `tasks.md` и roadmap соответствуют реализации.
- Нет изменений backend, OpenAPI или `apps/frontend/src/api/types.ts`.
- Нет незавершённых маркеров или отложенных требований.

View File

@ -0,0 +1,155 @@
# Информативный обзор брокерских счетов
Дата: 2026-06-19
Статус: согласовано к планированию
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
## Контекст
Страница `/broker` показывает открытые брокерские счета и ИИС карточками, но полезная информация в
них ограничена названием, типом, техническим статусом и идентификатором. Пользователь не может на
одном экране оценить совокупный капитал, сравнить счета и понять структуру активов.
Страница предназначена преимущественно для одного–трёх счетов.
## Цель
Превратить список брокерских счетов в компактный финансовый обзор, который с первого взгляда
отвечает на вопросы:
- сколько средств находится на всех доступных счетах;
- как изменилась их стоимость за день;
- сколько свободных денег доступно;
- как капитал распределён между счетами и основными классами активов;
- какой счёт нужно открыть для подробного анализа.
## Область изменений
Фича изменяет страницу списка брокерских счетов и включает:
- общую сводку по успешно загруженным счетам;
- информативные карточки отдельных счетов;
- независимую загрузку данных каждого счёта;
- состояния загрузки, частичной ошибки и отсутствия счетов;
- адаптивное отображение на широких и узких экранах.
Детальные страницы счёта, позиции, операции, торговые действия и правила расчёта показателей,
приходящих от T-Bank, не изменяются.
## Требования
### 1. Общая сводка
Над списком счетов отображается сводка по портфелям, данные которых успешно загружены:
- количество доступных счетов;
- совокупная стоимость портфелей;
- совокупный дневной результат в деньгах и процентах;
- свободные деньги;
- распределение стоимости по акциям, облигациям, фондам, деньгам и прочим активам.
Денежные значения разных валют не складываются и показываются отдельными суммами. Процентный
дневной результат и единое распределение активов показываются только для сопоставимых денежных
значений одной валюты. Интерфейс не выполняет неявную конвертацию валют.
Для каждой валюты совокупный дневной процент рассчитывается как отношение суммы дневных изменений
к суммарной стоимости портфелей на начало дня:
```text
sum(daily) / (sum(portfolio) - sum(daily)) * 100
```
Процент не показывается, если хотя бы у одного включённого в валютную группу счёта отсутствует
стоимость или дневное изменение либо если стоимость на начало дня неположительна.
Если загружены не все счета, сводка явно сообщает, по скольким счетам рассчитаны показатели.
Недоступный счёт не включается в агрегированные значения.
### 2. Карточка счёта
Для каждого счёта показываются:
- название;
- понятный тип: `Брокерский счёт` или `ИИС`;
- дата открытия, если она доступна;
- текущая стоимость;
- дневной результат в деньгах и процентах;
- ожидаемая доходность;
- распределение стоимости по основным классам активов.
Технический идентификатор, сырой enum статуса и сырой enum уровня доступа в карточке не
отображаются.
Вся карточка является доступной с клавиатуры ссылкой на обзор выбранного счёта. Цвет доходности
используется только как дополнительный признак: знак и числовое значение остаются видимыми.
### 3. Загрузка
После получения списка счетов данные их портфелей загружаются независимо. До появления значений
общая сводка и карточки показывают skeleton-состояние с устойчивыми размерами, чтобы содержимое не
скакало при загрузке.
Появление данных одного счёта не должно ждать завершения остальных запросов.
### 4. Ошибки
Ошибка загрузки списка счетов показывает ошибку всей страницы.
Ошибка загрузки портфеля отдельного счёта:
- не скрывает другие счета;
- не блокирует переход в доступные счета;
- показывает локальное сообщение в карточке проблемного счёта;
- позволяет повторить загрузку этого счёта;
- исключает этот счёт из общей сводки и маркирует сводку как частичную.
### 5. Пустое состояние
Если открытых брокерских счетов и ИИС нет, страница показывает отдельное пустое состояние вместо
пустой сетки карточек. Текст объясняет, какие счета появятся на странице после подключения T-Bank.
### 6. Адаптивность
При одном–трёх счетах карточки располагаются вертикально и сохраняют единый порядок показателей для
быстрого сравнения.
На узком экране показатели переносятся на несколько строк, диаграмма распределения остаётся
читаемой, а интерактивные области не требуют горизонтальной прокрутки.
## Ограничения
- Страница остаётся read-only.
- Frontend не обращается к T-Bank напрямую.
- Агрегация не пересчитывает показатели отдельного счёта: исходные стоимости и доходность берутся
из существующих ответов портфеля. Единственный новый производный показатель — совокупный дневной
процент по явно заданной в этой спецификации формуле.
- Данные разных счетов могут иметь разные моменты `asOf`; интерфейс не представляет сводку как
транзакционно согласованный снимок.
- Решение рассчитано на один–три счёта. Расширение этого предположения требует пересмотра
[ADR-012](../../../apps/docs/docs/adr/ADR-012-frontend-broker-account-aggregation.md).
## Acceptance Criteria
- Над счетами показана общая стоимость, дневной результат, свободные деньги, число счетов и
распределение активов.
- Значения разных валют не складываются без курса конвертации.
- Каждая успешно загруженная карточка показывает стоимость, доходность, тип и распределение активов.
- Название и дата открытия показываются, когда доступны.
- Технический ID и сырые T-Bank enum-значения не отображаются.
- Вся карточка ведёт на обзор соответствующего счёта и доступна с клавиатуры.
- Во время загрузки отображаются skeleton-состояния без заметного изменения геометрии страницы.
- Ошибка одного портфеля не скрывает остальные счета и не включает недоступные данные в сводку.
- Пользователь может повторить запрос проблемного счёта.
- При отсутствии счетов отображается объясняющее пустое состояние.
- Страница не требует горизонтальной прокрутки на поддерживаемых мобильных ширинах.
- Unit-тесты покрывают агрегацию, валютные ограничения и расчёт распределения.
- Component-тесты покрывают успешную загрузку, skeleton, пустое состояние, частичную ошибку,
повторный запрос и переход в счёт.
## Не цели
- Добавление aggregate endpoint на backend.
- Конвертация валют и получение валютных курсов.
- Поддержка более трёх счетов как отдельного плотного режима.
- Изменение детальной страницы брокерского счёта.
- Изменение T-Bank DTO, OpenAPI-контракта или сгенерированных frontend-типов.

View File

@ -0,0 +1,40 @@
# Информативный обзор брокерских счетов — задачи
Статус: реализовано
Подробные шаги, команды и ожидаемые результаты находятся в [plan.md](plan.md).
## 1. Агрегация
- [x] Добавить чистую валютно-безопасную агрегацию портфелей.
- [x] Покрыть формулу дневного процента и edge cases unit-тестами.
- [x] Не смешивать валюты и не выполнять неявную конвертацию.
## 2. Загрузка данных
- [x] Добавить параллельные portfolio queries для списка счетов.
- [x] Переиспользовать query keys детальной страницы.
- [x] Проверить независимое завершение запросов и кеш.
## 3. Интерфейс
- [x] Добавить общую сводку по успешно загруженным счетам.
- [x] Добавить информативную карточку счёта и allocation bar.
- [x] Добавить skeleton, empty state и локальную ошибку с retry.
- [x] Убрать технический ID и сырые T-Bank enum-значения.
- [x] Обеспечить keyboard navigation и текстовые признаки доходности.
## 4. Визуальная проверка
- [x] Реализовать согласованное зелёно-нейтральное визуальное направление.
- [x] Проверить desktop 1280px и mobile 390px без horizontal overflow.
- [x] Проверить loading и partial-error states в браузере.
## 5. Definition of Done
- [x] Frontend tests проходят.
- [x] Frontend lint проходит.
- [x] Frontend build проходит.
- [x] Docusaurus build проходит.
- [x] Code review завершён.
- [x] Roadmap отмечает фичу реализованной.

View File

@ -11,9 +11,10 @@ Roadmap отражает порядок продуктовой работы, н
Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций.
- [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано.
- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано.
## Следующие этапы для активной фичи
1. [x] Проверить и утвердить `spec.md` для планирования.
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.
3. [x] Получить отдельное подтверждение пользователя перед началом реализации.