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 { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
||||||
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
|
||||||
|
|
||||||
vi.mock('../api/broker', () => ({
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
getBrokerPortfolio: vi.fn(),
|
getBrokerPortfolio: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1 @@
|
|||||||
import { useQueries } from '@tanstack/react-query';
|
export { useBrokerAccountPortfolios } from '../entities/broker-account';
|
||||||
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] }));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { getBrokerAccounts } from '../api/broker';
|
import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
|
||||||
import { useBrokerAccounts } from './useBrokerAccounts';
|
import { useBrokerAccounts } from './useBrokerAccounts';
|
||||||
|
|
||||||
vi.mock('../api/broker', () => ({
|
vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
|
||||||
getBrokerAccounts: vi.fn(),
|
getBrokerAccounts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBrokerAccounts } from '../entities/broker-account';
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,14 +1 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
export { useBrokerPortfolio } from '../entities/broker-account';
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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';
|
export { BrokerAccountCard } from '../../widgets/broker-account-card';
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import { type ReactElement } from 'react';
|
|||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '../../api/responses';
|
import type { BrokerAccount, BrokerPortfolio } from '../../api/responses';
|
||||||
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
|
import * as brokerAccountEntity from '../../entities/broker-account';
|
||||||
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
|
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
|
||||||
function renderPage(ui: ReactElement) {
|
function renderPage(ui: ReactElement) {
|
||||||
@ -84,13 +83,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [broker, iis],
|
data: [broker, iis],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
{
|
{
|
||||||
account: broker,
|
account: broker,
|
||||||
query: createQueryState({ data: createPortfolio(broker) }),
|
query: createQueryState({ data: createPortfolio(broker) }),
|
||||||
@ -140,13 +139,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [broker, iis],
|
data: [broker, iis],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
{ account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
|
||||||
] as any);
|
] as any);
|
||||||
@ -165,7 +164,7 @@ describe('BrokerAccountsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows page skeleton while accounts are loading', () => {
|
it('shows page skeleton while accounts are loading', () => {
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isFetching: true,
|
isFetching: true,
|
||||||
@ -178,13 +177,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders an empty state when there are no accounts', () => {
|
it('renders an empty state when there are no accounts', () => {
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [],
|
data: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
|
||||||
|
|
||||||
renderPage(<BrokerAccountsPage />);
|
renderPage(<BrokerAccountsPage />);
|
||||||
|
|
||||||
@ -197,13 +196,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
|
||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
|
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [broker, iis],
|
data: [broker, iis],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
{
|
{
|
||||||
account: iis,
|
account: iis,
|
||||||
@ -222,13 +221,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
|
||||||
const refetch = vi.fn();
|
const refetch = vi.fn();
|
||||||
|
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [broker, iis],
|
data: [broker, iis],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
{
|
{
|
||||||
account: iis,
|
account: iis,
|
||||||
@ -250,13 +249,13 @@ describe('BrokerAccountsPage', () => {
|
|||||||
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
|
||||||
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
|
||||||
|
|
||||||
vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [broker, usd],
|
data: [broker, usd],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
|
vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
|
||||||
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
{ account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
|
||||||
{
|
{
|
||||||
account: usd,
|
account: usd,
|
||||||
|
|||||||
@ -1,120 +1 @@
|
|||||||
import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios';
|
export { BrokerAccountsPage } from '../broker-accounts';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,164 +1 @@
|
|||||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,198 +1,14 @@
|
|||||||
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
|
export {
|
||||||
|
aggregateBrokerAccounts,
|
||||||
export interface BrokerCurrencyAllocationSummary {
|
brokerAccountTypeLabel,
|
||||||
shares: number;
|
formatBrokerCurrencyValue,
|
||||||
bonds: number;
|
formatBrokerDate,
|
||||||
etf: number;
|
formatBrokerMoney,
|
||||||
cash: number;
|
formatBrokerPercent,
|
||||||
other: number;
|
formatBrokerSignedCurrencyValue,
|
||||||
}
|
formatBrokerSignedPercent,
|
||||||
|
type BrokerAccountsAggregate,
|
||||||
export interface BrokerCurrencyPortfolioSummary {
|
type BrokerCurrencyAllocationSummary,
|
||||||
currency: string;
|
type BrokerCurrencyCashSummary,
|
||||||
total: number;
|
type BrokerCurrencyPortfolioSummary,
|
||||||
daily: number | null;
|
} from '../../entities/broker-account/model/brokerAccountsOverview';
|
||||||
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()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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