Compare commits
3 Commits
a95308762f
...
1e007de5be
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e007de5be | |||
| 706bb12b6c | |||
| b7b627a0f3 |
@ -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,7 +1,7 @@
|
|||||||
# Архитектурные решения (ADR)
|
# Архитектурные решения (ADR)
|
||||||
|
|
||||||
| ADR | Статус | Описание |
|
| ADR | Статус | Описание |
|
||||||
|---|---|---|
|
| ------------------------------------------------------ | ---------- | ---------------------------------------------- |
|
||||||
| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX |
|
| [ADR-001](ADR-001-backend-single-point-of-access) | Accepted | Backend — единственная точка доступа к MOEX |
|
||||||
| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache |
|
| [ADR-002](ADR-002-in-memory-cache) | Accepted | Стратегия in-memory cache |
|
||||||
| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting |
|
| [ADR-003](ADR-003-rate-limiting-strategy) | Accepted | Стратегия rate limiting |
|
||||||
@ -13,5 +13,6 @@
|
|||||||
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
|
| [ADR-009](ADR-009-portfolio-domain) | Accepted | Доменная модель портфеля |
|
||||||
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
| [ADR-010](ADR-010-backend-price-computation) | Accepted | Расчёт цен на backend |
|
||||||
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
|
| [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-разделе.
|
Все опубликованные 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 { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
||||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
import { BrokerAccountCard } from './BrokerAccountCard';
|
||||||
|
import { BrokerAccountsSummary } from './BrokerAccountsSummary';
|
||||||
|
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
||||||
|
|
||||||
const cardStyle = {
|
function BrokerAccountsPageSkeleton() {
|
||||||
display: 'block',
|
return (
|
||||||
padding: 20,
|
<div className="broker-accounts-page">
|
||||||
background: 'var(--color-surface)',
|
<header className="broker-accounts-page__header">
|
||||||
border: '1px solid #e0e0e0',
|
<div>
|
||||||
borderRadius: 8,
|
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||||
color: 'var(--color-text)',
|
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||||
textDecoration: 'none',
|
</div>
|
||||||
boxShadow: 'var(--shadow)',
|
</header>
|
||||||
} satisfies React.CSSProperties;
|
<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() {
|
export function BrokerAccountsPage() {
|
||||||
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
||||||
|
const safeAccounts = accounts ?? [];
|
||||||
|
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
return <BrokerAccountsPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeAccounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
<div className="broker-accounts-page">
|
||||||
|
<header className="broker-accounts-page__header">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||||
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</header>
|
||||||
style={{
|
<section className="broker-accounts-empty">
|
||||||
display: 'grid',
|
<h2>Пока нет подключённых счетов</h2>
|
||||||
gap: 16,
|
<p>
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
|
||||||
}}
|
</p>
|
||||||
>
|
</section>
|
||||||
{[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>
|
}
|
||||||
</div>
|
|
||||||
);
|
const successfulPortfolios = accountQueries
|
||||||
}
|
.map(({ query }) => query.data)
|
||||||
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => Boolean(portfolio));
|
||||||
|
const loadingCount = accountQueries.filter(
|
||||||
return (
|
({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data,
|
||||||
<div>
|
).length;
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
const availableCount = successfulPortfolios.length;
|
||||||
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
const aggregate = aggregateBrokerAccounts(successfulPortfolios);
|
||||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>
|
|
||||||
{(accounts ?? []).length}
|
return (
|
||||||
</span>
|
<div className="broker-accounts-page">
|
||||||
</div>
|
<header className="broker-accounts-page__header">
|
||||||
|
<div>
|
||||||
<div
|
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||||
style={{
|
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||||
display: 'grid',
|
</div>
|
||||||
gap: 16,
|
<p className="broker-accounts-page__caption">
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
{safeAccounts.length} счетов под наблюдением
|
||||||
}}
|
</p>
|
||||||
>
|
</header>
|
||||||
{(accounts ?? []).map((account) => (
|
|
||||||
<Link key={account.id} to={`/broker/${encodeURIComponent(account.id)}`} style={cardStyle}>
|
<BrokerAccountsSummary
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>{account.name}</div>
|
aggregate={aggregate}
|
||||||
<div style={{ display: 'grid', gap: 6, color: 'var(--color-text-secondary)' }}>
|
availableCount={availableCount}
|
||||||
<span>{account.type === 'iis' ? 'ИИС' : 'Брокерский счет'}</span>
|
totalCount={safeAccounts.length}
|
||||||
<span>{account.status}</span>
|
isLoading={availableCount === 0 && loadingCount > 0}
|
||||||
<span>{account.id}</span>
|
/>
|
||||||
</div>
|
|
||||||
</Link>
|
<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>
|
||||||
</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 { type ReactElement } from 'react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import * as accountHook from '../../hooks/useBrokerAccounts';
|
|
||||||
import * as operationsHook from '../../hooks/useBrokerOperations';
|
import * as operationsHook from '../../hooks/useBrokerOperations';
|
||||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||||||
@ -12,7 +11,6 @@ import type { BrokerPortfolio, BrokerPosition } from '../../api/responses';
|
|||||||
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
||||||
|
|
||||||
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
|
||||||
import { BrokerPositionsPage } from './BrokerPositionsPage';
|
import { BrokerPositionsPage } from './BrokerPositionsPage';
|
||||||
import { BrokerOperationsPage } from './BrokerOperationsPage';
|
import { BrokerOperationsPage } from './BrokerOperationsPage';
|
||||||
|
|
||||||
@ -276,37 +274,6 @@ describe('Broker pages', () => {
|
|||||||
expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
|
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', () => {
|
it('renders the broker account overview with allocation, asset links and recent operations', () => {
|
||||||
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||||||
data: createOverviewPortfolio(),
|
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;
|
--color-negative: #c62828;
|
||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
--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 {
|
.pnl-cell {
|
||||||
@ -219,6 +225,301 @@ a {
|
|||||||
color: var(--color-negative);
|
color: var(--color-negative);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.broker-accounts-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 720px) {
|
||||||
.broker-account__header {
|
.broker-account__header {
|
||||||
padding: 0 0 8px;
|
padding: 0 0 8px;
|
||||||
@ -255,6 +556,38 @@ a {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
align-self: center;
|
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 {
|
.broker-operations__toolbar {
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
- [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md)
|
- [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md)
|
||||||
- [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md)
|
- [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md)
|
||||||
- [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано
|
- [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано
|
||||||
|
- [Информативный обзор брокерских счетов](../features/broker-accounts-overview/spec.md)
|
||||||
- [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md)
|
- [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md)
|
||||||
- [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md)
|
- [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md)
|
||||||
- [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/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-account-sections/spec.md) — реализовано.
|
||||||
|
- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано.
|
||||||
|
|
||||||
## Следующие этапы для активной фичи
|
## Следующие этапы для активной фичи
|
||||||
|
|
||||||
1. [x] Проверить и утвердить `spec.md` для планирования.
|
1. [x] Проверить и утвердить `spec.md` для планирования.
|
||||||
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
|
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
|
||||||
3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.
|
3. [x] Получить отдельное подтверждение пользователя перед началом реализации.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user