codex/broker-accounts-overview #22
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal file
135
apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getBrokerPortfolio } from '../api/broker';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
||||
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
||||
|
||||
vi.mock('../api/broker', () => ({
|
||||
getBrokerPortfolio: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWrapper(queryClient?: QueryClient) {
|
||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createAccount(id: string): BrokerAccount {
|
||||
return {
|
||||
id,
|
||||
type: 'brokerage',
|
||||
name: id,
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createPortfolio(id: string): BrokerPortfolio {
|
||||
return {
|
||||
account: createAccount(id),
|
||||
positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 },
|
||||
totals: {
|
||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||
bonds: null,
|
||||
etf: null,
|
||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 },
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 },
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: 3,
|
||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 10 },
|
||||
dailyPercent: 1,
|
||||
},
|
||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }],
|
||||
blockedCash: [],
|
||||
asOf: '2026-06-19T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useBrokerAccountPortfolios', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps account-to-query mapping regardless of completion order', async () => {
|
||||
const first = createDeferred<{
|
||||
data: BrokerPortfolio;
|
||||
meta: { fromCache: false; cachedAt: null };
|
||||
}>();
|
||||
const second = createDeferred<{
|
||||
data: BrokerPortfolio;
|
||||
meta: { fromCache: false; cachedAt: null };
|
||||
}>();
|
||||
|
||||
vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => {
|
||||
if (accountId === 'acc-1') {
|
||||
return first.promise;
|
||||
}
|
||||
|
||||
if (accountId === 'acc-2') {
|
||||
return second.promise;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected account ${accountId}`);
|
||||
});
|
||||
|
||||
const accounts = [createAccount('acc-1'), createAccount('acc-2')];
|
||||
const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(getBrokerPortfolio).toHaveBeenCalledTimes(2);
|
||||
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1');
|
||||
expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2');
|
||||
|
||||
second.resolve({
|
||||
data: createPortfolio('acc-2'),
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2'));
|
||||
expect(result.current[0].account.id).toBe('acc-1');
|
||||
expect(result.current[0].query.data).toBeUndefined();
|
||||
|
||||
first.resolve({
|
||||
data: createPortfolio('acc-1'),
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1'));
|
||||
expect(result.current[1].query.data?.account.id).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('reuses the same cache key as broker account overview page', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const cachedPortfolio = createPortfolio('acc-1');
|
||||
queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio);
|
||||
|
||||
const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio));
|
||||
expect(getBrokerPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
17
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal file
17
apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { getBrokerPortfolio } from '../api/broker';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
||||
|
||||
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
||||
const queries = useQueries({
|
||||
queries: accounts.map((account) => ({
|
||||
queryKey: ['broker', 'portfolio', account.id],
|
||||
queryFn: async (): Promise<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
})),
|
||||
});
|
||||
|
||||
return accounts.map((account, index) => ({ account, query: queries[index] }));
|
||||
}
|
||||
142
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal file
142
apps/frontend/src/pages/broker/BrokerAccountCard.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../../api/responses';
|
||||
import { buildBrokerAllocation } from './brokerAllocation';
|
||||
import { BrokerAllocationBar } from './BrokerAllocationBar';
|
||||
import {
|
||||
brokerAccountTypeLabel,
|
||||
formatBrokerDate,
|
||||
formatBrokerMoney,
|
||||
formatBrokerSignedCurrencyValue,
|
||||
formatBrokerSignedPercent,
|
||||
} from './brokerAccountsOverview';
|
||||
|
||||
function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) {
|
||||
return (
|
||||
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
|
||||
<div className="broker-account-card__header">
|
||||
<div>
|
||||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
||||
<h2 className="broker-account-card__title">{name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="broker-account-card__grid">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div className="broker-account-card__stat" key={item}>
|
||||
<SkeletonBlock height={12} width="50%" />
|
||||
<SkeletonBlock height={24} width="75%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SkeletonBlock height={16} width="100%" borderRadius={999} />
|
||||
<SkeletonBlock height={16} width="65%" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokerAccountCardError({
|
||||
account,
|
||||
onRetry,
|
||||
}: {
|
||||
account: BrokerAccount;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="broker-account-card broker-account-card--error">
|
||||
<div className="broker-account-card__header">
|
||||
<div>
|
||||
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
|
||||
<h2 className="broker-account-card__title">{account.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="broker-account-card__alert" role="alert">
|
||||
<p>Не удалось загрузить данные счёта</p>
|
||||
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokerAccountCardSuccess({
|
||||
account,
|
||||
portfolio,
|
||||
}: {
|
||||
account: BrokerAccount;
|
||||
portfolio: BrokerPortfolio;
|
||||
}) {
|
||||
const typeLabel = brokerAccountTypeLabel(account.type);
|
||||
const openedAt = formatBrokerDate(account.openedAt);
|
||||
const allocation = buildBrokerAllocation(portfolio);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="broker-account-card broker-account-card--link"
|
||||
to={`/broker/${encodeURIComponent(account.id)}`}
|
||||
>
|
||||
<div className="broker-account-card__header">
|
||||
<div>
|
||||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
||||
<h2 className="broker-account-card__title">{account.name}</h2>
|
||||
</div>
|
||||
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="broker-account-card__grid">
|
||||
<div className="broker-account-card__stat broker-account-card__stat--wide">
|
||||
<span>Стоимость</span>
|
||||
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
|
||||
</div>
|
||||
<div className="broker-account-card__stat">
|
||||
<span>За день</span>
|
||||
<strong>
|
||||
{formatBrokerSignedCurrencyValue(
|
||||
portfolio.totals.portfolio?.currency ?? 'RUB',
|
||||
portfolio.yields.daily?.value ?? null,
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="broker-account-card__stat">
|
||||
<span>Дневная динамика</span>
|
||||
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
|
||||
</div>
|
||||
<div className="broker-account-card__stat">
|
||||
<span>Ожидаемая доходность</span>
|
||||
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrokerAccountCard({
|
||||
account,
|
||||
portfolio,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
account: BrokerAccount;
|
||||
portfolio?: BrokerPortfolio;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (isLoading && !portfolio) {
|
||||
return (
|
||||
<BrokerAccountCardSkeleton
|
||||
name={account.name}
|
||||
typeLabel={brokerAccountTypeLabel(account.type)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !portfolio) {
|
||||
return <BrokerAccountCardError account={account} onRetry={onRetry} />;
|
||||
}
|
||||
|
||||
return <BrokerAccountCardSuccess account={account} portfolio={portfolio} />;
|
||||
}
|
||||
293
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal file
293
apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { type ReactElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../../api/responses';
|
||||
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
|
||||
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
|
||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||
|
||||
function renderPage(ui: ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function createAccount(
|
||||
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
|
||||
): BrokerAccount {
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
type: account.type ?? 'brokerage',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z',
|
||||
accessLevel: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createPortfolio(
|
||||
account: BrokerAccount,
|
||||
overrides: Partial<BrokerPortfolio> = {},
|
||||
): BrokerPortfolio {
|
||||
return {
|
||||
account,
|
||||
positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 },
|
||||
totals: {
|
||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 600 },
|
||||
bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 },
|
||||
etf: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 },
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: 8,
|
||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||
dailyPercent: 11.11,
|
||||
},
|
||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }],
|
||||
blockedCash: [],
|
||||
asOf: '2026-06-19T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createQueryState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BrokerAccountsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders heading, aggregate summary, daily result and two linked cards', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{
|
||||
account: broker,
|
||||
query: createQueryState({ data: createPortfolio(broker) }),
|
||||
},
|
||||
{
|
||||
account: iis,
|
||||
query: createQueryState({
|
||||
data: createPortfolio(iis, {
|
||||
totals: {
|
||||
shares: { currency: 'RUB', units: '0', nano: 0, value: 800 },
|
||||
bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 },
|
||||
etf: { currency: 'RUB', units: '0', nano: 0, value: 200 },
|
||||
currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 },
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: 12,
|
||||
daily: { currency: 'RUB', units: '0', nano: 0, value: 140 },
|
||||
dailyPercent: 9.59,
|
||||
},
|
||||
cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/broker/acc-1',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/broker/acc-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows human labels and opened date without exposing technical fields', () => {
|
||||
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
||||
] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
expect(screen.getByText('Брокерский счёт')).toBeInTheDocument();
|
||||
expect(screen.getByText('ИИС')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2);
|
||||
expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('account one')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/broker/account%20one',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows page skeleton while accounts are loading', () => {
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const { container } = renderPage(<BrokerAccountsPage />);
|
||||
|
||||
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no accounts', () => {
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the summary as partial when one account portfolio is unavailable', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: iis,
|
||||
query: createQueryState({ isError: true, error: new Error('boom') }),
|
||||
},
|
||||
] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a local alert and retries only the failed account', async () => {
|
||||
const user = userEvent.setup();
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
const refetch = vi.fn();
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: iis,
|
||||
query: createQueryState({ isError: true, error: new Error('boom'), refetch }),
|
||||
},
|
||||
] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveTextContent('Не удалось загрузить данные счёта');
|
||||
await user.click(
|
||||
within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }),
|
||||
);
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps currencies separate in the overview summary', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
||||
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, usd],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: usd,
|
||||
query: createQueryState({
|
||||
data: createPortfolio(usd, {
|
||||
totals: {
|
||||
shares: { currency: 'USD', units: '0', nano: 0, value: 300 },
|
||||
bonds: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
||||
etf: null,
|
||||
currencies: { currency: 'USD', units: '0', nano: 0, value: 100 },
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 },
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: 4,
|
||||
daily: { currency: 'USD', units: '0', nano: 0, value: 20 },
|
||||
dailyPercent: 4.16,
|
||||
},
|
||||
cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' });
|
||||
expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
|
||||
expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,85 +1,118 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios';
|
||||
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||
import { BrokerAccountCard } from './BrokerAccountCard';
|
||||
import { BrokerAccountsSummary } from './BrokerAccountsSummary';
|
||||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
||||
|
||||
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) {
|
||||
function BrokerAccountsPageSkeleton() {
|
||||
return (
|
||||
<div className="broker-accounts-page">
|
||||
<header className="broker-accounts-page__header">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{[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>;
|
||||
|
||||
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>
|
||||
isLoading
|
||||
error={null}
|
||||
onRetry={() => undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrokerAccountsPage() {
|
||||
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
||||
const safeAccounts = accounts ?? [];
|
||||
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
|
||||
|
||||
if (isLoading) {
|
||||
return <BrokerAccountsPageSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
||||
}
|
||||
|
||||
if (safeAccounts.length === 0) {
|
||||
return (
|
||||
<div className="broker-accounts-page">
|
||||
<header className="broker-accounts-page__header">
|
||||
<div>
|
||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||
</div>
|
||||
</header>
|
||||
<section className="broker-accounts-empty">
|
||||
<h2>Пока нет подключённых счетов</h2>
|
||||
<p>
|
||||
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const successfulPortfolios = accountQueries
|
||||
.map(({ query }) => query.data)
|
||||
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => Boolean(portfolio));
|
||||
const loadingCount = accountQueries.filter(
|
||||
({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data,
|
||||
).length;
|
||||
const availableCount = successfulPortfolios.length;
|
||||
const aggregate = aggregateBrokerAccounts(successfulPortfolios);
|
||||
|
||||
return (
|
||||
<div className="broker-accounts-page">
|
||||
<header className="broker-accounts-page__header">
|
||||
<div>
|
||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
||||
</div>
|
||||
<p className="broker-accounts-page__caption">
|
||||
{safeAccounts.length} счетов под наблюдением
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<BrokerAccountsSummary
|
||||
aggregate={aggregate}
|
||||
availableCount={availableCount}
|
||||
totalCount={safeAccounts.length}
|
||||
isLoading={availableCount === 0 && loadingCount > 0}
|
||||
/>
|
||||
|
||||
<div className="broker-accounts-page__cards">
|
||||
{accountQueries.map(({ account, query }) => (
|
||||
<BrokerAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
portfolio={query.data}
|
||||
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
|
||||
error={(query.error as Error | null) ?? null}
|
||||
onRetry={() => {
|
||||
void query.refetch();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
164
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal file
164
apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||
import { buildBrokerAllocation } from './brokerAllocation';
|
||||
import { BrokerAllocationBar } from './BrokerAllocationBar';
|
||||
import type { BrokerAccountsAggregate } from './brokerAccountsOverview';
|
||||
import {
|
||||
formatBrokerCurrencyValue,
|
||||
formatBrokerSignedCurrencyValue,
|
||||
formatBrokerSignedPercent,
|
||||
} from './brokerAccountsOverview';
|
||||
|
||||
export function BrokerAccountsSummary({
|
||||
aggregate,
|
||||
availableCount,
|
||||
totalCount,
|
||||
isLoading,
|
||||
}: {
|
||||
aggregate: BrokerAccountsAggregate;
|
||||
availableCount: number;
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="broker-accounts-summary broker-accounts-summary--loading">
|
||||
<div className="broker-accounts-summary__hero">
|
||||
<SkeletonBlock height={18} width="34%" />
|
||||
<SkeletonBlock height={52} width="48%" />
|
||||
<SkeletonBlock height={18} width="28%" />
|
||||
</div>
|
||||
<div className="broker-accounts-summary__metrics">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div className="broker-accounts-summary__metric" key={item}>
|
||||
<SkeletonBlock height={14} width="45%" />
|
||||
<SkeletonBlock height={26} width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
|
||||
<div className="broker-accounts-summary__hero">
|
||||
<div>
|
||||
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
|
||||
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
|
||||
</div>
|
||||
<div className="broker-accounts-summary__status">
|
||||
<span>{totalCount} счетов</span>
|
||||
{availableCount !== totalCount ? (
|
||||
<span>
|
||||
Доступно по {availableCount} из {totalCount} счетов
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="broker-accounts-summary__currency-grid">
|
||||
{aggregate.portfolios.map((portfolioSummary) => {
|
||||
const allocation = buildBrokerAllocation({
|
||||
account: {
|
||||
id: 'aggregate',
|
||||
type: 'brokerage',
|
||||
name: 'aggregate',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 },
|
||||
totals: {
|
||||
shares: {
|
||||
currency: portfolioSummary.currency,
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: portfolioSummary.allocation.shares,
|
||||
},
|
||||
bonds: {
|
||||
currency: portfolioSummary.currency,
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: portfolioSummary.allocation.bonds,
|
||||
},
|
||||
etf:
|
||||
portfolioSummary.allocation.etf > 0
|
||||
? {
|
||||
currency: portfolioSummary.currency,
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: portfolioSummary.allocation.etf,
|
||||
}
|
||||
: null,
|
||||
currencies: {
|
||||
currency: portfolioSummary.currency,
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: portfolioSummary.allocation.cash,
|
||||
},
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: {
|
||||
currency: portfolioSummary.currency,
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: portfolioSummary.total,
|
||||
},
|
||||
},
|
||||
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||
cash: [],
|
||||
blockedCash: [],
|
||||
asOf: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<article
|
||||
className="broker-accounts-summary__currency-card"
|
||||
key={portfolioSummary.currency}
|
||||
>
|
||||
<div className="broker-accounts-summary__currency-header">
|
||||
<span className="broker-accounts-summary__currency">
|
||||
{portfolioSummary.currency}
|
||||
</span>
|
||||
<strong className="broker-accounts-summary__total">
|
||||
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
|
||||
</strong>
|
||||
</div>
|
||||
<dl className="broker-accounts-summary__metrics">
|
||||
<div className="broker-accounts-summary__metric">
|
||||
<dt>За день</dt>
|
||||
<dd>
|
||||
{formatBrokerSignedCurrencyValue(
|
||||
portfolioSummary.currency,
|
||||
portfolioSummary.daily,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="broker-accounts-summary__metric">
|
||||
<dt>Динамика</dt>
|
||||
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
|
||||
</div>
|
||||
<div className="broker-accounts-summary__metric">
|
||||
<dt>Свободные деньги</dt>
|
||||
<dd>
|
||||
{formatBrokerCurrencyValue(
|
||||
portfolioSummary.currency,
|
||||
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
|
||||
?.value ?? 0,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<BrokerAllocationBar
|
||||
title={`Распределение активов ${portfolioSummary.currency}`}
|
||||
items={allocation.sectors}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal file
43
apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import type { BrokerAllocationItem } from './brokerAllocation';
|
||||
|
||||
export function BrokerAllocationBar({
|
||||
items,
|
||||
title,
|
||||
}: {
|
||||
items: BrokerAllocationItem[];
|
||||
title: string;
|
||||
}) {
|
||||
const positiveItems = items.filter((item) => item.value > 0);
|
||||
|
||||
if (positiveItems.length === 0) {
|
||||
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="broker-allocation-bar">
|
||||
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
||||
{positiveItems.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
className="broker-allocation-bar__segment"
|
||||
style={{ width: `${item.percent}%`, background: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
||||
{positiveItems.map((item) => (
|
||||
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
||||
<span
|
||||
className="broker-allocation-bar__swatch"
|
||||
style={{ background: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.percent.toFixed(0)}%</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { type ReactElement } from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as accountHook from '../../hooks/useBrokerAccounts';
|
||||
import * as operationsHook from '../../hooks/useBrokerOperations';
|
||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||||
@ -12,7 +11,6 @@ import type { BrokerPortfolio, BrokerPosition } from '../../api/responses';
|
||||
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
||||
|
||||
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||
import { BrokerPositionsPage } from './BrokerPositionsPage';
|
||||
import { BrokerOperationsPage } from './BrokerOperationsPage';
|
||||
|
||||
@ -276,37 +274,6 @@ describe('Broker pages', () => {
|
||||
expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders broker and IIS accounts', () => {
|
||||
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
{
|
||||
id: 'acc-2',
|
||||
type: 'iis',
|
||||
name: 'IIS',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
renderWithClient(<BrokerAccountsPage />);
|
||||
|
||||
expect(screen.getByText('Broker')).toBeInTheDocument();
|
||||
expect(screen.getByText('IIS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the broker account overview with allocation, asset links and recent operations', () => {
|
||||
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||||
data: createOverviewPortfolio(),
|
||||
|
||||
127
apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
Normal file
127
apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrokerPortfolio } from '../../api/responses';
|
||||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
||||
|
||||
function portfolio(
|
||||
id: string,
|
||||
currency: string,
|
||||
total: number,
|
||||
daily: number | null,
|
||||
cash: number,
|
||||
): BrokerPortfolio {
|
||||
return {
|
||||
account: {
|
||||
id,
|
||||
type: 'brokerage',
|
||||
name: id,
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: '2022-06-16T00:00:00.000Z',
|
||||
accessLevel: null,
|
||||
},
|
||||
positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 },
|
||||
totals: {
|
||||
shares: { currency, units: '0', nano: 0, value: total * 0.5 },
|
||||
bonds: { currency, units: '0', nano: 0, value: total * 0.3 },
|
||||
etf: null,
|
||||
currencies: { currency, units: '0', nano: 0, value: total * 0.2 },
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency, units: '0', nano: 0, value: total },
|
||||
},
|
||||
yields: {
|
||||
expectedPercent: 10,
|
||||
daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily },
|
||||
dailyPercent: null,
|
||||
},
|
||||
cash: [{ currency, units: '0', nano: 0, value: cash }],
|
||||
blockedCash: [],
|
||||
asOf: '2026-06-19T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('aggregateBrokerAccounts', () => {
|
||||
it('sums comparable portfolios and uses the specified daily percent formula', () => {
|
||||
const result = aggregateBrokerAccounts([
|
||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
||||
portfolio('b', 'RUB', 2_200, 200, 300),
|
||||
]);
|
||||
|
||||
expect(result.portfolios).toEqual([
|
||||
expect.objectContaining({
|
||||
currency: 'RUB',
|
||||
total: 3_300,
|
||||
daily: 300,
|
||||
dailyPercent: 10,
|
||||
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
|
||||
}),
|
||||
]);
|
||||
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
|
||||
});
|
||||
|
||||
it('keeps different currencies separate', () => {
|
||||
const result = aggregateBrokerAccounts([
|
||||
portfolio('rub', 'RUB', 1_100, 100, 200),
|
||||
portfolio('usd', 'USD', 550, 50, 25),
|
||||
]);
|
||||
|
||||
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
|
||||
{ currency: 'RUB', total: 1_100 },
|
||||
{ currency: 'USD', total: 550 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not expose a daily percent when one account lacks daily data', () => {
|
||||
const result = aggregateBrokerAccounts([
|
||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
||||
portfolio('b', 'RUB', 2_000, null, 300),
|
||||
]);
|
||||
|
||||
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
|
||||
});
|
||||
|
||||
it('does not expose a daily percent when start of day is non-positive', () => {
|
||||
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]);
|
||||
|
||||
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null });
|
||||
});
|
||||
|
||||
it('clamps negative residual other allocation to zero', () => {
|
||||
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100);
|
||||
overAllocated.totals.shares!.value = 700;
|
||||
overAllocated.totals.bonds!.value = 400;
|
||||
overAllocated.totals.currencies!.value = 100;
|
||||
|
||||
const result = aggregateBrokerAccounts([overAllocated]);
|
||||
|
||||
expect(result.portfolios[0].allocation).toEqual({
|
||||
shares: 700,
|
||||
bonds: 400,
|
||||
etf: 0,
|
||||
cash: 100,
|
||||
other: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty summaries for empty or unsupported portfolios', () => {
|
||||
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100);
|
||||
missingTotal.totals.portfolio = null;
|
||||
|
||||
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] });
|
||||
expect(aggregateBrokerAccounts([missingTotal])).toEqual({
|
||||
portfolios: [],
|
||||
cash: [{ currency: 'RUB', value: 100 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('groups cash separately by currency', () => {
|
||||
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100);
|
||||
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 });
|
||||
|
||||
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
|
||||
{ currency: 'RUB', value: 100 },
|
||||
{ currency: 'USD', value: 25 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
198
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal file
198
apps/frontend/src/pages/broker/brokerAccountsOverview.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
|
||||
|
||||
export interface BrokerCurrencyAllocationSummary {
|
||||
shares: number;
|
||||
bonds: number;
|
||||
etf: number;
|
||||
cash: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface BrokerCurrencyPortfolioSummary {
|
||||
currency: string;
|
||||
total: number;
|
||||
daily: number | null;
|
||||
dailyPercent: number | null;
|
||||
allocation: BrokerCurrencyAllocationSummary;
|
||||
}
|
||||
|
||||
export interface BrokerCurrencyCashSummary {
|
||||
currency: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BrokerAccountsAggregate {
|
||||
portfolios: BrokerCurrencyPortfolioSummary[];
|
||||
cash: BrokerCurrencyCashSummary[];
|
||||
}
|
||||
|
||||
interface MutableCurrencySummary {
|
||||
currency: string;
|
||||
total: number;
|
||||
daily: number | null;
|
||||
dailyComparable: boolean;
|
||||
allocation: BrokerCurrencyAllocationSummary;
|
||||
}
|
||||
|
||||
function moneyValue(money: BrokerMoney | null | undefined): number {
|
||||
return money?.value ?? 0;
|
||||
}
|
||||
|
||||
export function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return formatBrokerCurrencyValue(value.currency, value.value);
|
||||
}
|
||||
|
||||
export function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency || 'RUB',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
|
||||
|
||||
if (value > 0) {
|
||||
return `+${formatted}`;
|
||||
}
|
||||
|
||||
if (value < 0) {
|
||||
return `−${formatted}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function formatBrokerPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
|
||||
}
|
||||
|
||||
export function formatBrokerSignedPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const formatted = formatBrokerPercent(Math.abs(value));
|
||||
|
||||
if (value > 0) {
|
||||
return `+${formatted}`;
|
||||
}
|
||||
|
||||
if (value < 0) {
|
||||
return `−${formatted}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function formatBrokerDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||||
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||||
}
|
||||
|
||||
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
||||
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
||||
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
|
||||
|
||||
for (const portfolio of portfolios) {
|
||||
for (const cash of portfolio.cash) {
|
||||
if (!cash.currency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingCash = cashSummaries.get(cash.currency);
|
||||
|
||||
if (existingCash) {
|
||||
existingCash.value += cash.value;
|
||||
} else {
|
||||
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value });
|
||||
}
|
||||
}
|
||||
|
||||
const totalMoney = portfolio.totals.portfolio;
|
||||
const currency = totalMoney?.currency;
|
||||
|
||||
if (!totalMoney || !currency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSummary = portfolioSummaries.get(currency);
|
||||
const summary =
|
||||
existingSummary ??
|
||||
({
|
||||
currency,
|
||||
total: 0,
|
||||
daily: 0,
|
||||
dailyComparable: true,
|
||||
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
|
||||
} satisfies MutableCurrencySummary);
|
||||
|
||||
const total = totalMoney.value;
|
||||
const shares = moneyValue(portfolio.totals.shares);
|
||||
const bonds = moneyValue(portfolio.totals.bonds);
|
||||
const etf = moneyValue(portfolio.totals.etf);
|
||||
const cash = moneyValue(portfolio.totals.currencies);
|
||||
const other = Math.max(0, total - shares - bonds - etf - cash);
|
||||
|
||||
summary.total += total;
|
||||
summary.allocation.shares += shares;
|
||||
summary.allocation.bonds += bonds;
|
||||
summary.allocation.etf += etf;
|
||||
summary.allocation.cash += cash;
|
||||
summary.allocation.other += other;
|
||||
|
||||
const dailyMoney = portfolio.yields.daily;
|
||||
const comparableDaily = dailyMoney && dailyMoney.currency === currency;
|
||||
|
||||
if (!comparableDaily) {
|
||||
summary.daily = null;
|
||||
summary.dailyComparable = false;
|
||||
} else if (summary.dailyComparable) {
|
||||
summary.daily = (summary.daily ?? 0) + dailyMoney.value;
|
||||
}
|
||||
|
||||
if (!existingSummary) {
|
||||
portfolioSummaries.set(currency, summary);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
|
||||
const daily = summary.dailyComparable ? summary.daily : null;
|
||||
const startOfDay = daily === null ? null : summary.total - daily;
|
||||
const dailyPercent =
|
||||
daily === null || startOfDay === null || startOfDay <= 0
|
||||
? null
|
||||
: (daily / startOfDay) * 100;
|
||||
|
||||
return {
|
||||
currency: summary.currency,
|
||||
total: summary.total,
|
||||
daily,
|
||||
dailyPercent,
|
||||
allocation: summary.allocation,
|
||||
};
|
||||
}),
|
||||
cash: Array.from(cashSummaries.values()),
|
||||
};
|
||||
}
|
||||
@ -16,6 +16,12 @@
|
||||
--color-negative: #c62828;
|
||||
--border-radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
--broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%);
|
||||
--broker-overview-panel: rgba(15, 51, 36, 0.93);
|
||||
--broker-overview-panel-soft: rgba(255, 255, 255, 0.09);
|
||||
--broker-overview-border: rgba(21, 61, 43, 0.12);
|
||||
--broker-overview-accent: #98c484;
|
||||
--broker-overview-gold: #d7b268;
|
||||
}
|
||||
|
||||
.pnl-cell {
|
||||
@ -219,12 +225,307 @@ a {
|
||||
color: var(--color-negative);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.broker-account__header {
|
||||
padding: 0 0 8px;
|
||||
.broker-accounts-page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.broker-account__workspace {
|
||||
.broker-accounts-page__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.broker-accounts-page__eyebrow,
|
||||
.broker-account-card__eyebrow,
|
||||
.broker-accounts-summary__eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.broker-accounts-page__title,
|
||||
.broker-account-card__title,
|
||||
.broker-accounts-summary__title {
|
||||
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.broker-accounts-page__title {
|
||||
font-size: clamp(2.2rem, 3vw, 3rem);
|
||||
}
|
||||
|
||||
.broker-accounts-page__caption {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.broker-accounts-summary {
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
background: var(--broker-overview-bg);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(15, 52, 35, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(152, 196, 132, 0.3), transparent 28%),
|
||||
linear-gradient(135deg, var(--broker-overview-panel) 0%, #173f2d 100%);
|
||||
color: #f8f5ec;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__hero .broker-accounts-summary__eyebrow {
|
||||
color: rgba(248, 245, 236, 0.72);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__title {
|
||||
font-size: clamp(1.9rem, 2.4vw, 2.6rem);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: rgba(248, 245, 236, 0.78);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__status span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--broker-overview-panel-soft);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__currency-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__currency-card,
|
||||
.broker-account-card,
|
||||
.broker-accounts-empty {
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--broker-overview-border);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 20px 45px rgba(31, 48, 39, 0.08);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__currency-card {
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__currency-header {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__currency {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__total {
|
||||
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
||||
font-size: clamp(1.8rem, 2vw, 2.4rem);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__metric {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__metric dt,
|
||||
.broker-account-card__stat span {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.broker-accounts-summary__metric dd,
|
||||
.broker-account-card__stat strong {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.broker-allocation-bar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.broker-allocation-bar__track {
|
||||
min-height: 12px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: rgba(19, 54, 38, 0.08);
|
||||
}
|
||||
|
||||
.broker-allocation-bar__segment {
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.broker-allocation-bar__legend {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.broker-allocation-bar__legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 54, 38, 0.05);
|
||||
}
|
||||
|
||||
.broker-allocation-bar__swatch {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.broker-allocation-bar__empty {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.broker-accounts-page__cards {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.broker-account-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.broker-account-card--link {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.broker-account-card--link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 26px 50px rgba(31, 48, 39, 0.11);
|
||||
border-color: rgba(38, 92, 55, 0.22);
|
||||
}
|
||||
|
||||
.broker-account-card--link:focus-visible {
|
||||
outline: 3px solid rgba(59, 128, 74, 0.3);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.broker-account-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.broker-account-card__title {
|
||||
font-size: clamp(1.4rem, 2vw, 1.8rem);
|
||||
}
|
||||
|
||||
.broker-account-card__opened {
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.broker-account-card__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.broker-account-card__stat {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 239, 0.88));
|
||||
border: 1px solid rgba(31, 48, 39, 0.08);
|
||||
}
|
||||
|
||||
.broker-account-card__stat--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.broker-account-card__alert {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(198, 40, 40, 0.08);
|
||||
color: #8e2525;
|
||||
}
|
||||
|
||||
.broker-account-card__retry {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #163f2c;
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.broker-account-card__retry:focus-visible {
|
||||
outline: 3px solid rgba(59, 128, 74, 0.3);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.broker-accounts-empty {
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.broker-account-card--link,
|
||||
.loading-spinner,
|
||||
.skeleton {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.broker-account__header {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.broker-account__workspace {
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@ -255,6 +556,38 @@ a {
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.broker-accounts-page__header,
|
||||
.broker-account-card__header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.broker-accounts-summary,
|
||||
.broker-account-card,
|
||||
.broker-accounts-empty {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.broker-accounts-summary__hero,
|
||||
.broker-accounts-summary__metrics,
|
||||
.broker-account-card__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.broker-account-card__stat--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.broker-account-card__opened {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.broker-account-card__alert {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.broker-operations__toolbar {
|
||||
|
||||
@ -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 отмечает фичу реализованной.
|
||||
|
||||
@ -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] Получить отдельное подтверждение пользователя перед началом реализации.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user