feat: add broker accounts overview
All checks were successful
CI / ci (pull_request) Successful in 3m21s
CI / ci (push) Successful in 3m6s

This commit is contained in:
Sergey Krylov 2026-06-19 14:31:53 +03:00
parent 706bb12b6c
commit 1e007de5be
13 changed files with 1591 additions and 139 deletions

View File

@ -0,0 +1,135 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
vi.mock('../api/broker', () => ({
getBrokerPortfolio: vi.fn(),
}));
function createWrapper(queryClient?: QueryClient) {
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAccount(id: string): BrokerAccount {
return {
id,
type: 'brokerage',
name: id,
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
}
function createPortfolio(id: string): BrokerPortfolio {
return {
account: createAccount(id),
positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 },
totals: {
shares: { currency: 'RUB', units: '0', nano: 0, value: 100 },
bonds: null,
etf: null,
currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 },
},
yields: {
expectedPercent: 3,
daily: { currency: 'RUB', units: '0', nano: 0, value: 10 },
dailyPercent: 1,
},
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }],
blockedCash: [],
asOf: '2026-06-19T10:00:00.000Z',
};
}
describe('useBrokerAccountPortfolios', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps account-to-query mapping regardless of completion order', async () => {
const first = createDeferred<{
data: BrokerPortfolio;
meta: { fromCache: false; cachedAt: null };
}>();
const second = createDeferred<{
data: BrokerPortfolio;
meta: { fromCache: false; cachedAt: null };
}>();
vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => {
if (accountId === 'acc-1') {
return first.promise;
}
if (accountId === 'acc-2') {
return second.promise;
}
throw new Error(`Unexpected account ${accountId}`);
});
const accounts = [createAccount('acc-1'), createAccount('acc-2')];
const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), {
wrapper: createWrapper(),
});
expect(getBrokerPortfolio).toHaveBeenCalledTimes(2);
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1');
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2');
second.resolve({
data: createPortfolio('acc-2'),
meta: { fromCache: false, cachedAt: null },
});
await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2'));
expect(result.current[0].account.id).toBe('acc-1');
expect(result.current[0].query.data).toBeUndefined();
first.resolve({
data: createPortfolio('acc-1'),
meta: { fromCache: false, cachedAt: null },
});
await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1'));
expect(result.current[1].query.data?.account.id).toBe('acc-2');
});
it('reuses the same cache key as broker account overview page', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const cachedPortfolio = createPortfolio('acc-1');
queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio);
const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio));
expect(getBrokerPortfolio).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,17 @@
import { useQueries } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
const queries = useQueries({
queries: accounts.map((account) => ({
queryKey: ['broker', 'portfolio', account.id],
queryFn: async (): Promise<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
})),
});
return accounts.map((account, index) => ({ account, query: queries[index] }));
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,164 @@
import { SkeletonBlock } from '../../components/SkeletonBlock';
import { buildBrokerAllocation } from './brokerAllocation';
import { BrokerAllocationBar } from './BrokerAllocationBar';
import type { BrokerAccountsAggregate } from './brokerAccountsOverview';
import {
formatBrokerCurrencyValue,
formatBrokerSignedCurrencyValue,
formatBrokerSignedPercent,
} from './brokerAccountsOverview';
export function BrokerAccountsSummary({
aggregate,
availableCount,
totalCount,
isLoading,
}: {
aggregate: BrokerAccountsAggregate;
availableCount: number;
totalCount: number;
isLoading: boolean;
}) {
if (isLoading) {
return (
<section className="broker-accounts-summary broker-accounts-summary--loading">
<div className="broker-accounts-summary__hero">
<SkeletonBlock height={18} width="34%" />
<SkeletonBlock height={52} width="48%" />
<SkeletonBlock height={18} width="28%" />
</div>
<div className="broker-accounts-summary__metrics">
{[1, 2, 3].map((item) => (
<div className="broker-accounts-summary__metric" key={item}>
<SkeletonBlock height={14} width="45%" />
<SkeletonBlock height={26} width="70%" />
</div>
))}
</div>
</section>
);
}
return (
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
<div className="broker-accounts-summary__hero">
<div>
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
</div>
<div className="broker-accounts-summary__status">
<span>{totalCount} счетов</span>
{availableCount !== totalCount ? (
<span>
Доступно по {availableCount} из {totalCount} счетов
</span>
) : null}
</div>
</div>
<div className="broker-accounts-summary__currency-grid">
{aggregate.portfolios.map((portfolioSummary) => {
const allocation = buildBrokerAllocation({
account: {
id: 'aggregate',
type: 'brokerage',
name: 'aggregate',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 },
totals: {
shares: {
currency: portfolioSummary.currency,
units: '0',
nano: 0,
value: portfolioSummary.allocation.shares,
},
bonds: {
currency: portfolioSummary.currency,
units: '0',
nano: 0,
value: portfolioSummary.allocation.bonds,
},
etf:
portfolioSummary.allocation.etf > 0
? {
currency: portfolioSummary.currency,
units: '0',
nano: 0,
value: portfolioSummary.allocation.etf,
}
: null,
currencies: {
currency: portfolioSummary.currency,
units: '0',
nano: 0,
value: portfolioSummary.allocation.cash,
},
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: {
currency: portfolioSummary.currency,
units: '0',
nano: 0,
value: portfolioSummary.total,
},
},
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '',
});
return (
<article
className="broker-accounts-summary__currency-card"
key={portfolioSummary.currency}
>
<div className="broker-accounts-summary__currency-header">
<span className="broker-accounts-summary__currency">
{portfolioSummary.currency}
</span>
<strong className="broker-accounts-summary__total">
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
</strong>
</div>
<dl className="broker-accounts-summary__metrics">
<div className="broker-accounts-summary__metric">
<dt>За день</dt>
<dd>
{formatBrokerSignedCurrencyValue(
portfolioSummary.currency,
portfolioSummary.daily,
)}
</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Динамика</dt>
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Свободные деньги</dt>
<dd>
{formatBrokerCurrencyValue(
portfolioSummary.currency,
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
?.value ?? 0,
)}
</dd>
</div>
</dl>
<BrokerAllocationBar
title={`Распределение активов ${portfolioSummary.currency}`}
items={allocation.sectors}
/>
</article>
);
})}
</div>
</section>
);
}

View File

@ -0,0 +1,43 @@
import type { BrokerAllocationItem } from './brokerAllocation';
export function BrokerAllocationBar({
items,
title,
}: {
items: BrokerAllocationItem[];
title: string;
}) {
const positiveItems = items.filter((item) => item.value > 0);
if (positiveItems.length === 0) {
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
}
return (
<div className="broker-allocation-bar">
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
{positiveItems.map((item) => (
<span
key={item.key}
className="broker-allocation-bar__segment"
style={{ width: `${item.percent}%`, background: item.color }}
aria-hidden="true"
/>
))}
</div>
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
{positiveItems.map((item) => (
<li className="broker-allocation-bar__legend-item" key={item.key}>
<span
className="broker-allocation-bar__swatch"
style={{ background: item.color }}
aria-hidden="true"
/>
<span>{item.label}</span>
<strong>{item.percent.toFixed(0)}%</strong>
</li>
))}
</ul>
</div>
);
}

View File

@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event';
import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as positionsHook from '../../hooks/useBrokerPositions';
@ -12,7 +11,6 @@ import type { BrokerPortfolio, BrokerPosition } from '../../api/responses';
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
import { BrokerAccountsPage } from './BrokerAccountsPage';
import { BrokerPositionsPage } from './BrokerPositionsPage';
import { BrokerOperationsPage } from './BrokerOperationsPage';
@ -276,37 +274,6 @@ describe('Broker pages', () => {
expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
});
it('renders broker and IIS accounts', () => {
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
{
id: 'acc-2',
type: 'iis',
name: 'IIS',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
isLoading: false,
isFetching: false,
error: null,
} as any);
renderWithClient(<BrokerAccountsPage />);
expect(screen.getByText('Broker')).toBeInTheDocument();
expect(screen.getByText('IIS')).toBeInTheDocument();
});
it('renders the broker account overview with allocation, asset links and recent operations', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),

View File

@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { BrokerPortfolio } from '../../api/responses';
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
function portfolio(
id: string,
currency: string,
total: number,
daily: number | null,
cash: number,
): BrokerPortfolio {
return {
account: {
id,
type: 'brokerage',
name: id,
status: 'ACCOUNT_STATUS_OPEN',
openedAt: '2022-06-16T00:00:00.000Z',
accessLevel: null,
},
positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 },
totals: {
shares: { currency, units: '0', nano: 0, value: total * 0.5 },
bonds: { currency, units: '0', nano: 0, value: total * 0.3 },
etf: null,
currencies: { currency, units: '0', nano: 0, value: total * 0.2 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency, units: '0', nano: 0, value: total },
},
yields: {
expectedPercent: 10,
daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily },
dailyPercent: null,
},
cash: [{ currency, units: '0', nano: 0, value: cash }],
blockedCash: [],
asOf: '2026-06-19T10:00:00.000Z',
};
}
describe('aggregateBrokerAccounts', () => {
it('sums comparable portfolios and uses the specified daily percent formula', () => {
const result = aggregateBrokerAccounts([
portfolio('a', 'RUB', 1_100, 100, 200),
portfolio('b', 'RUB', 2_200, 200, 300),
]);
expect(result.portfolios).toEqual([
expect.objectContaining({
currency: 'RUB',
total: 3_300,
daily: 300,
dailyPercent: 10,
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
}),
]);
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
});
it('keeps different currencies separate', () => {
const result = aggregateBrokerAccounts([
portfolio('rub', 'RUB', 1_100, 100, 200),
portfolio('usd', 'USD', 550, 50, 25),
]);
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
{ currency: 'RUB', total: 1_100 },
{ currency: 'USD', total: 550 },
]);
});
it('does not expose a daily percent when one account lacks daily data', () => {
const result = aggregateBrokerAccounts([
portfolio('a', 'RUB', 1_100, 100, 200),
portfolio('b', 'RUB', 2_000, null, 300),
]);
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
});
it('does not expose a daily percent when start of day is non-positive', () => {
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]);
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null });
});
it('clamps negative residual other allocation to zero', () => {
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100);
overAllocated.totals.shares!.value = 700;
overAllocated.totals.bonds!.value = 400;
overAllocated.totals.currencies!.value = 100;
const result = aggregateBrokerAccounts([overAllocated]);
expect(result.portfolios[0].allocation).toEqual({
shares: 700,
bonds: 400,
etf: 0,
cash: 100,
other: 0,
});
});
it('returns empty summaries for empty or unsupported portfolios', () => {
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100);
missingTotal.totals.portfolio = null;
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] });
expect(aggregateBrokerAccounts([missingTotal])).toEqual({
portfolios: [],
cash: [{ currency: 'RUB', value: 100 }],
});
});
it('groups cash separately by currency', () => {
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100);
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 });
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
{ currency: 'RUB', value: 100 },
{ currency: 'USD', value: 25 },
]);
});
});

View File

@ -0,0 +1,198 @@
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
export interface BrokerCurrencyAllocationSummary {
shares: number;
bonds: number;
etf: number;
cash: number;
other: number;
}
export interface BrokerCurrencyPortfolioSummary {
currency: string;
total: number;
daily: number | null;
dailyPercent: number | null;
allocation: BrokerCurrencyAllocationSummary;
}
export interface BrokerCurrencyCashSummary {
currency: string;
value: number;
}
export interface BrokerAccountsAggregate {
portfolios: BrokerCurrencyPortfolioSummary[];
cash: BrokerCurrencyCashSummary[];
}
interface MutableCurrencySummary {
currency: string;
total: number;
daily: number | null;
dailyComparable: boolean;
allocation: BrokerCurrencyAllocationSummary;
}
function moneyValue(money: BrokerMoney | null | undefined): number {
return money?.value ?? 0;
}
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
if (!value) {
return '—';
}
return formatBrokerCurrencyValue(value.currency, value.value);
}
export function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
maximumFractionDigits: 2,
}).format(value);
}
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
export function formatBrokerPercent(value: number | null): string {
if (value === null) {
return '—';
}
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
export function formatBrokerSignedPercent(value: number | null): string {
if (value === null) {
return '—';
}
const formatted = formatBrokerPercent(Math.abs(value));
if (value > 0) {
return `+${formatted}`;
}
if (value < 0) {
return `${formatted}`;
}
return formatted;
}
export function formatBrokerDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
return new Date(value).toLocaleDateString('ru-RU');
}
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
}
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
for (const portfolio of portfolios) {
for (const cash of portfolio.cash) {
if (!cash.currency) {
continue;
}
const existingCash = cashSummaries.get(cash.currency);
if (existingCash) {
existingCash.value += cash.value;
} else {
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value });
}
}
const totalMoney = portfolio.totals.portfolio;
const currency = totalMoney?.currency;
if (!totalMoney || !currency) {
continue;
}
const existingSummary = portfolioSummaries.get(currency);
const summary =
existingSummary ??
({
currency,
total: 0,
daily: 0,
dailyComparable: true,
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
} satisfies MutableCurrencySummary);
const total = totalMoney.value;
const shares = moneyValue(portfolio.totals.shares);
const bonds = moneyValue(portfolio.totals.bonds);
const etf = moneyValue(portfolio.totals.etf);
const cash = moneyValue(portfolio.totals.currencies);
const other = Math.max(0, total - shares - bonds - etf - cash);
summary.total += total;
summary.allocation.shares += shares;
summary.allocation.bonds += bonds;
summary.allocation.etf += etf;
summary.allocation.cash += cash;
summary.allocation.other += other;
const dailyMoney = portfolio.yields.daily;
const comparableDaily = dailyMoney && dailyMoney.currency === currency;
if (!comparableDaily) {
summary.daily = null;
summary.dailyComparable = false;
} else if (summary.dailyComparable) {
summary.daily = (summary.daily ?? 0) + dailyMoney.value;
}
if (!existingSummary) {
portfolioSummaries.set(currency, summary);
}
}
return {
portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
const daily = summary.dailyComparable ? summary.daily : null;
const startOfDay = daily === null ? null : summary.total - daily;
const dailyPercent =
daily === null || startOfDay === null || startOfDay <= 0
? null
: (daily / startOfDay) * 100;
return {
currency: summary.currency,
total: summary.total,
daily,
dailyPercent,
allocation: summary.allocation,
};
}),
cash: Array.from(cashSummaries.values()),
};
}

View File

@ -16,6 +16,12 @@
--color-negative: #c62828;
--border-radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
--broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%);
--broker-overview-panel: rgba(15, 51, 36, 0.93);
--broker-overview-panel-soft: rgba(255, 255, 255, 0.09);
--broker-overview-border: rgba(21, 61, 43, 0.12);
--broker-overview-accent: #98c484;
--broker-overview-gold: #d7b268;
}
.pnl-cell {
@ -219,12 +225,307 @@ a {
color: var(--color-negative);
}
@media (max-width: 720px) {
.broker-account__header {
padding: 0 0 8px;
.broker-accounts-page {
display: grid;
gap: 24px;
}
.broker-account__workspace {
.broker-accounts-page__header {
display: flex;
justify-content: space-between;
align-items: end;
gap: 16px;
}
.broker-accounts-page__eyebrow,
.broker-account-card__eyebrow,
.broker-accounts-summary__eyebrow {
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.broker-accounts-page__title,
.broker-account-card__title,
.broker-accounts-summary__title {
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
line-height: 1.05;
}
.broker-accounts-page__title {
font-size: clamp(2.2rem, 3vw, 3rem);
}
.broker-accounts-page__caption {
color: var(--color-text-secondary);
}
.broker-accounts-summary {
padding: 28px;
border-radius: 28px;
background: var(--broker-overview-bg);
box-shadow:
0 24px 60px rgba(15, 52, 35, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
border: 1px solid rgba(255, 255, 255, 0.7);
display: grid;
gap: 20px;
}
.broker-accounts-summary__hero {
display: grid;
gap: 12px;
padding: 24px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(152, 196, 132, 0.3), transparent 28%),
linear-gradient(135deg, var(--broker-overview-panel) 0%, #173f2d 100%);
color: #f8f5ec;
}
.broker-accounts-summary__hero .broker-accounts-summary__eyebrow {
color: rgba(248, 245, 236, 0.72);
}
.broker-accounts-summary__title {
font-size: clamp(1.9rem, 2.4vw, 2.6rem);
}
.broker-accounts-summary__status {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: rgba(248, 245, 236, 0.78);
}
.broker-accounts-summary__status span {
padding: 6px 10px;
border-radius: 999px;
background: var(--broker-overview-panel-soft);
}
.broker-accounts-summary__currency-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.broker-accounts-summary__currency-card,
.broker-account-card,
.broker-accounts-empty {
border-radius: 24px;
border: 1px solid var(--broker-overview-border);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 20px 45px rgba(31, 48, 39, 0.08);
}
.broker-accounts-summary__currency-card {
padding: 22px;
display: grid;
gap: 16px;
}
.broker-accounts-summary__currency-header {
display: grid;
gap: 6px;
}
.broker-accounts-summary__currency {
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.broker-accounts-summary__total {
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
font-size: clamp(1.8rem, 2vw, 2.4rem);
}
.broker-accounts-summary__metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.broker-accounts-summary__metric {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.7);
}
.broker-accounts-summary__metric dt,
.broker-account-card__stat span {
font-size: 13px;
color: var(--color-text-secondary);
}
.broker-accounts-summary__metric dd,
.broker-account-card__stat strong {
font-size: 1.05rem;
font-weight: 700;
}
.broker-allocation-bar {
display: grid;
gap: 12px;
}
.broker-allocation-bar__track {
min-height: 12px;
border-radius: 999px;
overflow: hidden;
display: flex;
background: rgba(19, 54, 38, 0.08);
}
.broker-allocation-bar__segment {
min-width: 8px;
}
.broker-allocation-bar__legend {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.broker-allocation-bar__legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(19, 54, 38, 0.05);
}
.broker-allocation-bar__swatch {
width: 9px;
height: 9px;
border-radius: 999px;
}
.broker-allocation-bar__empty {
color: var(--color-text-secondary);
}
.broker-accounts-page__cards {
display: grid;
gap: 16px;
}
.broker-account-card {
padding: 22px;
}
.broker-account-card--link {
display: grid;
gap: 18px;
color: inherit;
text-decoration: none;
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
border-color 0.22s ease;
}
.broker-account-card--link:hover {
transform: translateY(-2px);
box-shadow: 0 26px 50px rgba(31, 48, 39, 0.11);
border-color: rgba(38, 92, 55, 0.22);
}
.broker-account-card--link:focus-visible {
outline: 3px solid rgba(59, 128, 74, 0.3);
outline-offset: 3px;
}
.broker-account-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.broker-account-card__title {
font-size: clamp(1.4rem, 2vw, 1.8rem);
}
.broker-account-card__opened {
color: var(--color-text-secondary);
text-align: right;
}
.broker-account-card__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.broker-account-card__stat {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 239, 0.88));
border: 1px solid rgba(31, 48, 39, 0.08);
}
.broker-account-card__stat--wide {
grid-column: span 2;
}
.broker-account-card__alert {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 18px;
border-radius: 18px;
background: rgba(198, 40, 40, 0.08);
color: #8e2525;
}
.broker-account-card__retry {
border: none;
border-radius: 999px;
background: #163f2c;
color: #fff;
padding: 10px 16px;
font: inherit;
cursor: pointer;
}
.broker-account-card__retry:focus-visible {
outline: 3px solid rgba(59, 128, 74, 0.3);
outline-offset: 3px;
}
.broker-accounts-empty {
padding: 28px;
display: grid;
gap: 10px;
}
@media (prefers-reduced-motion: reduce) {
.broker-account-card--link,
.loading-spinner,
.skeleton {
transition: none;
animation: none;
}
}
@media (max-width: 720px) {
.broker-account__header {
padding: 0 0 8px;
}
.broker-account__workspace {
gap: 16px;
grid-template-columns: minmax(0, 1fr);
}
@ -255,6 +556,38 @@ a {
width: 100%;
align-self: center;
}
.broker-accounts-page__header,
.broker-account-card__header {
grid-template-columns: minmax(0, 1fr);
display: grid;
}
.broker-accounts-summary,
.broker-account-card,
.broker-accounts-empty {
padding: 18px;
border-radius: 20px;
}
.broker-accounts-summary__hero,
.broker-accounts-summary__metrics,
.broker-account-card__grid {
grid-template-columns: 1fr;
}
.broker-account-card__stat--wide {
grid-column: auto;
}
.broker-account-card__opened {
text-align: left;
}
.broker-account-card__alert {
align-items: stretch;
flex-direction: column;
}
}
.broker-operations__toolbar {

View File

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

View File

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