codex/broker-accounts-overview #22
@ -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` запросов;
|
||||
- нескольким клиентам нужна одинаковая серверная сводка;
|
||||
- появляется требование к согласованному серверному снимку или серверной валютной конвертации;
|
||||
- правила агрегации становятся самостоятельной бизнес-логикой, а не логикой представления.
|
||||
@ -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-разделе.
|
||||
|
||||
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal file
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
17
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal file
17
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal 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] }));
|
||||
}
|
||||
142
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal file
142
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal 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} />;
|
||||
}
|
||||
293
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal file
293
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
164
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal file
164
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal file
43
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
127
apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
Normal file
127
apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
198
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal file
198
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
439
docs/features/broker-accounts-overview/plan.md
Normal file
439
docs/features/broker-accounts-overview/plan.md
Normal 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`.
|
||||
- Нет незавершённых маркеров или отложенных требований.
|
||||
155
docs/features/broker-accounts-overview/spec.md
Normal file
155
docs/features/broker-accounts-overview/spec.md
Normal 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-типов.
|
||||
40
docs/features/broker-accounts-overview/tasks.md
Normal file
40
docs/features/broker-accounts-overview/tasks.md
Normal 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 отмечает фичу реализованной.
|
||||
@ -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] Получить отдельное подтверждение пользователя перед началом реализации.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user