refactor(frontend): move broker account slice to fsd
This commit is contained in:
parent
a03e3c0240
commit
55cc479abb
@ -0,0 +1,28 @@
|
||||
import { request } from '../../../api/client';
|
||||
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '../../../api/responses';
|
||||
|
||||
export type BrokerOperationQuery = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
instrumentId?: string;
|
||||
operationTypes?: string;
|
||||
state?: string;
|
||||
};
|
||||
|
||||
export function getBrokerAccounts(): Promise<{
|
||||
data: BrokerAccount[];
|
||||
meta: ApiResponseMeta;
|
||||
}> {
|
||||
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
||||
}
|
||||
|
||||
export function getBrokerPortfolio(accountId: string): Promise<{
|
||||
data: BrokerPortfolio;
|
||||
meta: ApiResponseMeta;
|
||||
}> {
|
||||
return request<BrokerPortfolio>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
||||
);
|
||||
}
|
||||
9
apps/frontend/src/entities/broker-account/index.ts
Normal file
9
apps/frontend/src/entities/broker-account/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { useBrokerAccounts } from './model/useBrokerAccounts';
|
||||
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
|
||||
export { useBrokerPortfolio } from './model/useBrokerPortfolio';
|
||||
export { aggregateBrokerAccounts } from './model/brokerAccountsOverview';
|
||||
export {
|
||||
getBrokerAccounts,
|
||||
getBrokerPortfolio,
|
||||
type BrokerOperationQuery,
|
||||
} from './api/brokerAccountApi';
|
||||
@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrokerPortfolio } from '../../../api/responses';
|
||||
import { aggregateBrokerAccounts } from '../model/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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,126 @@
|
||||
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 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()),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../../../api/responses';
|
||||
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
||||
|
||||
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] }));
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { BrokerAccount } from '../../../api/responses';
|
||||
import { getBrokerAccounts } from '../api/brokerAccountApi';
|
||||
|
||||
export function useBrokerAccounts() {
|
||||
return useQuery<BrokerAccount[]>({
|
||||
queryKey: ['broker', 'accounts'],
|
||||
queryFn: async () => (await getBrokerAccounts()).data,
|
||||
staleTime: 3_600_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { BrokerPortfolio } from '../../../api/responses';
|
||||
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
||||
|
||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||
return useQuery<BrokerPortfolio>({
|
||||
queryKey: ['broker', 'portfolio', accountId],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@ -2,11 +2,11 @@ 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 { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi';
|
||||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
||||
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
||||
|
||||
vi.mock('../api/broker', () => ({
|
||||
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||
getBrokerPortfolio: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@ -1,17 +1 @@
|
||||
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] }));
|
||||
}
|
||||
export { useBrokerAccountPortfolios } from '../entities/broker-account';
|
||||
|
||||
@ -2,10 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getBrokerAccounts } from '../api/broker';
|
||||
import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
|
||||
import { useBrokerAccounts } from './useBrokerAccounts';
|
||||
|
||||
vi.mock('../api/broker', () => ({
|
||||
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||
getBrokerAccounts: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@ -1,13 +1 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBrokerAccounts } from '../api/broker';
|
||||
import type { BrokerAccount } from '../api/responses';
|
||||
|
||||
export function useBrokerAccounts() {
|
||||
return useQuery<BrokerAccount[]>({
|
||||
queryKey: ['broker', 'accounts'],
|
||||
queryFn: async () => (await getBrokerAccounts()).data,
|
||||
staleTime: 3_600_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
export { useBrokerAccounts } from '../entities/broker-account';
|
||||
|
||||
@ -1,14 +1 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBrokerPortfolio } from '../api/broker';
|
||||
import type { BrokerPortfolio } from '../api/responses';
|
||||
|
||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||
return useQuery<BrokerPortfolio>({
|
||||
queryKey: ['broker', 'portfolio', accountId],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
export { useBrokerPortfolio } from '../entities/broker-account';
|
||||
|
||||
@ -1 +1,122 @@
|
||||
export { BrokerAccountsPage } from '../../broker/BrokerAccountsPage';
|
||||
import {
|
||||
aggregateBrokerAccounts,
|
||||
useBrokerAccounts,
|
||||
useBrokerAccountPortfolios,
|
||||
} from '../../../entities/broker-account';
|
||||
import { BrokerAccountCard } from '../../../widgets/broker-account-card';
|
||||
import { BrokerAccountsSummary } from '../../../widgets/broker-accounts-summary';
|
||||
|
||||
function BrokerAccountsPageSkeleton() {
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,142 +1 @@
|
||||
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} />;
|
||||
}
|
||||
export { BrokerAccountCard } from '../../widgets/broker-account-card';
|
||||
|
||||
@ -5,8 +5,7 @@ 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 * as brokerAccountEntity from '../../entities/broker-account';
|
||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||
|
||||
function renderPage(ui: ReactElement) {
|
||||
@ -84,13 +83,13 @@ describe('BrokerAccountsPage', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{
|
||||
account: broker,
|
||||
query: createQueryState({ data: createPortfolio(broker) }),
|
||||
@ -140,13 +139,13 @@ describe('BrokerAccountsPage', () => {
|
||||
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
||||
] as any);
|
||||
@ -165,7 +164,7 @@ describe('BrokerAccountsPage', () => {
|
||||
});
|
||||
|
||||
it('shows page skeleton while accounts are loading', () => {
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
@ -178,13 +177,13 @@ describe('BrokerAccountsPage', () => {
|
||||
});
|
||||
|
||||
it('renders an empty state when there are no accounts', () => {
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
||||
|
||||
renderPage(<BrokerAccountsPage />);
|
||||
|
||||
@ -197,13 +196,13 @@ describe('BrokerAccountsPage', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: iis,
|
||||
@ -222,13 +221,13 @@ describe('BrokerAccountsPage', () => {
|
||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||
const refetch = vi.fn();
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, iis],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: iis,
|
||||
@ -250,13 +249,13 @@ describe('BrokerAccountsPage', () => {
|
||||
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
||||
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
||||
|
||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||
data: [broker, usd],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||
{
|
||||
account: usd,
|
||||
|
||||
@ -1,120 +1 @@
|
||||
import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios';
|
||||
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
||||
import { BrokerAccountCard } from './BrokerAccountCard';
|
||||
import { BrokerAccountsSummary } from './BrokerAccountsSummary';
|
||||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
||||
|
||||
function BrokerAccountsPageSkeleton() {
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
export { BrokerAccountsPage } from '../broker-accounts';
|
||||
|
||||
@ -1,164 +1 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';
|
||||
|
||||
@ -1,198 +1,14 @@
|
||||
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()),
|
||||
};
|
||||
}
|
||||
export {
|
||||
aggregateBrokerAccounts,
|
||||
brokerAccountTypeLabel,
|
||||
formatBrokerCurrencyValue,
|
||||
formatBrokerDate,
|
||||
formatBrokerMoney,
|
||||
formatBrokerPercent,
|
||||
formatBrokerSignedCurrencyValue,
|
||||
formatBrokerSignedPercent,
|
||||
type BrokerAccountsAggregate,
|
||||
type BrokerCurrencyAllocationSummary,
|
||||
type BrokerCurrencyCashSummary,
|
||||
type BrokerCurrencyPortfolioSummary,
|
||||
} from '../../entities/broker-account/model/brokerAccountsOverview';
|
||||
|
||||
1
apps/frontend/src/widgets/broker-account-card/index.ts
Normal file
1
apps/frontend/src/widgets/broker-account-card/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BrokerAccountCard } from './ui/BrokerAccountCard';
|
||||
@ -0,0 +1,201 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
||||
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '../../../api/responses';
|
||||
import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation';
|
||||
import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar';
|
||||
|
||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency || 'RUB',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return formatBrokerCurrencyValue(value.currency, value.value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function formatBrokerSignedPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const formatted = `${new Intl.NumberFormat('ru-RU', {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Math.abs(value))}%`;
|
||||
|
||||
if (value > 0) {
|
||||
return `+${formatted}`;
|
||||
}
|
||||
|
||||
if (value < 0) {
|
||||
return `−${formatted}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatBrokerDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||||
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { BrokerAccountsSummary } from './ui/BrokerAccountsSummary';
|
||||
@ -0,0 +1,205 @@
|
||||
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
||||
import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation';
|
||||
import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar';
|
||||
import type { BrokerAccountsAggregate } from '../../../pages/broker/brokerAccountsOverview';
|
||||
|
||||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency || 'RUB',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function formatBrokerSignedPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const formatted = `${new Intl.NumberFormat('ru-RU', {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Math.abs(value))}%`;
|
||||
|
||||
if (value > 0) {
|
||||
return `+${formatted}`;
|
||||
}
|
||||
|
||||
if (value < 0) {
|
||||
return `−${formatted}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user