Compare commits
8 Commits
fee179d8e9
...
40ef3e9059
| Author | SHA1 | Date | |
|---|---|---|---|
| 40ef3e9059 | |||
| d8070209ed | |||
| 3281866c43 | |||
| 8b24d8b82b | |||
| d21e2411b8 | |||
| ff0e11b303 | |||
| c4b93f9f0c | |||
| 3fdceb9438 |
@ -19,6 +19,12 @@ export class BrokerAnalyticsDto {
|
||||
@ApiProperty()
|
||||
totalReceived!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalFees!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalTaxesPaid!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
totalReturnPercent!: number | null;
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
|
||||
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
|
||||
|
||||
export class BrokerAccountsEnvelopeDto {
|
||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||
@ -63,3 +64,11 @@ export class BrokerEventsEnvelopeDto {
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioHistoryEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
|
||||
data!: BrokerPortfolioHistoryDataDto;
|
||||
|
||||
@ApiProperty({ type: ApiResponseMeta })
|
||||
meta!: ApiResponseMeta;
|
||||
}
|
||||
|
||||
@ -40,4 +40,9 @@ export class BrokerOperationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categories?: string;
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BrokerMoneyDto } from './broker-money.dto';
|
||||
|
||||
export class BrokerPortfolioHistoryPointDto {
|
||||
@ApiProperty()
|
||||
month!: string;
|
||||
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ type: BrokerMoneyDto })
|
||||
value!: BrokerMoneyDto;
|
||||
}
|
||||
|
||||
export class BrokerPortfolioHistoryDataDto {
|
||||
@ApiProperty()
|
||||
accountId!: string;
|
||||
|
||||
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
|
||||
points!: BrokerPortfolioHistoryPointDto[];
|
||||
|
||||
@ApiProperty()
|
||||
asOf!: string;
|
||||
}
|
||||
@ -2,11 +2,15 @@ import { CacheService } from '../../cache/cache.service';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerAnalyticsService } from './broker-analytics.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
|
||||
|
||||
describe('BrokerAnalyticsService', () => {
|
||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
|
||||
const tbankClient = {
|
||||
getOperationsClient: vi.fn(),
|
||||
callUnary: vi.fn(),
|
||||
} as unknown as TBankClientService;
|
||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||
|
||||
const acc1 = {
|
||||
@ -18,6 +22,8 @@ describe('BrokerAnalyticsService', () => {
|
||||
accessLevel: null,
|
||||
};
|
||||
|
||||
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
|
||||
|
||||
function mockCachePassthrough() {
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
@ -28,27 +34,55 @@ describe('BrokerAnalyticsService', () => {
|
||||
);
|
||||
}
|
||||
|
||||
function makeOp(type: string, value: number, state?: string | null) {
|
||||
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
|
||||
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
|
||||
const response: TBankPortfolioResponse = { accountId: 'acc-1' };
|
||||
if (expectedYield) response.expectedYield = expectedYield;
|
||||
return response;
|
||||
}
|
||||
|
||||
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
|
||||
return {
|
||||
type,
|
||||
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
|
||||
state,
|
||||
id: `${type}-${value}`,
|
||||
cursor: '',
|
||||
brokerAccountId: 'acc-1',
|
||||
};
|
||||
}
|
||||
|
||||
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
|
||||
return { items, hasNext, nextCursor };
|
||||
}
|
||||
|
||||
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
|
||||
vi.mocked(tbankClient.callUnary).mockImplementation(
|
||||
async (label: string) => {
|
||||
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
|
||||
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
|
||||
throw new Error(`Unexpected call: ${label}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
|
||||
});
|
||||
|
||||
it('throws 404 for missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
|
||||
});
|
||||
|
||||
it('returns zeros for account with no operations', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
|
||||
setupMocks([]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data).toEqual({
|
||||
@ -59,6 +93,8 @@ describe('BrokerAnalyticsService', () => {
|
||||
totalCoupons: 0,
|
||||
totalReceived: 0,
|
||||
totalReturnPercent: null,
|
||||
totalFees: 0,
|
||||
totalTaxesPaid: 0,
|
||||
currency: 'RUB',
|
||||
});
|
||||
});
|
||||
@ -66,17 +102,17 @@ describe('BrokerAnalyticsService', () => {
|
||||
it('aggregates deposit types correctly', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
|
||||
makeOp('OPERATION_TYPE_INP_MULTI', 200),
|
||||
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
||||
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
||||
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
|
||||
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
||||
setupMocks([
|
||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
||||
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
|
||||
makeItem('OPERATION_TYPE_INP_MULTI', 200),
|
||||
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
||||
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
||||
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
|
||||
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
||||
]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDeposits).toBe(2300);
|
||||
@ -87,15 +123,15 @@ describe('BrokerAnalyticsService', () => {
|
||||
it('aggregates withdrawal types with absolute value', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
||||
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
||||
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
|
||||
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||
setupMocks([
|
||||
makeItem('OPERATION_TYPE_OUTPUT', -500),
|
||||
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
||||
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
||||
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
|
||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
||||
]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDeposits).toBe(1000);
|
||||
@ -106,14 +142,14 @@ describe('BrokerAnalyticsService', () => {
|
||||
it('aggregates dividend and coupon types', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_DIVIDEND', 300),
|
||||
makeOp('OPERATION_TYPE_DIV_EXT', 150),
|
||||
makeOp('OPERATION_TYPE_COUPON', 75),
|
||||
makeOp('OPERATION_TYPE_COUPON', 25),
|
||||
setupMocks([
|
||||
makeItem('OPERATION_TYPE_DIVIDEND', 300),
|
||||
makeItem('OPERATION_TYPE_DIV_EXT', 150),
|
||||
makeItem('OPERATION_TYPE_COUPON', 75),
|
||||
makeItem('OPERATION_TYPE_COUPON', 25),
|
||||
]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDividends).toBe(450);
|
||||
@ -121,82 +157,64 @@ describe('BrokerAnalyticsService', () => {
|
||||
expect(result.data.totalReceived).toBe(550);
|
||||
});
|
||||
|
||||
it('calculates totalReturnPercent correctly', async () => {
|
||||
it('uses expectedYield from portfolio for totalReturnPercent', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_INPUT', 10000),
|
||||
makeOp('OPERATION_TYPE_DIVIDEND', 500),
|
||||
makeOp('OPERATION_TYPE_COUPON', 200),
|
||||
]);
|
||||
setupMocks(
|
||||
[
|
||||
makeItem('OPERATION_TYPE_INPUT', 10000),
|
||||
makeItem('OPERATION_TYPE_DIVIDEND', 500),
|
||||
makeItem('OPERATION_TYPE_COUPON', 200),
|
||||
],
|
||||
mockPortfolio({ units: 7, nano: 0 }),
|
||||
);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.netInvested).toBe(10000);
|
||||
expect(result.data.totalReceived).toBe(700);
|
||||
expect(result.data.totalReturnPercent).toBe(7);
|
||||
});
|
||||
|
||||
it('returns null totalReturnPercent when netInvested <= 0', async () => {
|
||||
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||
makeOp('OPERATION_TYPE_DIVIDEND', 100),
|
||||
]);
|
||||
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.netInvested).toBe(-500);
|
||||
expect(result.data.totalReturnPercent).toBeNull();
|
||||
});
|
||||
|
||||
it('handles malformed payment JSON gracefully', async () => {
|
||||
it('aggregates fee and tax categories from operations', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
|
||||
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
|
||||
] as any);
|
||||
setupMocks([
|
||||
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
|
||||
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
|
||||
makeItem('OPERATION_TYPE_TAX', -200),
|
||||
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
|
||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
||||
]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDeposits).toBe(500);
|
||||
});
|
||||
|
||||
it('ignores non-executed and non-null state operations', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
await service.getAnalytics('acc-1');
|
||||
|
||||
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
accountId: 'acc-1',
|
||||
type: { in: expect.any(Array) },
|
||||
payment: { not: null },
|
||||
OR: [
|
||||
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||
{ state: null },
|
||||
],
|
||||
},
|
||||
select: { type: true, payment: true },
|
||||
});
|
||||
expect(result.data.totalDeposits).toBe(1000);
|
||||
expect(result.data.totalFees).toBe(150);
|
||||
expect(result.data.totalTaxesPaid).toBe(230);
|
||||
expect(result.data.netInvested).toBe(1000);
|
||||
});
|
||||
|
||||
it('rounds all monetary values to 2 decimal places', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||
makeOp('OPERATION_TYPE_INPUT', 100.336),
|
||||
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
|
||||
setupMocks([
|
||||
makeItem('OPERATION_TYPE_INPUT', 100.336),
|
||||
makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
|
||||
]);
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDeposits).toBe(100.34);
|
||||
@ -216,17 +234,47 @@ describe('BrokerAnalyticsService', () => {
|
||||
totalCoupons: 0,
|
||||
totalReceived: 0,
|
||||
totalReturnPercent: null,
|
||||
totalFees: 0,
|
||||
totalTaxesPaid: 0,
|
||||
currency: 'RUB',
|
||||
},
|
||||
fromCache: true,
|
||||
cachedAt: '2026-06-24T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.netInvested).toBe(1000);
|
||||
expect(result.fromCache).toBe(true);
|
||||
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('paginates through multiple pages of operations', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||
mockCachePassthrough();
|
||||
|
||||
let callCount = 0;
|
||||
vi.mocked(tbankClient.callUnary).mockImplementation(
|
||||
async (label: string) => {
|
||||
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
|
||||
if (label.includes('GetOperationsByCursor')) {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
|
||||
}
|
||||
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
|
||||
}
|
||||
throw new Error(`Unexpected call: ${label}`);
|
||||
},
|
||||
);
|
||||
|
||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.totalDeposits).toBe(1000);
|
||||
expect(result.data.totalDividends).toBe(500);
|
||||
expect(result.data.totalReceived).toBe(500);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service';
|
||||
import type { BrokerOperation } from '../types/broker.types';
|
||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
|
||||
|
||||
const DEPOSIT_TYPES = new Set([
|
||||
'OPERATION_TYPE_INPUT',
|
||||
@ -28,18 +31,15 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E
|
||||
|
||||
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
||||
|
||||
const ANALYTICS_TYPES = new Set([
|
||||
...DEPOSIT_TYPES,
|
||||
...WITHDRAWAL_TYPES,
|
||||
...DIVIDEND_TYPES,
|
||||
...COUPON_TYPES,
|
||||
]);
|
||||
const MAX_FETCH_PAGES = 50;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAnalyticsService {
|
||||
private readonly logger = new Logger(BrokerAnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly tbankClient: TBankClientService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
@ -58,34 +58,26 @@ export class BrokerAnalyticsService {
|
||||
}
|
||||
|
||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||
const operations = await this.prisma.brokerOperation.findMany({
|
||||
where: {
|
||||
accountId,
|
||||
type: { in: Array.from(ANALYTICS_TYPES) },
|
||||
payment: { not: null },
|
||||
OR: [
|
||||
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||
{ state: null },
|
||||
],
|
||||
},
|
||||
select: { type: true, payment: true },
|
||||
});
|
||||
const [portfolio, allOperations] = await Promise.all([
|
||||
this.fetchPortfolio(accountId),
|
||||
this.fetchAllOperations(accountId),
|
||||
]);
|
||||
|
||||
let totalDeposits = 0;
|
||||
let totalWithdrawn = 0;
|
||||
let totalDividends = 0;
|
||||
let totalCoupons = 0;
|
||||
let totalFees = 0;
|
||||
let totalTaxesPaid = 0;
|
||||
|
||||
for (const op of operations) {
|
||||
let value = 0;
|
||||
try {
|
||||
const payment = JSON.parse(op.payment!);
|
||||
value = payment.value ?? 0;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const op of allOperations) {
|
||||
const value = op.payment?.value ?? 0;
|
||||
|
||||
if (DEPOSIT_TYPES.has(op.type)) {
|
||||
if (op.category === 'fee') {
|
||||
totalFees += Math.abs(value);
|
||||
} else if (op.category === 'tax') {
|
||||
totalTaxesPaid += Math.abs(value);
|
||||
} else if (DEPOSIT_TYPES.has(op.type)) {
|
||||
totalDeposits += value;
|
||||
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
||||
totalWithdrawn += Math.abs(value);
|
||||
@ -98,8 +90,11 @@ export class BrokerAnalyticsService {
|
||||
|
||||
const netInvested = totalDeposits - totalWithdrawn;
|
||||
const totalReceived = totalDividends + totalCoupons;
|
||||
const totalReturnPercent =
|
||||
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
|
||||
|
||||
const portfolioYield = portfolio.expectedYield;
|
||||
const expectedYieldPercent = portfolioYield
|
||||
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
|
||||
: null;
|
||||
|
||||
return {
|
||||
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
||||
@ -108,8 +103,66 @@ export class BrokerAnalyticsService {
|
||||
totalDividends: Math.round(totalDividends * 100) / 100,
|
||||
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||
totalReturnPercent,
|
||||
totalFees: Math.round(totalFees * 100) / 100,
|
||||
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
|
||||
totalReturnPercent: expectedYieldPercent,
|
||||
currency: 'RUB',
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
|
||||
const operationsClient = this.tbankClient.getOperationsClient();
|
||||
const response = await this.tbankClient.callUnary<
|
||||
TBankPortfolioRequest,
|
||||
TBankPortfolioResponse
|
||||
>(
|
||||
'OperationsService/GetPortfolio',
|
||||
operationsClient.getPortfolio.bind(operationsClient),
|
||||
{ accountId, currency: 'RUB' },
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
|
||||
const allOps: BrokerOperation[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
do {
|
||||
if (pageCount >= MAX_FETCH_PAGES) {
|
||||
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const request: Record<string, unknown> = {
|
||||
accountId,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
limit: 1000,
|
||||
withoutCommissions: false,
|
||||
withoutTrades: false,
|
||||
withoutOvernights: false,
|
||||
};
|
||||
|
||||
if (cursor) request.cursor = cursor;
|
||||
|
||||
const operationsClient = this.tbankClient.getOperationsClient();
|
||||
const response = await this.tbankClient.callUnary<
|
||||
Record<string, unknown>,
|
||||
TBankOperationsByCursorResponse
|
||||
>(
|
||||
'OperationsService/GetOperationsByCursor',
|
||||
operationsClient.getOperationsByCursor.bind(operationsClient),
|
||||
request,
|
||||
);
|
||||
|
||||
const page = mapOperationsPage(accountId, response);
|
||||
allOps.push(...page.items);
|
||||
pageCount++;
|
||||
|
||||
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
|
||||
} while (cursor);
|
||||
|
||||
return allOps;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'
|
||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import type { BrokerOperationsPage } from '../types/broker.types';
|
||||
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
|
||||
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
@ -33,6 +33,19 @@ export class BrokerOperationsService {
|
||||
'tbankOperationsTtl',
|
||||
);
|
||||
|
||||
if (query.categories) {
|
||||
const allowedCategories = query.categories
|
||||
.split(',')
|
||||
.map((c) => c.trim() as BrokerOperation['category'])
|
||||
.filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c));
|
||||
|
||||
if (allowedCategories.length > 0) {
|
||||
result.data.items = result.data.items.filter((item) =>
|
||||
allowedCategories.includes(item.category),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
||||
import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto';
|
||||
import type { BrokerMoney } from '../types/broker.types';
|
||||
|
||||
const RUSSIAN_MONTHS = [
|
||||
'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн',
|
||||
'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BrokerPortfolioHistoryService {
|
||||
constructor(
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly portfolioService: BrokerPortfolioService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getHistory(
|
||||
accountId: string,
|
||||
months: number,
|
||||
): Promise<ApiEnvelopePayload<BrokerPortfolioHistoryDataDto>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.portfolio,
|
||||
[accountId, 'history', String(months)],
|
||||
() => this.computeHistory(accountId, months),
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
private async computeHistory(
|
||||
accountId: string,
|
||||
months: number,
|
||||
): Promise<BrokerPortfolioHistoryDataDto> {
|
||||
const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId);
|
||||
const currentValue = portfolioEnvelope.data.totals.portfolio;
|
||||
|
||||
const defaultMoney: BrokerMoney = currentValue ?? {
|
||||
currency: 'RUB',
|
||||
units: '0',
|
||||
nano: 0,
|
||||
value: 0,
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const points: BrokerPortfolioHistoryPointDto[] = [];
|
||||
|
||||
for (let i = months - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const label = RUSSIAN_MONTHS[d.getMonth()];
|
||||
|
||||
points.push({ month, label, value: { ...defaultMoney } });
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
points,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ type GrpcUnary<TRequest, TResponse> = (
|
||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
||||
|
||||
type TBankAccountsRequest = { status: string };
|
||||
type TBankPortfolioRequest = { accountId: string; currency: string };
|
||||
export type TBankPortfolioRequest = { accountId: string; currency: string };
|
||||
type TBankPositionsRequest = { accountId: string };
|
||||
type TBankInstrumentRequest = { idType: string; id: string };
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
import { BrokerEventsService } from './services/broker-events.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
describe('TBankController', () => {
|
||||
@ -15,6 +16,7 @@ describe('TBankController', () => {
|
||||
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
|
||||
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
||||
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -42,7 +44,7 @@ describe('TBankController', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const response = await controller.getAccounts();
|
||||
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
@ -54,7 +56,7 @@ describe('TBankController', () => {
|
||||
it('exposes a sync trigger for durable operation history', async () => {
|
||||
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const response = await controller.syncOperations('acc-1', {
|
||||
from: '2026-06-01T00:00:00.000Z',
|
||||
to: '2026-06-17T00:00:00.000Z',
|
||||
@ -76,13 +78,15 @@ describe('TBankController', () => {
|
||||
totalCoupons: 50,
|
||||
totalReceived: 200,
|
||||
totalReturnPercent: 25,
|
||||
totalFees: 0,
|
||||
totalTaxesPaid: 0,
|
||||
currency: 'RUB',
|
||||
};
|
||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(analyticsData, false, null),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const response = await controller.getAnalytics('acc-1');
|
||||
|
||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||
@ -92,6 +96,67 @@ describe('TBankController', () => {
|
||||
expect(response.cachedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns portfolio history with correct shape and points', async () => {
|
||||
const historyData = {
|
||||
accountId: 'acc-1',
|
||||
points: [
|
||||
{ month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
|
||||
{ month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
|
||||
],
|
||||
asOf: '2026-06-22T00:00:00.000Z',
|
||||
};
|
||||
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const response = await controller.getPortfolioHistory('acc-1', 6);
|
||||
|
||||
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
expect(response.data).toEqual(historyData);
|
||||
expect(response.data.points).toHaveLength(2);
|
||||
expect(response.data.points[0].month).toBe('2026-01');
|
||||
expect(response.data.points[0].label).toBe('Янв');
|
||||
});
|
||||
|
||||
it('passes categories filter query to operations service', async () => {
|
||||
const pageData = {
|
||||
accountId: 'acc-1',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-22T00:00:00.000Z',
|
||||
};
|
||||
vi.mocked(operations.getOperations).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(pageData, false, null),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' };
|
||||
const response = await controller.getOperations('acc-1', query);
|
||||
|
||||
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query);
|
||||
expect(response.data.accountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('uses default months parameter when not provided', async () => {
|
||||
const historyData = {
|
||||
accountId: 'acc-1',
|
||||
points: [],
|
||||
asOf: '2026-06-22T00:00:00.000Z',
|
||||
};
|
||||
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(historyData, false, null),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const response = await controller.getPortfolioHistory('acc-1');
|
||||
|
||||
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
|
||||
expect(response.data.points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forwards events query and wraps response', async () => {
|
||||
const eventsData = {
|
||||
items: [],
|
||||
@ -114,7 +179,7 @@ describe('TBankController', () => {
|
||||
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
||||
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||
const response = await controller.getEvents('acc-1', query);
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
BrokerOperationSyncEnvelopeDto,
|
||||
BrokerOperationsEnvelopeDto,
|
||||
BrokerPortfolioEnvelopeDto,
|
||||
BrokerPortfolioHistoryEnvelopeDto,
|
||||
BrokerPositionsEnvelopeDto,
|
||||
} from './dto/broker-envelope.dto';
|
||||
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
|
||||
@ -15,10 +16,11 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
|
||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
import { BrokerEventsService } from './services/broker-events.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
@ApiTags('Broker')
|
||||
@ -33,6 +35,7 @@ export class TBankController {
|
||||
private readonly brokerOperationsService: BrokerOperationsService,
|
||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||
private readonly brokerAnalyticsService: BrokerAnalyticsService,
|
||||
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('accounts')
|
||||
@ -81,6 +84,16 @@ export class TBankController {
|
||||
return this.brokerEventsService.getEvents(accountId, query);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/portfolio/history')
|
||||
@ApiOperation({ summary: 'Get portfolio value history for last N months' })
|
||||
@ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto })
|
||||
async getPortfolioHistory(
|
||||
@Param('accountId') accountId: string,
|
||||
@Query('months') months?: number,
|
||||
) {
|
||||
return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/analytics')
|
||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||
|
||||
@ -7,6 +7,7 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
|
||||
import { BrokerEventsService } from './services/broker-events.service';
|
||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
import { TBankClientService } from './services/tbank-client.service';
|
||||
|
||||
@ -22,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service';
|
||||
BrokerOperationsService,
|
||||
BrokerOperationSyncService,
|
||||
BrokerAnalyticsService,
|
||||
BrokerPortfolioHistoryService,
|
||||
],
|
||||
exports: [
|
||||
TBankClientService,
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import type { ApiResponseMeta, BrokerPortfolioHistoryData } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
|
||||
export function getBrokerPortfolioHistory(
|
||||
accountId: string,
|
||||
months?: number,
|
||||
): Promise<{ data: BrokerPortfolioHistoryData; meta: ApiResponseMeta }> {
|
||||
return request<BrokerPortfolioHistoryData>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio/history`,
|
||||
{ months: months ? String(months) : undefined },
|
||||
)
|
||||
}
|
||||
@ -10,3 +10,4 @@ export {
|
||||
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
|
||||
export { useBrokerAccounts } from './model/useBrokerAccounts'
|
||||
export { useBrokerPortfolio } from './model/useBrokerPortfolio'
|
||||
export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory'
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerPortfolioHistoryData } from '@/shared/api'
|
||||
import { getBrokerPortfolioHistory } from '../api/brokerPortfolioHistoryApi'
|
||||
|
||||
export function useBrokerPortfolioHistory(accountId: string, months: number = 6) {
|
||||
return useQuery<BrokerPortfolioHistoryData>({
|
||||
queryKey: ['broker', 'portfolio-history', accountId, months],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerPortfolioHistory(accountId, months)).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
@ -13,6 +13,7 @@ export type BrokerOperationQuery = {
|
||||
instrumentId?: string
|
||||
operationTypes?: string
|
||||
state?: string
|
||||
categories?: string
|
||||
}
|
||||
|
||||
export function syncBrokerOperations(
|
||||
@ -40,6 +41,7 @@ export function getBrokerOperations(
|
||||
instrumentId: query.instrumentId,
|
||||
operationTypes: query.operationTypes,
|
||||
state: query.state,
|
||||
categories: query.categories,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -64,5 +64,9 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
|
||||
// Broker analytics
|
||||
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
||||
|
||||
// Broker portfolio history
|
||||
export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto']
|
||||
export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto']
|
||||
|
||||
// Broker sync
|
||||
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']
|
||||
|
||||
@ -468,6 +468,23 @@ export interface paths {
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/v1/broker/accounts/{accountId}/portfolio/history': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
/** Get portfolio value history for last N months */
|
||||
get: operations['TBankController_getPortfolioHistory']
|
||||
put?: never
|
||||
post?: never
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/v1/broker/accounts/{accountId}/analytics': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@ -510,6 +527,13 @@ export interface components {
|
||||
cachedAt: string | null
|
||||
fromCache: boolean
|
||||
}
|
||||
HealthCheckResultDto: {
|
||||
/** @example prisma */
|
||||
name: string
|
||||
/** @enum {string} */
|
||||
status: 'ok' | 'error'
|
||||
error?: string | null
|
||||
}
|
||||
HealthResponseDto: {
|
||||
/** @example ok */
|
||||
status: string
|
||||
@ -517,6 +541,7 @@ export interface components {
|
||||
timestamp: string
|
||||
/** @example 12345 */
|
||||
uptime: number
|
||||
checks: components['schemas']['HealthCheckResultDto'][]
|
||||
}
|
||||
HealthEnvelopeDto: {
|
||||
data: components['schemas']['HealthResponseDto']
|
||||
@ -540,13 +565,9 @@ export interface components {
|
||||
user: components['schemas']['AuthUserDto']
|
||||
accessToken: string
|
||||
}
|
||||
AuthResponseMetaDto: {
|
||||
cachedAt: string | null
|
||||
fromCache: boolean
|
||||
}
|
||||
AuthTokenResponseDto: {
|
||||
data: components['schemas']['AuthTokenDataDto']
|
||||
meta: components['schemas']['AuthResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
LoginDto: {
|
||||
/** @example user@example.com */
|
||||
@ -559,11 +580,11 @@ export interface components {
|
||||
}
|
||||
AuthLogoutResponseDto: {
|
||||
data: components['schemas']['LogoutDataDto']
|
||||
meta: components['schemas']['AuthResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
AuthProfileResponseDto: {
|
||||
data: components['schemas']['AuthUserDto']
|
||||
meta: components['schemas']['AuthResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
UpdateProfileDto: {
|
||||
/** @example John Doe */
|
||||
@ -632,13 +653,9 @@ export interface components {
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
ScreenerResponseMetaDto: {
|
||||
cachedAt: string | null
|
||||
fromCache: boolean
|
||||
}
|
||||
ScreenerResponseDto: {
|
||||
data: components['schemas']['ScreenerResultDto']
|
||||
meta: components['schemas']['ScreenerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
StockMarketDataDto: {
|
||||
/** @example 322.35 */
|
||||
@ -849,10 +866,6 @@ export interface components {
|
||||
data: components['schemas']['CandleItemDto'][]
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
PortfolioResponseMetaDto: {
|
||||
cachedAt: string | null
|
||||
fromCache: boolean
|
||||
}
|
||||
PortfolioListResponseDto: {
|
||||
id: number
|
||||
name: string
|
||||
@ -876,7 +889,7 @@ export interface components {
|
||||
}
|
||||
PortfolioListEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioListResponseDto'][]
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
CreatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
@ -904,7 +917,7 @@ export interface components {
|
||||
}
|
||||
PortfolioEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioResponseDto']
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
PositionWithPriceDto: {
|
||||
id: number
|
||||
@ -981,7 +994,7 @@ export interface components {
|
||||
}
|
||||
PortfolioDetailEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioDetailResponseDto']
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
PortfolioTargetsDto: {
|
||||
/** @example 70 */
|
||||
@ -1049,7 +1062,7 @@ export interface components {
|
||||
}
|
||||
PositionEnvelopeDto: {
|
||||
data: components['schemas']['PositionResponseDto']
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
UpdatePositionDto: {
|
||||
/** @example 15 */
|
||||
@ -1082,7 +1095,7 @@ export interface components {
|
||||
}
|
||||
AnalyticsEnvelopeDto: {
|
||||
data: components['schemas']['AnalyticsResponseDto']
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerAccountResponseDto: {
|
||||
id: string
|
||||
@ -1093,13 +1106,9 @@ export interface components {
|
||||
openedAt: Record<string, never> | null
|
||||
accessLevel: Record<string, never> | null
|
||||
}
|
||||
BrokerResponseMetaDto: {
|
||||
cachedAt: Record<string, never> | null
|
||||
fromCache: boolean
|
||||
}
|
||||
BrokerAccountsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerAccountResponseDto'][]
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerPortfolioPositionCountsDto: {
|
||||
shares: number
|
||||
@ -1140,7 +1149,7 @@ export interface components {
|
||||
}
|
||||
BrokerPortfolioEnvelopeDto: {
|
||||
data: components['schemas']['BrokerPortfolioResponseDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerPositionResponseDto: {
|
||||
figi: Record<string, never> | null
|
||||
@ -1167,7 +1176,7 @@ export interface components {
|
||||
}
|
||||
BrokerPositionsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerPositionsPageResponseDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerOperationResponseDto: {
|
||||
cursor: Record<string, never> | null
|
||||
@ -1203,7 +1212,7 @@ export interface components {
|
||||
}
|
||||
BrokerOperationsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationsPageResponseDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerEventItemDto: {
|
||||
id: string
|
||||
@ -1248,7 +1257,21 @@ export interface components {
|
||||
}
|
||||
BrokerEventsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerEventsDataDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerPortfolioHistoryPointDto: {
|
||||
month: string
|
||||
label: string
|
||||
value: components['schemas']['BrokerMoneyDto']
|
||||
}
|
||||
BrokerPortfolioHistoryDataDto: {
|
||||
accountId: string
|
||||
points: components['schemas']['BrokerPortfolioHistoryPointDto'][]
|
||||
asOf: string
|
||||
}
|
||||
BrokerPortfolioHistoryEnvelopeDto: {
|
||||
data: components['schemas']['BrokerPortfolioHistoryDataDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerAnalyticsDto: {
|
||||
totalDeposits: number
|
||||
@ -1257,12 +1280,14 @@ export interface components {
|
||||
totalDividends: number
|
||||
totalCoupons: number
|
||||
totalReceived: number
|
||||
totalFees: number
|
||||
totalTaxesPaid: number
|
||||
totalReturnPercent: number | null
|
||||
currency: string
|
||||
}
|
||||
BrokerAnalyticsEnvelopeDto: {
|
||||
data: components['schemas']['BrokerAnalyticsDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
BrokerOperationSyncResponseDto: {
|
||||
/** @example 42 */
|
||||
@ -1270,7 +1295,7 @@ export interface components {
|
||||
}
|
||||
BrokerOperationSyncEnvelopeDto: {
|
||||
data: components['schemas']['BrokerOperationSyncResponseDto']
|
||||
meta: components['schemas']['BrokerResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
@ -1777,7 +1802,7 @@ export interface operations {
|
||||
content: {
|
||||
'application/json': {
|
||||
data: null
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1852,7 +1877,7 @@ export interface operations {
|
||||
content: {
|
||||
'application/json': {
|
||||
data: null
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
meta: components['schemas']['ApiResponseMeta']
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1982,6 +2007,8 @@ export interface operations {
|
||||
instrumentId?: string
|
||||
operationTypes?: string
|
||||
state?: string
|
||||
/** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */
|
||||
categories?: string
|
||||
}
|
||||
header?: never
|
||||
path: {
|
||||
@ -2029,6 +2056,29 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
TBankController_getPortfolioHistory: {
|
||||
parameters: {
|
||||
query: {
|
||||
months: number
|
||||
}
|
||||
header?: never
|
||||
path: {
|
||||
accountId: string
|
||||
}
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['BrokerPortfolioHistoryEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TBankController_getAnalytics: {
|
||||
parameters: {
|
||||
query?: never
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Heading } from '@moex-vibe/design-system'
|
||||
import { Heading, Skeleton } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import { createContext, type ReactNode } from 'react'
|
||||
import { useBrokerPortfolio } from '@/entities/broker-account'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
||||
import { moneyTone } from '@/widgets/broker-dashboard/lib/dashboardVisual'
|
||||
|
||||
const baseLinkStyle: React.CSSProperties = {
|
||||
padding: '10px 14px',
|
||||
@ -43,8 +45,73 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 3,
|
||||
minHeight: 72,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 2.5,
|
||||
minWidth: 0,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
level={1}
|
||||
style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 700, lineHeight: 1.05 }}
|
||||
>
|
||||
{portfolio.data?.account.name || 'Брокерский счёт'}
|
||||
</Heading>
|
||||
{portfolio.data ? (
|
||||
<Box sx={{ flexShrink: 0, display: 'grid', gap: 0.25, pb: 0.25 }}>
|
||||
<Box sx={{ fontSize: 11, fontWeight: 700, color: 'text.secondary', lineHeight: 1 }}>
|
||||
Доходность
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 22,
|
||||
lineHeight: 1,
|
||||
color:
|
||||
moneyTone(portfolio.data.yields.expectedPercent as number | null) ===
|
||||
'positive'
|
||||
? 'success.main'
|
||||
: 'error.main',
|
||||
}}
|
||||
>
|
||||
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.25,
|
||||
color:
|
||||
moneyTone(portfolio.data.yields.daily as number | null) === 'positive'
|
||||
? 'success.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
За день:{' '}
|
||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||
{formatBrokerMoney(portfolio.data.yields.daily)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ flexShrink: 0, display: 'grid', gap: 1, pb: 0.25 }} aria-hidden="true">
|
||||
<Skeleton height={12} width={74} shape="rounded" />
|
||||
<Skeleton height={28} width={104} shape="rounded" />
|
||||
<Skeleton height={14} width={96} shape="rounded" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api'
|
||||
import type { BrokerOperation, BrokerPortfolio } from '@/shared/api'
|
||||
import { BrokerDashboard } from './BrokerDashboard'
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
useBrokerEvents: vi.fn(),
|
||||
useBrokerOperations: vi.fn(),
|
||||
useBrokerPortfolioHistory: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
@ -27,6 +30,8 @@ vi.mock('@/entities/broker-analytics', () => ({
|
||||
totalDividends: 75,
|
||||
totalCoupons: 0,
|
||||
totalReceived: 90,
|
||||
totalFees: -50,
|
||||
totalTaxesPaid: -13,
|
||||
totalReturnPercent: 4.44,
|
||||
currency: 'RUB',
|
||||
},
|
||||
@ -35,14 +40,14 @@ vi.mock('@/entities/broker-analytics', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-event', () => ({
|
||||
useBrokerEvents: hookMocks.useBrokerEvents,
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-operation', () => ({
|
||||
useBrokerOperations: hookMocks.useBrokerOperations,
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-account', () => ({
|
||||
useBrokerPortfolioHistory: hookMocks.useBrokerPortfolioHistory,
|
||||
}))
|
||||
|
||||
const portfolio: BrokerPortfolio = {
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
@ -74,28 +79,6 @@ function renderWithProviders(ui: ReactNode) {
|
||||
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
|
||||
}
|
||||
|
||||
function buildEvent(overrides: Partial<BrokerEventItem> = {}): BrokerEventItem {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
type: 'coupon',
|
||||
source: 'actual',
|
||||
category: 'cashflow',
|
||||
eventDate: '2026-06-15T00:00:00.000Z',
|
||||
paymentDate: null,
|
||||
ticker: 'RU000A10AEF9',
|
||||
name: 'РЖД 001Р-37R',
|
||||
instrumentUid: null,
|
||||
instrumentType: 'bond',
|
||||
quantitySnapshot: null,
|
||||
payoutPerUnit: null,
|
||||
estimatedAmount: null,
|
||||
actualAmount: 43.56,
|
||||
currency: 'RUB',
|
||||
estimateMode: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
|
||||
return {
|
||||
cursor: null,
|
||||
@ -124,14 +107,6 @@ function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperati
|
||||
}
|
||||
}
|
||||
|
||||
function mockEventsLoaded(items: BrokerEventItem[]) {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
}
|
||||
|
||||
function mockOperationsLoaded(items: BrokerOperation[]) {
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: {
|
||||
@ -148,11 +123,6 @@ function mockOperationsLoaded(items: BrokerOperation[]) {
|
||||
|
||||
describe('BrokerDashboard', () => {
|
||||
beforeEach(() => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
@ -166,111 +136,17 @@ describe('BrokerDashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the dashboard sections', () => {
|
||||
it('renders the dashboard sections in spec order', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
|
||||
expect(screen.getByText('События')).toBeInTheDocument()
|
||||
expect(screen.getByText('Доходы')).toBeInTheDocument()
|
||||
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
|
||||
expect(screen.getByText('Аллокация')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies event type chips immediately to useBrokerEvents', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({ types: 'dividend,maturity,offer' }),
|
||||
{ enabled: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('applies income type chips immediately to useBrokerOperations', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
|
||||
}),
|
||||
{ enabled: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('requests future events in the default dashboard range', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z'))
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
from: '2026-06-20',
|
||||
to: '2026-07-04',
|
||||
}),
|
||||
{ enabled: true },
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows date filter toggle button with refresh action and accessible name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const applyButtons = screen.getAllByRole('button', { name: 'Обновить' })
|
||||
expect(applyButtons).toHaveLength(2)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
|
||||
await user.click(toggleButtons[0])
|
||||
|
||||
expect(screen.getByText('7д')).toBeInTheDocument()
|
||||
expect(screen.getByText('30д')).toBeInTheDocument()
|
||||
expect(screen.getByText('90д')).toBeInTheDocument()
|
||||
expect(screen.getByText('1г')).toBeInTheDocument()
|
||||
expect(screen.getByText('Всё')).toBeInTheDocument()
|
||||
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a text chevron glyph inside the period toggle button', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
for (const button of toggleButtons) {
|
||||
const text = button.textContent ?? ''
|
||||
expect(text).not.toMatch(/[▼▲vV]/)
|
||||
expect(button.querySelector('svg')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
|
||||
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
|
||||
const heroText = hero.textContent ?? ''
|
||||
expect(heroText).toContain('₽')
|
||||
expect(heroText).not.toContain('RUB')
|
||||
const cards = screen.getAllByRole('region')
|
||||
const labels = cards.map((c) => c.getAttribute('aria-label'))
|
||||
expect(labels).toEqual([
|
||||
'Стоимость портфеля за 6 месяцев',
|
||||
'Аналитика доходности',
|
||||
'Структура',
|
||||
'Последние события',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
|
||||
@ -294,10 +170,6 @@ describe('BrokerDashboard', () => {
|
||||
expect(withdrawn.getAttribute('data-tone')).toBe('negative')
|
||||
expect(withdrawn.textContent).toMatch(/^[−-]250,00/)
|
||||
|
||||
const net = screen.getByTestId('dashboard-analytics-netInvested')
|
||||
expect(net.getAttribute('data-tone')).toBe('negative')
|
||||
expect(net.textContent).toMatch(/^[−-]150,00/)
|
||||
|
||||
const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
|
||||
expect(dividends.getAttribute('data-tone')).toBe('positive')
|
||||
expect(dividends.textContent).toContain('75,00')
|
||||
@ -306,25 +178,16 @@ describe('BrokerDashboard', () => {
|
||||
expect(coupons.getAttribute('data-tone')).toBe('neutral')
|
||||
expect(coupons.textContent).toContain('0,00')
|
||||
|
||||
const received = screen.getByTestId('dashboard-analytics-totalReceived')
|
||||
expect(received.getAttribute('data-tone')).toBe('positive')
|
||||
expect(received.textContent).toContain('90,00')
|
||||
const fees = screen.getByTestId('dashboard-analytics-totalFees')
|
||||
expect(fees.getAttribute('data-tone')).toBe('negative')
|
||||
expect(fees.textContent).toMatch(/^[−-]50,00/)
|
||||
|
||||
const taxes = screen.getByTestId('dashboard-analytics-totalTaxesPaid')
|
||||
expect(taxes.getAttribute('data-tone')).toBe('negative')
|
||||
expect(taxes.textContent).toMatch(/^[−-]13,00/)
|
||||
})
|
||||
|
||||
it('shows skeleton table while events are loading', () => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows skeleton table while income operations are loading', () => {
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
@ -337,49 +200,50 @@ describe('BrokerDashboard', () => {
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('does not show skeleton when data is loaded', () => {
|
||||
it('renders dashboard sections when data is loaded', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Последние события')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders events table thead with semantic column headers', () => {
|
||||
mockEventsLoaded([buildEvent()])
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-1',
|
||||
category: 'income',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
|
||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
||||
expect(eventsTable.querySelector('thead')).not.toBeNull()
|
||||
const headers = within(eventsTable).getAllByRole('columnheader')
|
||||
expect(headers.map((h) => h.textContent)).toEqual([
|
||||
'Дата',
|
||||
'Инструмент',
|
||||
'Тип',
|
||||
'Сумма',
|
||||
'Статус',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders income table thead without status column', () => {
|
||||
mockOperationsLoaded([buildOperation()])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
|
||||
expect(incomeTable.querySelector('thead')).not.toBeNull()
|
||||
const headers = within(incomeTable).getAllByRole('columnheader')
|
||||
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
|
||||
})
|
||||
|
||||
it('renders event type badge, instrument subtitle and status pill for loaded events', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }),
|
||||
buildEvent({
|
||||
id: 'evt-forecast',
|
||||
source: 'forecast',
|
||||
type: 'dividend',
|
||||
actualAmount: null,
|
||||
estimatedAmount: 197.56,
|
||||
it('renders event type badge, instrument subtitle for loaded operations', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-div',
|
||||
category: 'income',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
||||
}),
|
||||
buildOperation({
|
||||
id: 'op-coupon',
|
||||
category: 'income',
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
ticker: 'SU26249RMFS1',
|
||||
name: 'ОФЗ 26241',
|
||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
||||
}),
|
||||
])
|
||||
|
||||
@ -389,71 +253,28 @@ describe('BrokerDashboard', () => {
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
|
||||
expect(subtitles).toHaveLength(2)
|
||||
for (const subtitle of subtitles) {
|
||||
expect(subtitle.textContent).toBe('РЖД 001Р-37R')
|
||||
}
|
||||
expect(subtitles.map((el) => el.textContent)).toEqual(['IRAO', 'SU26249RMFS1'])
|
||||
|
||||
const amounts = screen.getAllByTestId('dashboard-events-amount')
|
||||
expect(amounts).toHaveLength(2)
|
||||
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
|
||||
expect(amounts[0].textContent).toContain('+43,56')
|
||||
expect(amounts[1].getAttribute('data-tone')).toBe('planned')
|
||||
expect(amounts[1].textContent).toContain('~197,56')
|
||||
expect(amounts[0].textContent).toContain('+649,25')
|
||||
expect(amounts[1].getAttribute('data-tone')).toBe('positive')
|
||||
expect(amounts[1].textContent).toContain('+109,70')
|
||||
|
||||
expect(screen.getByText('Купон')).toBeInTheDocument()
|
||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||||
expect(screen.getByText('Поступило')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ожидается')).toBeInTheDocument()
|
||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
||||
expect(within(eventsTable).getByText('Дивиденд')).toBeInTheDocument()
|
||||
expect(within(eventsTable).getByText('Купон')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt-1' }),
|
||||
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
|
||||
])
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildEvent({ id: 'evt-1' }),
|
||||
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
|
||||
],
|
||||
summary: { eventCount: 48 },
|
||||
asOf: '2026-06-27T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsSection = screen.getByLabelText('События')
|
||||
expect(screen.getByText('2 из 48')).toBeInTheDocument()
|
||||
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
|
||||
expect(
|
||||
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders HTML-parity count badge and footer summary for income', () => {
|
||||
it('uses negative tone when operation payment is negative', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({ id: 'op-1' }),
|
||||
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('2 операции')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses negative tone when event actual amount is negative', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({
|
||||
id: 'evt-tax',
|
||||
type: 'coupon',
|
||||
source: 'actual',
|
||||
actualAmount: -87.0,
|
||||
buildOperation({
|
||||
id: 'op-neg',
|
||||
category: 'fee',
|
||||
type: 'OPERATION_TYPE_BROKER_FEE',
|
||||
ticker: null,
|
||||
name: 'Комиссия брокера',
|
||||
payment: { currency: 'RUB', units: '0', nano: 0, value: -87.0 },
|
||||
}),
|
||||
])
|
||||
|
||||
@ -461,77 +282,35 @@ describe('BrokerDashboard', () => {
|
||||
|
||||
const [amount] = screen.getAllByTestId('dashboard-events-amount')
|
||||
expect(amount.getAttribute('data-tone')).toBe('negative')
|
||||
expect(amount.textContent).toBe('−87,00 ₽')
|
||||
expect(amount.textContent).toContain('87,00')
|
||||
})
|
||||
|
||||
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }),
|
||||
buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }),
|
||||
])
|
||||
it('renders events amounts with the ₽ symbol and no "RUB" code', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-positive',
|
||||
id: 'op-coupon',
|
||||
category: 'income',
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
ticker: 'SU26249RMFS1',
|
||||
name: 'ОФЗ 26241',
|
||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
||||
}),
|
||||
buildOperation({
|
||||
id: 'op-negative',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
|
||||
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
|
||||
for (const table of [eventsTable, incomeTable]) {
|
||||
expect(table.textContent ?? '').toContain('₽')
|
||||
expect(table.textContent ?? '').not.toContain('RUB')
|
||||
}
|
||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
||||
expect(eventsTable.textContent ?? '').toContain('₽')
|
||||
expect(eventsTable.textContent ?? '').not.toContain('RUB')
|
||||
})
|
||||
|
||||
it('renders income rows with main + subtitle, type badge and signed amount tone', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-div',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
||||
}),
|
||||
buildOperation({
|
||||
id: 'op-div-ext',
|
||||
type: 'OPERATION_TYPE_DIV_EXT',
|
||||
ticker: 'AAPL',
|
||||
name: 'Apple Inc.',
|
||||
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
|
||||
}),
|
||||
])
|
||||
|
||||
it('sends correct params to useBrokerOperations for latest events', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const rows = screen.getAllByTestId('dashboard-income-row')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main')
|
||||
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL'])
|
||||
|
||||
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
|
||||
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
|
||||
|
||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||||
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
|
||||
|
||||
const amounts = screen.getAllByTestId('dashboard-income-amount')
|
||||
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
|
||||
expect(amounts[0].textContent).toContain('+649,25')
|
||||
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
|
||||
expect(amounts[1].textContent).toContain('−35,00')
|
||||
expect(hookMocks.useBrokerOperations).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
{ categories: 'income,tax,fee', limit: 100 },
|
||||
{ enabled: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,35 +1,12 @@
|
||||
import { Box } from '@mui/material'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useBrokerPortfolioHistory } from '@/entities/broker-account'
|
||||
import { useBrokerAnalytics } from '@/entities/broker-analytics'
|
||||
import { useBrokerEvents } from '@/entities/broker-event'
|
||||
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||
import {
|
||||
applyDatePreset,
|
||||
applyEventDatePreset,
|
||||
type DashboardDatePreset,
|
||||
type DashboardEventType,
|
||||
type DashboardIncomeType,
|
||||
defaultEventsFilters,
|
||||
defaultIncomeFilters,
|
||||
incomeTypesToOperationTypes,
|
||||
} from '../lib/dashboardFilters'
|
||||
import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
|
||||
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
|
||||
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
|
||||
import { BrokerDashboardHero } from './BrokerDashboardHero'
|
||||
import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
|
||||
|
||||
function formatDateLabel(from: string, to: string): string | null {
|
||||
if (!from && !to) return 'За всё время'
|
||||
const fmt = (d: string) => dayjs(d).format('D MMM')
|
||||
if (from && to) return `${fmt(from)} – ${fmt(to)}`
|
||||
if (from) return `с ${fmt(from)}`
|
||||
if (to) return `до ${fmt(to)}`
|
||||
return null
|
||||
}
|
||||
import { BrokerPortfolioHistoryCard } from './BrokerPortfolioHistoryCard'
|
||||
|
||||
export function BrokerDashboard({
|
||||
accountId,
|
||||
@ -38,200 +15,36 @@ export function BrokerDashboard({
|
||||
accountId: string
|
||||
portfolio: BrokerPortfolio
|
||||
}) {
|
||||
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
|
||||
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
|
||||
const [eventPage, setEventPage] = useState(1)
|
||||
|
||||
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const incomePagination = useCursorPagination()
|
||||
|
||||
const analytics = useBrokerAnalytics(accountId)
|
||||
const hasEventTypes = appliedEventFilters.types.length > 0
|
||||
const hasIncomeTypes = appliedIncomeFilters.types.length > 0
|
||||
const hasDraftEventTypes = draftEventFilters.types.length > 0
|
||||
const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0
|
||||
const eventPageSize = 10
|
||||
const portfolioHistory = useBrokerPortfolioHistory(accountId)
|
||||
|
||||
const events = useBrokerEvents(
|
||||
const eventsOps = useBrokerOperations(
|
||||
accountId,
|
||||
{
|
||||
from: appliedEventFilters.from,
|
||||
to: appliedEventFilters.to,
|
||||
types: appliedEventFilters.types.join(','),
|
||||
},
|
||||
{ enabled: hasEventTypes },
|
||||
{ categories: 'income,tax,fee', limit: 100 },
|
||||
{ enabled: true },
|
||||
)
|
||||
|
||||
const operations = useBrokerOperations(
|
||||
accountId,
|
||||
{
|
||||
from: appliedIncomeFilters.from,
|
||||
to: appliedIncomeFilters.to,
|
||||
operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types),
|
||||
cursor: incomePagination.cursor,
|
||||
limit: 10,
|
||||
},
|
||||
{ enabled: hasIncomeTypes },
|
||||
)
|
||||
|
||||
const nextCursor: string | undefined = operations.data?.nextCursor
|
||||
? (operations.data.nextCursor as unknown as string)
|
||||
: undefined
|
||||
|
||||
const eventItems = events.data?.items ?? []
|
||||
const eventPageItems = eventItems.slice(
|
||||
(eventPage - 1) * eventPageSize,
|
||||
eventPage * eventPageSize,
|
||||
)
|
||||
|
||||
function toggleEventType(type: DashboardEventType) {
|
||||
setAppliedEventFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setDraftEventFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setEventPage(1)
|
||||
}
|
||||
|
||||
function toggleIncomeType(type: DashboardIncomeType) {
|
||||
setAppliedIncomeFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setDraftIncomeFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
incomePagination.reset()
|
||||
}
|
||||
|
||||
const applyEventFilters = useCallback(() => {
|
||||
setAppliedEventFilters((prev) => ({
|
||||
...prev,
|
||||
from: draftEventFilters.from,
|
||||
to: draftEventFilters.to,
|
||||
preset: draftEventFilters.preset,
|
||||
}))
|
||||
setEventPage(1)
|
||||
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
|
||||
|
||||
const resetEventFilters = useCallback(() => {
|
||||
const defaults = defaultEventsFilters()
|
||||
setAppliedEventFilters(defaults)
|
||||
setDraftEventFilters(defaults)
|
||||
setEventPage(1)
|
||||
}, [])
|
||||
|
||||
const applyIncomeFilters = useCallback(() => {
|
||||
setAppliedIncomeFilters((prev) => ({
|
||||
...prev,
|
||||
from: draftIncomeFilters.from,
|
||||
to: draftIncomeFilters.to,
|
||||
preset: draftIncomeFilters.preset,
|
||||
}))
|
||||
incomePagination.reset()
|
||||
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
|
||||
|
||||
const resetIncomeFilters = useCallback(() => {
|
||||
const defaults = defaultIncomeFilters()
|
||||
setAppliedIncomeFilters(defaults)
|
||||
setDraftIncomeFilters(defaults)
|
||||
incomePagination.reset()
|
||||
}, [incomePagination])
|
||||
|
||||
function handleDraftEventPresetChange(preset: DashboardDatePreset) {
|
||||
setDraftEventFilters((filters) => applyEventDatePreset(filters, preset))
|
||||
}
|
||||
|
||||
function handleDraftEventFromChange(value: string) {
|
||||
setDraftEventFilters((filters) => ({ ...filters, from: value }))
|
||||
}
|
||||
|
||||
function handleDraftEventToChange(value: string) {
|
||||
setDraftEventFilters((filters) => ({ ...filters, to: value }))
|
||||
}
|
||||
|
||||
function handleDraftIncomePresetChange(preset: DashboardDatePreset) {
|
||||
setDraftIncomeFilters((filters) => applyDatePreset(filters, preset))
|
||||
}
|
||||
|
||||
function handleDraftIncomeFromChange(value: string) {
|
||||
setDraftIncomeFilters((filters) => ({ ...filters, from: value }))
|
||||
}
|
||||
|
||||
function handleDraftIncomeToChange(value: string) {
|
||||
setDraftIncomeFilters((filters) => ({ ...filters, to: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
|
||||
<BrokerDashboardEventsCard
|
||||
accountId={accountId}
|
||||
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
|
||||
isLoading={events.isLoading}
|
||||
isError={events.isError}
|
||||
selectedTypes={draftEventFilters.types}
|
||||
onToggleType={toggleEventType}
|
||||
appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)}
|
||||
draftPreset={draftEventFilters.preset}
|
||||
draftFrom={draftEventFilters.from}
|
||||
draftTo={draftEventFilters.to}
|
||||
onDraftPresetChange={handleDraftEventPresetChange}
|
||||
onDraftFromChange={handleDraftEventFromChange}
|
||||
onDraftToChange={handleDraftEventToChange}
|
||||
onApplyFilters={applyEventFilters}
|
||||
onResetFilters={resetEventFilters}
|
||||
hasDraftTypes={hasDraftEventTypes}
|
||||
totalCount={events.data?.summary?.eventCount}
|
||||
page={eventPage}
|
||||
canGoBack={eventPage > 1}
|
||||
canGoForward={eventPage * eventPageSize < eventItems.length}
|
||||
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
|
||||
onNextPage={() => setEventPage((page) => page + 1)}
|
||||
/>
|
||||
<BrokerDashboardIncomeCard
|
||||
accountId={accountId}
|
||||
page={operations.data}
|
||||
isLoading={operations.isLoading}
|
||||
isError={operations.isError}
|
||||
selectedTypes={draftIncomeFilters.types}
|
||||
onToggleType={toggleIncomeType}
|
||||
appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)}
|
||||
draftPreset={draftIncomeFilters.preset}
|
||||
draftFrom={draftIncomeFilters.from}
|
||||
draftTo={draftIncomeFilters.to}
|
||||
onDraftPresetChange={handleDraftIncomePresetChange}
|
||||
onDraftFromChange={handleDraftIncomeFromChange}
|
||||
onDraftToChange={handleDraftIncomeToChange}
|
||||
onApplyFilters={applyIncomeFilters}
|
||||
onResetFilters={resetIncomeFilters}
|
||||
hasDraftTypes={hasDraftIncomeTypes}
|
||||
visibleCount={operations.data?.items?.length ?? 0}
|
||||
pageNumber={incomePagination.pageNumber}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
canGoForward={operations.data?.hasNext ?? false}
|
||||
onPreviousPage={incomePagination.handlePrevious}
|
||||
onNextPage={() => incomePagination.handleNext(nextCursor)}
|
||||
<BrokerPortfolioHistoryCard
|
||||
data={portfolioHistory.data}
|
||||
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
||||
isLoading={portfolioHistory.isLoading}
|
||||
isError={portfolioHistory.isError}
|
||||
/>
|
||||
<BrokerDashboardAnalyticsCard
|
||||
data={analytics.data}
|
||||
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
||||
isLoading={analytics.isLoading}
|
||||
isError={analytics.isError}
|
||||
/>
|
||||
<BrokerDashboardAllocationCard portfolio={portfolio} />
|
||||
<BrokerDashboardEventsCard
|
||||
accountId={accountId}
|
||||
data={eventsOps.data?.items ?? []}
|
||||
isLoading={eventsOps.isLoading}
|
||||
isError={eventsOps.isError}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,11 +8,11 @@ import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
const ALLOCATION_COLORS: Record<string, string> = {
|
||||
shares: '#4969f5',
|
||||
bonds: '#e5a33c',
|
||||
etf: '#62b889',
|
||||
cash: '#7b63cf',
|
||||
other: '#aeb6c5',
|
||||
}
|
||||
|
||||
const VISIBLE_SECTORS = new Set(['shares', 'bonds', 'cash'])
|
||||
|
||||
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
const { total, sectors, negative } = buildBrokerAllocation(portfolio)
|
||||
const currency =
|
||||
@ -22,24 +22,19 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
|
||||
'RUB')
|
||||
: 'RUB'
|
||||
|
||||
const visibleSectors = sectors.filter((s) => VISIBLE_SECTORS.has(s.key))
|
||||
const visibleNegative = negative.filter((n) => VISIBLE_SECTORS.has(n.key))
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="Аллокация"
|
||||
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
|
||||
>
|
||||
{sectors.length === 0 && negative.length === 0 ? (
|
||||
<BrokerDashboardCard title="Структура">
|
||||
<Box sx={{ fontWeight: 700, mb: 1.5, lineHeight: 1.25 }}>
|
||||
{formatBrokerMoney(portfolio.totals.portfolio)}
|
||||
</Box>
|
||||
{visibleSectors.length === 0 && visibleNegative.length === 0 ? (
|
||||
<Text tone="muted">Нет данных для распределения</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Структура портфеля
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontWeight: 700 }}>
|
||||
{formatBrokerMoney(portfolio.totals.portfolio)}
|
||||
</Text>
|
||||
</Box>
|
||||
{sectors.map((sector) => (
|
||||
{visibleSectors.map((sector) => (
|
||||
<Box key={sector.key}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Text variant="body">{sector.label}</Text>
|
||||
@ -67,9 +62,9 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{negative.length > 0 && (
|
||||
{visibleNegative.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{negative.map((item) => (
|
||||
{visibleNegative.map((item) => (
|
||||
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
|
||||
<Text variant="body">{item.label}:</Text>
|
||||
<Text variant="body" tone="negative">
|
||||
|
||||
@ -1,21 +1,16 @@
|
||||
import { Metric, Text } from '@moex-vibe/design-system'
|
||||
import { Skeleton, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BrokerAnalytics } from '@/shared/api'
|
||||
import {
|
||||
formatDashboardCurrency,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
} from '../lib/dashboardVisual'
|
||||
import type { BrokerAnalytics, BrokerMoney } from '@/shared/api'
|
||||
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
|
||||
type AnalyticsField =
|
||||
| 'totalDeposits'
|
||||
| 'totalWithdrawn'
|
||||
| 'netInvested'
|
||||
| 'totalDividends'
|
||||
| 'totalCoupons'
|
||||
| 'totalReceived'
|
||||
| 'totalFees'
|
||||
| 'totalTaxesPaid'
|
||||
|
||||
const ANALYTICS_METRICS: readonly {
|
||||
field: AnalyticsField
|
||||
@ -24,13 +19,18 @@ const ANALYTICS_METRICS: readonly {
|
||||
}[] = [
|
||||
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
|
||||
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
|
||||
{ field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' },
|
||||
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
|
||||
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
|
||||
{ field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' },
|
||||
{ field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' },
|
||||
{
|
||||
field: 'totalTaxesPaid',
|
||||
label: 'Уплаченные налоги',
|
||||
testId: 'dashboard-analytics-totalTaxesPaid',
|
||||
},
|
||||
]
|
||||
|
||||
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
|
||||
if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative'
|
||||
if (field === 'totalWithdrawn') {
|
||||
return value > 0 ? 'negative' : moneyTone(value)
|
||||
}
|
||||
@ -38,17 +38,23 @@ function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
|
||||
}
|
||||
|
||||
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
|
||||
if (field === 'totalFees' || field === 'totalTaxesPaid') {
|
||||
const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) })
|
||||
return `\u2212${formatted}`
|
||||
}
|
||||
const formatted = formatDashboardCurrency({ currency, value })
|
||||
if (field === 'totalWithdrawn' && value > 0) return `−${formatted}`
|
||||
if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}`
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function BrokerDashboardAnalyticsCard({
|
||||
data,
|
||||
portfolioValue,
|
||||
isLoading,
|
||||
isError,
|
||||
}: {
|
||||
data: BrokerAnalytics | undefined
|
||||
portfolioValue?: BrokerMoney
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}) {
|
||||
@ -57,52 +63,194 @@ export function BrokerDashboardAnalyticsCard({
|
||||
{isError ? (
|
||||
<Text tone="negative">Не удалось загрузить аналитику</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка аналитики…</Text>
|
||||
) : !data ? (
|
||||
<Text tone="muted">Нет данных для аналитики</Text>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
||||
const value = data[field]
|
||||
const tone = analyticsTone(field, value)
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 2 }} aria-hidden="true">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Box
|
||||
key={field}
|
||||
key={i}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: tone === 'negative' ? 'error.light' : 'divider',
|
||||
bgcolor:
|
||||
tone === 'positive'
|
||||
? 'rgba(46, 125, 50, 0.06)'
|
||||
: tone === 'negative'
|
||||
? 'rgba(211, 47, 47, 0.06)'
|
||||
: 'grey.50',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.50',
|
||||
p: 1.5,
|
||||
minHeight: 78,
|
||||
display: 'grid',
|
||||
gap: 1.25,
|
||||
}}
|
||||
>
|
||||
<Metric
|
||||
label={label}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
data-testid={testId}
|
||||
data-tone={tone}
|
||||
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
|
||||
>
|
||||
{analyticsDisplay(field, value, data.currency)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Skeleton height={12} width={92} shape="rounded" />
|
||||
<Skeleton height={15} width={64} shape="rounded" />
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.50',
|
||||
p: 1.5,
|
||||
minHeight: 72,
|
||||
display: 'grid',
|
||||
gap: 1.25,
|
||||
}}
|
||||
>
|
||||
<Skeleton height={12} width={92} shape="rounded" />
|
||||
<Skeleton height={15} width={64} shape="rounded" />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : !data ? (
|
||||
<Text tone="muted">Нет данных для аналитики</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'rgba(255,255,255,0.5)',
|
||||
p: 1.5,
|
||||
minHeight: 72,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text variant="label" tone="secondary">
|
||||
Стоимость портфеля
|
||||
</Text>
|
||||
<Box sx={{ mt: 0.5, fontWeight: 700, fontSize: 18, whiteSpace: 'nowrap' }}>
|
||||
{formatDashboardCurrency({
|
||||
currency: portfolioValue?.currency ?? data.currency,
|
||||
value: portfolioValue?.value ?? 0,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'rgba(255,255,255,0.5)',
|
||||
p: 1.5,
|
||||
minHeight: 72,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text variant="label" tone="secondary">
|
||||
Всего доходов
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'success.main',
|
||||
}}
|
||||
>
|
||||
{formatDashboardCurrency({
|
||||
currency: data.currency,
|
||||
value: data.totalDividends + data.totalCoupons,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
||||
const value = data[field]
|
||||
const tone = analyticsTone(field, value)
|
||||
return (
|
||||
<Box
|
||||
key={field}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor:
|
||||
tone === 'positive'
|
||||
? 'rgba(46, 125, 50, 0.3)'
|
||||
: tone === 'negative'
|
||||
? 'rgba(211, 47, 47, 0.3)'
|
||||
: 'divider',
|
||||
background:
|
||||
tone === 'positive'
|
||||
? 'linear-gradient(180deg, #f3faf5, #edf7f0)'
|
||||
: tone === 'negative'
|
||||
? 'linear-gradient(180deg, #fff7f5, #fff1ef)'
|
||||
: undefined,
|
||||
bgcolor: tone === 'positive' || tone === 'negative' ? undefined : 'grey.50',
|
||||
p: 1.5,
|
||||
minHeight: 72,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
<Box
|
||||
data-testid={testId}
|
||||
data-tone={tone}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontWeight: 700,
|
||||
fontSize: 15,
|
||||
lineHeight: 1.25,
|
||||
color:
|
||||
tone === 'positive'
|
||||
? 'success.main'
|
||||
: tone === 'negative'
|
||||
? 'error.main'
|
||||
: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{analyticsDisplay(field, value, data.currency)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
|
||||
@ -47,7 +47,11 @@ export function BrokerDashboardCard({
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Heading level={2} size="section">
|
||||
<Heading
|
||||
level={2}
|
||||
size="section"
|
||||
style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.18 }}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{badge ? (
|
||||
|
||||
@ -1,35 +1,12 @@
|
||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
|
||||
import { useState } from 'react'
|
||||
import type { BrokerOperation } from '@/shared/api'
|
||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
import {
|
||||
eventStatusTone,
|
||||
eventTypeTone,
|
||||
formatDashboardCurrency,
|
||||
instrumentDisplay,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
type TypeTone,
|
||||
} from '../lib/dashboardVisual'
|
||||
import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
||||
|
||||
const EVENT_FILTERS: Array<{
|
||||
type: DashboardEventType
|
||||
label: string
|
||||
tone: TypeTone
|
||||
}> = [
|
||||
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
|
||||
{ type: 'coupon', label: 'Купоны', tone: 'info' },
|
||||
{ type: 'maturity', label: 'Погашения', tone: 'warning' },
|
||||
{ type: 'offer', label: 'Оферты', tone: 'neutral' },
|
||||
]
|
||||
|
||||
const TH_SX = {
|
||||
textAlign: 'left' as const,
|
||||
@ -62,50 +39,113 @@ const TD_SX_INSTRUMENT = {
|
||||
minWidth: 180,
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 7
|
||||
|
||||
const BADGE_STYLES: Record<string, { bg: string; borderColor: string; color: string }> = {
|
||||
coupon: { bg: '#e8f0ff', borderColor: '#cddcff', color: '#2563eb' },
|
||||
dividend: { bg: '#e5f2ea', borderColor: '#bcdcc9', color: '#176747' },
|
||||
maturity: { bg: '#fff5dc', borderColor: '#f0d898', color: '#8d6400' },
|
||||
tax: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
||||
fee: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
||||
}
|
||||
|
||||
type BadgeInfo = { label: string; badgeKey: string }
|
||||
|
||||
type BrokerDashboardEventsCardProps = {
|
||||
accountId: string
|
||||
data: BrokerEventsData | undefined
|
||||
data: BrokerOperation[] | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedTypes: DashboardEventType[]
|
||||
onToggleType: (type: DashboardEventType) => void
|
||||
appliedDateLabel: string | null
|
||||
draftPreset: DashboardDatePreset
|
||||
draftFrom: string
|
||||
draftTo: string
|
||||
onDraftPresetChange: (preset: DashboardDatePreset) => void
|
||||
onDraftFromChange: (value: string) => void
|
||||
onDraftToChange: (value: string) => void
|
||||
onApplyFilters: () => void
|
||||
onResetFilters: () => void
|
||||
hasDraftTypes: boolean
|
||||
totalCount?: number
|
||||
page: number
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
}
|
||||
|
||||
function eventAmountValue(event: BrokerEventItem): number | null {
|
||||
return event.source === 'actual' ? event.actualAmount : event.estimatedAmount
|
||||
function getOperationBadge(operation: BrokerOperation): BadgeInfo {
|
||||
if (operation.category === 'tax') return { label: 'Налог', badgeKey: 'tax' }
|
||||
if (operation.category === 'fee') return { label: 'Комиссия', badgeKey: 'fee' }
|
||||
if (operation.category === 'income') {
|
||||
if (
|
||||
operation.type === 'OPERATION_TYPE_DIVIDEND' ||
|
||||
operation.type === 'OPERATION_TYPE_DIV_EXT'
|
||||
) {
|
||||
return { label: 'Дивиденд', badgeKey: 'dividend' }
|
||||
}
|
||||
if (operation.type === 'OPERATION_TYPE_COUPON') {
|
||||
return { label: 'Купон', badgeKey: 'coupon' }
|
||||
}
|
||||
if (
|
||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
|
||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
|
||||
operation.type === 'OPERATION_TYPE_MATURITY'
|
||||
) {
|
||||
return { label: 'Погашение', badgeKey: 'maturity' }
|
||||
}
|
||||
return { label: 'Доход', badgeKey: '' }
|
||||
}
|
||||
return { label: 'Прочее', badgeKey: '' }
|
||||
}
|
||||
|
||||
function eventMoneyTone(event: BrokerEventItem): MoneyTone {
|
||||
return moneyTone(eventAmountValue(event), event.source)
|
||||
function Badge({ label, badgeKey }: { label: string; badgeKey: string }) {
|
||||
const style = BADGE_STYLES[badgeKey]
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 24,
|
||||
px: 1.25,
|
||||
borderRadius: '999px',
|
||||
border: 1,
|
||||
borderColor: style?.borderColor ?? 'divider',
|
||||
bgcolor: style?.bg ?? 'transparent',
|
||||
color: style?.color ?? 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function eventAmountDisplay(event: BrokerEventItem): string {
|
||||
const amount = eventAmountValue(event)
|
||||
if (amount === null || amount === undefined) return '—'
|
||||
const abs = formatDashboardCurrency({
|
||||
currency: event.currency ?? 'RUB',
|
||||
value: Math.abs(amount),
|
||||
})
|
||||
if (event.source === 'forecast') return `~${abs}`
|
||||
if (amount > 0) return `+${abs}`
|
||||
if (amount < 0) return `−${abs}`
|
||||
return abs
|
||||
function PagerButton({
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minWidth: 34,
|
||||
height: 32,
|
||||
border: 1,
|
||||
borderColor: disabled ? 'divider' : 'grey.300',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
color: disabled ? 'text.disabled' : 'primary.main',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
px: 1,
|
||||
'&:hover:not(:disabled)': { bgcolor: 'grey.100' },
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrokerDashboardEventsCard({
|
||||
@ -113,188 +153,132 @@ export function BrokerDashboardEventsCard({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
appliedDateLabel,
|
||||
draftPreset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
onDraftPresetChange,
|
||||
onDraftFromChange,
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
hasDraftTypes,
|
||||
totalCount,
|
||||
page,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
}: BrokerDashboardEventsCardProps) {
|
||||
const events = data?.items ?? []
|
||||
const [page, setPage] = useState(0)
|
||||
const operations = data ?? []
|
||||
const totalPages = Math.max(1, Math.ceil(operations.length / ITEMS_PER_PAGE))
|
||||
const safePage = Math.min(page, totalPages - 1)
|
||||
const pageStart = safePage * ITEMS_PER_PAGE
|
||||
const pageEnd = pageStart + ITEMS_PER_PAGE
|
||||
const pageItems = operations.slice(pageStart, pageEnd)
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="События"
|
||||
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
|
||||
title="Последние события"
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
||||
filters={
|
||||
<BrokerDashboardTableToolbar
|
||||
chips={EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<BrokerDashboardDateFilter
|
||||
appliedLabel={appliedDateLabel}
|
||||
preset={draftPreset}
|
||||
draftFrom={draftFrom}
|
||||
draftTo={draftTo}
|
||||
hasDraftTypes={hasDraftTypes}
|
||||
onPresetChange={onDraftPresetChange}
|
||||
onFromChange={onDraftFromChange}
|
||||
onToChange={onDraftToChange}
|
||||
onReset={onResetFilters}
|
||||
onApply={onApplyFilters}
|
||||
/>
|
||||
</BrokerDashboardTableToolbar>
|
||||
}
|
||||
>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип событий</Text>
|
||||
) : isError ? (
|
||||
{isError ? (
|
||||
<Text tone="negative">Не удалось загрузить события</Text>
|
||||
) : isLoading ? (
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={5} />
|
||||
) : events.length === 0 ? (
|
||||
<Text tone="muted">В ближайшем периоде событий нет</Text>
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
||||
) : operations.length === 0 ? (
|
||||
<Text tone="muted">Событий нет</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
aria-label="Таблица событий брокерского счёта"
|
||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }}
|
||||
>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Дата
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Инструмент
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Тип
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Сумма
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Статус
|
||||
</Box>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
aria-label="Таблица последних событий брокерского счёта"
|
||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
|
||||
>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Дата
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Инструмент
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Тип
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Сумма
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{events.map((event) => {
|
||||
const display = instrumentDisplay({
|
||||
ticker: event.ticker,
|
||||
name: event.name,
|
||||
})
|
||||
const formattedAmount = eventAmountDisplay(event)
|
||||
return (
|
||||
<Box component="tr" key={event.id} data-testid="dashboard-events-row">
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
{formatBrokerDate(event.eventDate) ?? '—'}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Box
|
||||
sx={{ fontWeight: 700 }}
|
||||
data-testid="dashboard-events-instrument-main"
|
||||
>
|
||||
{display.main}
|
||||
</Box>
|
||||
{display.subtitle ? (
|
||||
<Box
|
||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
||||
data-testid="dashboard-events-instrument-subtitle"
|
||||
>
|
||||
{display.subtitle}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{pageItems.map((op) => {
|
||||
const boldText = op.name ?? op.description ?? op.ticker ?? '—'
|
||||
const grayText = op.name ? (op.ticker ?? null) : null
|
||||
const { label, badgeKey } = getOperationBadge(op)
|
||||
const amountValue = op.payment?.value ?? 0
|
||||
const amountTone = amountValue >= 0 ? 'positive' : 'negative'
|
||||
const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
|
||||
op.payment
|
||||
? { currency: op.payment.currency, value: Math.abs(amountValue) }
|
||||
: { currency: 'RUB', value: 0 },
|
||||
)}`
|
||||
return (
|
||||
<Box
|
||||
component="tr"
|
||||
key={String(op.id ?? op.cursor ?? `${op.type}-${op.date}`)}
|
||||
data-testid="dashboard-events-row"
|
||||
>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
{formatBrokerDate(typeof op.date === 'string' ? op.date : null) ?? '—'}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Box
|
||||
sx={{ fontWeight: 700 }}
|
||||
data-testid="dashboard-events-instrument-main"
|
||||
>
|
||||
{boldText}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
<Chip
|
||||
label={eventTypeLabel(event.type)}
|
||||
tone={eventTypeTone(event.type)}
|
||||
selected={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
component="td"
|
||||
sx={{
|
||||
...TD_SX_RIGHT,
|
||||
fontWeight: 700,
|
||||
color: moneyToneToColor(eventMoneyTone(event)),
|
||||
}}
|
||||
data-testid="dashboard-events-amount"
|
||||
data-tone={eventMoneyTone(event)}
|
||||
>
|
||||
{formattedAmount}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_RIGHT}>
|
||||
<Chip
|
||||
label={eventStatusLabel(event)}
|
||||
tone={eventStatusTone(event.source)}
|
||||
selected={false}
|
||||
/>
|
||||
{grayText ? (
|
||||
<Box
|
||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
||||
data-testid="dashboard-events-instrument-subtitle"
|
||||
>
|
||||
{grayText}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
<Badge label={label} badgeKey={badgeKey} />
|
||||
</Box>
|
||||
<Box
|
||||
component="td"
|
||||
sx={{
|
||||
...TD_SX_RIGHT,
|
||||
fontWeight: 700,
|
||||
color: moneyToneToColor(amountTone),
|
||||
}}
|
||||
data-testid="dashboard-events-amount"
|
||||
data-tone={amountTone}
|
||||
>
|
||||
{formattedAmount}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
mt: 1.5,
|
||||
minHeight: 34,
|
||||
}}
|
||||
>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Показано {events.length} событий за выбранный период
|
||||
</Text>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onPreviousPage}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Text variant="body" tone="secondary">
|
||||
{page}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onNextPage}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
<PagerButton
|
||||
disabled={safePage === 0}
|
||||
label="←"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<Box component="span" sx={{ fontSize: 13, color: 'text.secondary' }}>
|
||||
{safePage + 1}
|
||||
</Box>
|
||||
<PagerButton
|
||||
disabled={safePage >= totalPages - 1}
|
||||
label="→"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
import { Skeleton, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BrokerMoney, BrokerPortfolioHistoryData } from '@/shared/api'
|
||||
import { formatDashboardCurrency } from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
|
||||
type BrokerPortfolioHistoryCardProps = {
|
||||
data: BrokerPortfolioHistoryData | undefined
|
||||
portfolioValue: BrokerMoney | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
function generateChartPath(points: { value: BrokerMoney }[]): { line: string; area: string } {
|
||||
const values = points.map((p) => p.value.value)
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min || 1
|
||||
const padding = range * 0.1
|
||||
|
||||
const viewWidth = 300
|
||||
const viewHeight = 90
|
||||
const padLeft = 10
|
||||
const padRight = 10
|
||||
const padTop = 5
|
||||
const padBottom = 5
|
||||
const plotWidth = viewWidth - padLeft - padRight
|
||||
const plotHeight = viewHeight - padTop - padBottom
|
||||
const yMin = min - padding
|
||||
const yRange = max + padding - yMin
|
||||
|
||||
function x(i: number) {
|
||||
return padLeft + (i / (points.length - 1)) * plotWidth
|
||||
}
|
||||
|
||||
function y(val: number) {
|
||||
return padTop + plotHeight - ((val - yMin) / yRange) * plotHeight
|
||||
}
|
||||
|
||||
let lineCmd = `M ${x(0)},${y(values[0])}`
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const x0 = x(i - 1)
|
||||
const y0 = y(values[i - 1])
|
||||
const x1 = x(i)
|
||||
const y1 = y(values[i])
|
||||
const cx1 = x0 + (x1 - x0) / 2
|
||||
const cx2 = x0 + (x1 - x0) / 2
|
||||
lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}`
|
||||
}
|
||||
|
||||
const bottom = viewHeight
|
||||
const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z`
|
||||
|
||||
return { line: lineCmd, area: areaCmd }
|
||||
}
|
||||
|
||||
export function BrokerPortfolioHistoryCard({
|
||||
data,
|
||||
portfolioValue,
|
||||
isLoading,
|
||||
isError,
|
||||
}: BrokerPortfolioHistoryCardProps) {
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="Стоимость портфеля за 6 месяцев"
|
||||
sx={{
|
||||
borderColor: 'success.light',
|
||||
background: 'linear-gradient(135deg, rgba(229, 242, 234, 0.96), rgba(255, 255, 255, 0.86))',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
if (isError) {
|
||||
return <Text tone="negative">Не удалось загрузить историю портфеля</Text>
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 2.5, minHeight: 180 }} aria-hidden="true">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Skeleton height={12} width={120} shape="rounded" />
|
||||
<Skeleton height={18} width={140} shape="rounded" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 120,
|
||||
mx: -2.25,
|
||||
width: 'calc(100% + 36px)',
|
||||
borderRadius: 2,
|
||||
background: 'rgba(46, 125, 50, 0.05)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 20,
|
||||
borderTop: '1px dashed rgba(46, 125, 50, 0.16)',
|
||||
}}
|
||||
/>
|
||||
<Skeleton height="100%" width="100%" shape="rectangular" />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const points = data.points
|
||||
if (points.length === 0) {
|
||||
return <Text tone="muted">Нет данных за выбранный период</Text>
|
||||
}
|
||||
|
||||
const { line, area } = generateChartPath(points)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Text variant="label" tone="secondary">
|
||||
Текущая стоимость
|
||||
</Text>
|
||||
<Box sx={{ fontWeight: 700, fontSize: 18, lineHeight: 1.25 }}>
|
||||
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mx: -2.25, width: 'calc(100% + 36px)' }}>
|
||||
<svg
|
||||
viewBox="0 0 300 90"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
aria-label="График изменения стоимости портфеля"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#areaGradient)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mx: -2.25,
|
||||
width: 'calc(100% + 36px)',
|
||||
height: 20,
|
||||
color: 'text.disabled',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{points.map((point, i) => {
|
||||
const plotWidth = 300 - 10 - 10
|
||||
const leftPct = ((10 + (i / (points.length - 1)) * plotWidth) / 300) * 100
|
||||
const isFirst = i === 0
|
||||
const isLast = i === points.length - 1
|
||||
return (
|
||||
<Box
|
||||
key={point.month}
|
||||
component="span"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: isLast ? `${leftPct}%` : `${leftPct}%`,
|
||||
transform: isFirst
|
||||
? 'none'
|
||||
: isLast
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{point.label}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
</BrokerDashboardCard>
|
||||
)
|
||||
}
|
||||
329
docs/features/broker-account-overview-html-parity/plan.md
Normal file
329
docs/features/broker-account-overview-html-parity/plan.md
Normal file
@ -0,0 +1,329 @@
|
||||
# Финальный HTML Parity брокерского overview — план реализации
|
||||
|
||||
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать
|
||||
> `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для
|
||||
> пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`.
|
||||
|
||||
**Цель:** привести `/broker/:accountId` к финальному HTML-эталону
|
||||
`docs/research/frontend-overview-redesign/example.html` без изменения URL-структуры счёта.
|
||||
|
||||
**Архитектура:** backend расширяет существующий broker read API минимальными агрегатами и историей
|
||||
стоимости. Frontend остаётся в FSD-границах `entities/broker-*`, `widgets/broker-dashboard`,
|
||||
`widgets/broker-account-layout`; dashboard-композиция меняется с промежуточной версии на финальную
|
||||
HTML parity структуру.
|
||||
|
||||
**Технологии:** NestJS, Prisma/T-Bank broker operations read path, Swagger/OpenAPI codegen, React 18,
|
||||
TanStack Query, TanStack Router, MUI + `@moex-vibe/design-system`, Vitest, Testing Library.
|
||||
|
||||
---
|
||||
|
||||
## Canonical Research Source
|
||||
|
||||
- Использовать как visual source of truth:
|
||||
`docs/research/frontend-overview-redesign/example.html`.
|
||||
- Не использовать как source of truth:
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`, пока файл не синхронизирован с финальным
|
||||
макетом.
|
||||
- Production UI не переносит demo-control `Данные / Загрузка`; этот control нужен только HTML-макету.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Backend
|
||||
|
||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` — добавить
|
||||
`totalFees`, `totalTaxesPaid`.
|
||||
- Create: `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts` — DTO для
|
||||
6-месячной истории стоимости.
|
||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` — envelope для history endpoint.
|
||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts` — query `categories`.
|
||||
- Modify: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` — агрегировать fee/tax.
|
||||
- Create: `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts` — read-model
|
||||
истории стоимости.
|
||||
- Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` — применять
|
||||
category-фильтр до ответа.
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — endpoint portfolio history.
|
||||
- Modify: backend tests рядом с изменёнными сервисами/controller.
|
||||
|
||||
### Frontend data
|
||||
|
||||
- Modify: `apps/frontend/src/shared/api/index.ts` — экспорт нового `BrokerPortfolioHistory`.
|
||||
- Create: `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts`.
|
||||
- Create: `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts`.
|
||||
- Modify: `apps/frontend/src/entities/broker-account/index.ts` — экспорт hook/API.
|
||||
- Modify: `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts` — query `categories`.
|
||||
|
||||
### Frontend UI
|
||||
|
||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — title yield
|
||||
рядом с заголовком счёта, без page-header skeleton.
|
||||
- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — финальный порядок блоков
|
||||
и источники данных.
|
||||
- Replace/Remove: `BrokerDashboardHero.tsx` из overview-композиции; файл можно оставить только если
|
||||
больше используется в тестах/экспортах, но в `/broker/:accountId` он не рендерится.
|
||||
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx`.
|
||||
- Modify: `BrokerDashboardAnalyticsCard.tsx`, `BrokerDashboardAllocationCard.tsx`,
|
||||
`BrokerDashboardEventsCard.tsx`, `BrokerDashboardSkeleton.tsx`.
|
||||
- Modify/Create helpers в `apps/frontend/src/widgets/broker-dashboard/lib/` для chart points, latest
|
||||
event rows, analytics display и visual tones.
|
||||
- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` и helper unit tests.
|
||||
|
||||
### Docs
|
||||
|
||||
- Modify: `docs/features/broker-account-overview-html-parity/tasks.md` по факту выполнения.
|
||||
- Не переписывать `docs/features/broker-dashboard-redesign/*`; старая фича остаётся историей
|
||||
промежуточной итерации.
|
||||
|
||||
## Data Contracts
|
||||
|
||||
### Analytics
|
||||
|
||||
`BrokerAnalyticsDto` расширяется:
|
||||
|
||||
```ts
|
||||
totalFees: number
|
||||
totalTaxesPaid: number
|
||||
```
|
||||
|
||||
Расчёт:
|
||||
|
||||
- `totalFees` = сумма absolute payment values executed операций категории `fee`;
|
||||
- `totalTaxesPaid` = сумма absolute payment values executed операций категории `tax`;
|
||||
- значения возвращаются как положительные агрегаты;
|
||||
- frontend отображает их со знаком минус и negative tone.
|
||||
|
||||
### Portfolio History
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6
|
||||
```
|
||||
|
||||
Response data:
|
||||
|
||||
```ts
|
||||
type BrokerPortfolioHistoryData = {
|
||||
accountId: string
|
||||
points: Array<{
|
||||
month: string
|
||||
label: string
|
||||
value: BrokerMoneyDto
|
||||
}>
|
||||
asOf: string
|
||||
}
|
||||
```
|
||||
|
||||
Правила v1:
|
||||
|
||||
- `months` по умолчанию 6, допустимый диапазон 2-12;
|
||||
- `points.length === months`;
|
||||
- `month` в формате `YYYY-MM`;
|
||||
- `label` — короткое русское имя месяца;
|
||||
- последняя точка равна текущей `portfolio.totals.portfolio`;
|
||||
- предыдущие точки могут быть estimated read-model из текущей стоимости и executed операций за период;
|
||||
- контракт должен позволять позже заменить estimated calculation на persisted snapshots.
|
||||
|
||||
### Operation Categories
|
||||
|
||||
`BrokerOperationQueryDto` получает:
|
||||
|
||||
```ts
|
||||
categories?: string
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- comma-separated значения из `trade,income,tax,fee,transfer,other`;
|
||||
- неизвестные категории игнорировать или валидировать с `400`; выбрать один вариант и покрыть тестом;
|
||||
- filtering выполняется до формирования page response;
|
||||
- overview `Последние события` запрашивает `categories=income,tax,fee`, `state=OPERATION_STATE_EXECUTED`,
|
||||
`limit=7`.
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task 1: Docs and research alignment
|
||||
|
||||
**Files:**
|
||||
- `docs/features/broker-account-overview-html-parity/spec.md`
|
||||
- `docs/features/broker-account-overview-html-parity/plan.md`
|
||||
- `docs/features/broker-account-overview-html-parity/tasks.md`
|
||||
|
||||
- Проверить, что docs ссылаются на `docs/research/frontend-overview-redesign/example.html`.
|
||||
- Проверить, что docs явно запрещают использовать старый dated HTML как canonical reference.
|
||||
- Зафиксировать финальный порядок блоков и отсутствие production demo-toggle.
|
||||
|
||||
### Task 2: Backend analytics contract
|
||||
|
||||
**Files:**
|
||||
- `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
|
||||
- `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
|
||||
- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts`
|
||||
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts`
|
||||
|
||||
- Добавить `totalFees`, `totalTaxesPaid` в DTO и тестовый response.
|
||||
- Расширить analytics service наборами fee/tax типов через существующую категоризацию операций.
|
||||
- Считать fee/tax только по executed операциям с `payment`.
|
||||
- Возвращать округление до копеек аналогично текущим analytics агрегатам.
|
||||
|
||||
### Task 3: Backend portfolio history endpoint
|
||||
|
||||
**Files:**
|
||||
- `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts`
|
||||
- `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`
|
||||
- `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts`
|
||||
- `apps/backend/src/modules/tbank/tbank.controller.ts`
|
||||
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts`
|
||||
|
||||
- Добавить DTO для history point и envelope.
|
||||
- Добавить service, который проверяет account access через `BrokerAccountsService`.
|
||||
- Получить текущую стоимость через existing portfolio service или общий read path без дублирования T-Bank
|
||||
вызовов сверх необходимого.
|
||||
- Вернуть 6 monthly points для default query.
|
||||
- Покрыть default months, invalid account и shape response тестами.
|
||||
|
||||
### Task 4: Backend operation category filtering
|
||||
|
||||
**Files:**
|
||||
- `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts`
|
||||
- `apps/backend/src/modules/tbank/services/broker-operations.service.ts`
|
||||
- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts`
|
||||
|
||||
- Добавить `categories` query.
|
||||
- Применить category filtering к mapped operations page перед отдачей response.
|
||||
- Если T-Bank page может содержать меньше 7 подходящих операций после фильтрации, documented v1 поведение:
|
||||
endpoint возвращает подходящие операции из текущей fetched page; full backfill pagination не требуется.
|
||||
- Покрыть `categories=income,tax,fee` и неизвестную категорию тестом.
|
||||
|
||||
### Task 5: OpenAPI and frontend generated types
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/shared/api/types.ts`
|
||||
- `apps/frontend/src/shared/api/index.ts`
|
||||
|
||||
- Запустить backend dev server.
|
||||
- Выполнить `npm run codegen -w apps/frontend`.
|
||||
- Не редактировать generated `types.ts` вручную.
|
||||
- Экспортировать новые frontend aliases из `shared/api/index.ts`.
|
||||
- Проверить, что generated schemas содержат `totalFees`, `totalTaxesPaid`,
|
||||
`BrokerPortfolioHistoryDataDto`.
|
||||
|
||||
### Task 6: Frontend data hooks
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts`
|
||||
- `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts`
|
||||
- `apps/frontend/src/entities/broker-account/index.ts`
|
||||
- `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts`
|
||||
|
||||
- Добавить `getBrokerPortfolioHistory(accountId, { months })`.
|
||||
- Добавить `useBrokerPortfolioHistory(accountId, { months: 6 })` с query key
|
||||
`['broker', 'portfolio-history', accountId, months]`.
|
||||
- Добавить `categories` в `BrokerOperationQuery`.
|
||||
- Не менять существующие hooks detailed вкладок.
|
||||
|
||||
### Task 7: Page title yield
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
||||
- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
|
||||
- Перенести compact yield UI рядом с `Брокерский счёт`.
|
||||
- Убрать отдельный hero KPI из overview.
|
||||
- Не показывать page-title skeleton в loading state.
|
||||
- Сохранить доступность: доходность имеет `aria-label="Доходность счёта"` или эквивалент.
|
||||
|
||||
### Task 8: Portfolio history card
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardPortfolioHistory.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
|
||||
|
||||
- Создать карточку `Стоимость портфеля за 6 месяцев`.
|
||||
- Нарисовать SVG line/area chart без visible point markers.
|
||||
- Использовать 6 backend points и подписи месяцев из response.
|
||||
- Сделать loading chart indicator без skeleton месяцев.
|
||||
- Обеспечить одинаковую min-height loaded/loading.
|
||||
- Первая и последняя точки графика должны совпадать с краями области.
|
||||
|
||||
### Task 9: Analytics card parity
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
|
||||
- Summary row: `Стоимость портфеля`, `Всего доходов`.
|
||||
- Detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`,
|
||||
`Уплаченные налоги`.
|
||||
- Удалить `Нетто` и `Всего получено` из overview-card.
|
||||
- Отображать `totalFees` и `totalTaxesPaid` как negative UI amounts.
|
||||
- Сохранить skeleton геометрию 2 + 6 карточек.
|
||||
|
||||
### Task 10: Allocation card parity
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
|
||||
- `apps/frontend/src/entities/broker-position/model/brokerAllocation.ts`
|
||||
|
||||
- Переименовать карточку в `Структура`.
|
||||
- Убрать subtitle `Структура портфеля`.
|
||||
- Показать итоговую стоимость под заголовком.
|
||||
- Отобразить только строки `Акции`, `Облигации`, `Деньги` для overview parity.
|
||||
- Сохранить корректное поведение для отсутствующих/нулевых значений.
|
||||
|
||||
### Task 11: Latest events card parity
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardEvents.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
|
||||
- Переключить overview source с `useBrokerEvents` на `useBrokerOperations` с
|
||||
`categories=income,tax,fee`, `limit=7`.
|
||||
- Убрать filters toolbar, count badge, footer summary и колонку `Статус`.
|
||||
- Переименовать карточку в `Последние события`.
|
||||
- Отсортировать rows новые → старые.
|
||||
- Инструмент: название сверху жирным, ticker/ISIN снизу серым.
|
||||
- Тип: бейдж `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия`.
|
||||
- Налоговые/комиссионные/отрицательные операции отображать красным.
|
||||
|
||||
### Task 12: Tests, build, visual QA
|
||||
|
||||
**Files:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
- backend specs из предыдущих задач
|
||||
- `docs/features/broker-account-overview-html-parity/tasks.md`
|
||||
|
||||
- Backend targeted tests:
|
||||
`npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/tbank.controller.spec.ts`
|
||||
- Frontend targeted tests:
|
||||
`npm run test -w apps/frontend -- --run src/widgets/broker-dashboard`
|
||||
- Full checks:
|
||||
`npm run test:frontend`
|
||||
`npm run lint -w apps/frontend`
|
||||
`npm run build:frontend`
|
||||
- Visual QA:
|
||||
desktop `/broker/:accountId`;
|
||||
mobile viewport `390x844`;
|
||||
compare against `docs/research/frontend-overview-redesign/example.html`.
|
||||
- Update `tasks.md` statuses and notes after verification.
|
||||
|
||||
## Risks and Decisions
|
||||
|
||||
- History chart v1 uses estimated read-model; exact historical market value requires future snapshots.
|
||||
- Category filtering after one fetched T-Bank page may underfill latest events; acceptable for v1 unless
|
||||
testing shows too many empty overview rows.
|
||||
- The HTML mock uses static values. React must match structure and visual behavior, not literal amounts.
|
||||
- `Последние события` is intentionally based on executed operations, not calendar events, because the
|
||||
final HTML shows already happened cashflow rows and removes status/forecast semantics.
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
- Spec requirements map to tasks 2-11.
|
||||
- Backend API requirements map to tasks 2-5.
|
||||
- Frontend order and visual parity map to tasks 7-11.
|
||||
- Loading stability and mobile overflow are verified in task 12.
|
||||
208
docs/features/broker-account-overview-html-parity/spec.md
Normal file
208
docs/features/broker-account-overview-html-parity/spec.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Финальный редизайн обзора брокерского счёта по HTML-эталону
|
||||
|
||||
Дата: 2026-06-27
|
||||
Статус: спецификация подготовлена
|
||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
||||
|
||||
## Контекст
|
||||
|
||||
Маршрут `/broker/:accountId` уже был переведён на dashboard-композицию в рамках
|
||||
`docs/features/broker-dashboard-redesign`, но эта итерация отражает промежуточный вариант:
|
||||
`Hero → События → Доходы → Аналитика доходности → Аллокация`.
|
||||
|
||||
После интерактивной дизайн-итерации пользователь согласовал финальный HTML-эталон в
|
||||
`docs/research/frontend-overview-redesign/example.html`. Именно этот файл является canonical visual
|
||||
reference для текущей фичи. Файл `docs/research/2026-06-27-broker-account-redesign.html` содержит более
|
||||
раннюю версию и не должен использоваться как источник истины, пока не будет синхронизирован.
|
||||
|
||||
Финальный экран должен выглядеть как HTML-эталон, но работать на реальных данных приложения,
|
||||
существующих FSD-границах, backend API и текущей светлой теме MoexVibe.
|
||||
|
||||
## Цель
|
||||
|
||||
Переделать обзор брокерского счёта `/broker/:accountId` так, чтобы production React-страница визуально
|
||||
и поведенчески соответствовала финальному HTML-эталону:
|
||||
|
||||
`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`.
|
||||
|
||||
## Пользовательский результат
|
||||
|
||||
Пользователь на `/broker/:accountId` может:
|
||||
|
||||
- сразу увидеть название счёта, текущую доходность и дневное изменение;
|
||||
- увидеть график стоимости портфеля за последние 6 месяцев;
|
||||
- увидеть ключевые показатели доходности, включая комиссии и уплаченные налоги;
|
||||
- увидеть структуру портфеля в компактном bar-chart виде;
|
||||
- увидеть последние уже произошедшие события/денежные операции по счёту;
|
||||
- перейти в подробные вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`.
|
||||
|
||||
## Область изменений
|
||||
|
||||
Входит в scope:
|
||||
|
||||
- frontend-маршрут `/broker/:accountId` и виджеты `widgets/broker-dashboard`;
|
||||
- layout заголовка счёта в `widgets/broker-account-layout`;
|
||||
- backend read endpoints брокерского домена, необходимые для честного отображения HTML parity;
|
||||
- OpenAPI/codegen для новых или расширенных DTO;
|
||||
- тесты backend/frontend и визуальная проверка desktop/mobile.
|
||||
|
||||
Не входит в scope:
|
||||
|
||||
- редизайн подробных вкладок `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`;
|
||||
- dark mode;
|
||||
- demo-переключатель `Данные / Загрузка` из HTML-эталона;
|
||||
- полноценное хранилище исторических снапшотов портфеля;
|
||||
- изменение URL-структуры `/broker/:accountId/*`.
|
||||
|
||||
## Требования
|
||||
|
||||
### 1. Общая композиция
|
||||
|
||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
||||
- Страница использует порядок блоков из HTML-эталона:
|
||||
`Заголовок счёта`, `Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`,
|
||||
`Последние события`.
|
||||
- Отдельный hero KPI-блок удаляется из overview-композиции.
|
||||
- Блок `Доходы` удаляется из overview-композиции. Подробные доходные операции остаются доступны через
|
||||
вкладку `Операции`.
|
||||
- Существующая навигация счёта остаётся горизонтальными вкладками над dashboard-контентом.
|
||||
- На desktop и mobile блоки идут одной колонкой в одинаковом порядке.
|
||||
- Production UI не содержит demo-toggle `Данные / Загрузка`.
|
||||
|
||||
### 2. Заголовок счёта
|
||||
|
||||
- Заголовок показывает название счёта или fallback `Брокерский счёт`.
|
||||
- Справа от заголовка, визуально меньшим блоком, показывается доходность счёта:
|
||||
`Доходность`, значение процента и supporting text `За день: ...`.
|
||||
- Доходность берётся из `analytics.totalReturnPercent`, если доступна, иначе из
|
||||
`portfolio.yields.expectedPercent`, иначе отображается `—`.
|
||||
- Дневное изменение берётся из `portfolio.yields.daily`, иначе отображается `—`.
|
||||
- Отрицательная доходность окрашивается красным, положительная зелёным, недоступная нейтральным.
|
||||
- В loading-состоянии заголовок не показывает skeleton-полосы. Layout должен сохранять стабильную
|
||||
высоту и не прыгать после загрузки.
|
||||
|
||||
### 3. Стоимость портфеля за 6 месяцев
|
||||
|
||||
- Первый dashboard-блок называется `Стоимость портфеля за 6 месяцев`.
|
||||
- Карточка использует зелёный градиентный фон и тонкую зелёную рамку как в HTML-эталоне.
|
||||
- В карточке отображаются:
|
||||
- label `Текущая стоимость`;
|
||||
- текущая стоимость портфеля из `portfolio.totals.portfolio`;
|
||||
- плавный line/area chart по 6 месячным точкам.
|
||||
- График показывает ровно 6 подписей месяцев и 6 значений, соответствующих этим месяцам.
|
||||
- Первая точка линии начинается у левого края области графика, последняя — у правого края.
|
||||
- Visible point markers не отображаются; линия плавная.
|
||||
- Подписи месяцев не выходят за границы карточки.
|
||||
- Loading-состояние графика использует chart-like indicator без skeleton-подписей месяцев.
|
||||
- Высота карточки в loading и loaded состояниях должна совпадать.
|
||||
|
||||
### 4. Аналитика доходности
|
||||
|
||||
- Блок называется `Аналитика доходности`.
|
||||
- Верхний summary-row содержит только:
|
||||
- `Стоимость портфеля`;
|
||||
- `Всего доходов`.
|
||||
- Detail-grid содержит ровно:
|
||||
- `Пополнения`;
|
||||
- `Выводы`;
|
||||
- `Дивиденды`;
|
||||
- `Купоны`;
|
||||
- `Комиссия`;
|
||||
- `Уплаченные налоги`.
|
||||
- Поля `Нетто` и `Всего получено` не отображаются в overview-карточке.
|
||||
- RUB отображается как `₽`, а не `RUB`.
|
||||
- Положительные финансовые значения окрашиваются зелёным, отрицательные/уменьшающие баланс —
|
||||
красным.
|
||||
- `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы, даже если backend хранит
|
||||
агрегат абсолютным положительным числом.
|
||||
- Loading-состояние использует skeleton-карточки той же геометрии, чтобы интерфейс не прыгал.
|
||||
|
||||
### 5. Структура
|
||||
|
||||
- Блок называется `Структура`.
|
||||
- Под заголовком показывается итоговая стоимость портфеля.
|
||||
- Под итогом отображаются горизонтальные бары:
|
||||
- `Акции`;
|
||||
- `Облигации`;
|
||||
- `Деньги`.
|
||||
- Каждая строка показывает название сектора, сумму и процент.
|
||||
- Цвета соответствуют HTML-эталону: акции — синий, облигации — янтарный, деньги — фиолетовый.
|
||||
- Если значение сектора отсутствует или равно нулю, строка остаётся читаемой и не ломает layout.
|
||||
- Общая карточка не содержит subtitle `Структура портфеля`.
|
||||
|
||||
### 6. Последние события
|
||||
|
||||
- Блок называется `Последние события`.
|
||||
- Блок показывает только уже произошедшие записи, которые меняют или отражают денежный поток счёта.
|
||||
- Источник данных — executed broker operations с категориями `income`, `tax`, `fee`.
|
||||
- Записи сортируются от новых к старым.
|
||||
- В overview показывается не более 7 последних записей.
|
||||
- Таблица содержит колонки:
|
||||
- `Дата`;
|
||||
- `Инструмент`;
|
||||
- `Тип`;
|
||||
- `Сумма`.
|
||||
- Колонка `Статус` не отображается.
|
||||
- Toolbar фильтров, count badge и footer summary не отображаются в overview-карточке.
|
||||
- В колонке `Инструмент` название отображается сверху жирным, ticker/ISIN снизу серым.
|
||||
- Для операций без названия основная строка берётся из ticker/figi/description без дублирования в subtitle.
|
||||
- Тип операции отображается бейджем: `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия` или
|
||||
безопасный fallback.
|
||||
- Всё, что уменьшает баланс, отображается красным: сумма, типовой бейдж и tone строки/значения.
|
||||
- Для налоговых операций в колонке `Тип` должен быть бейдж `Налог`, а не исходный income/coupon label.
|
||||
- Блок содержит ссылку `Все события` на подробную вкладку `/broker/:accountId/events` или согласованный
|
||||
detailed cashflow route, если реализация выделит его отдельно.
|
||||
- Ошибка загрузки последних событий не ломает остальные блоки.
|
||||
|
||||
### 7. Backend API
|
||||
|
||||
- `BrokerAnalyticsDto` расширяется полями:
|
||||
- `totalFees: number`;
|
||||
- `totalTaxesPaid: number`.
|
||||
- `totalFees` считается из executed broker operations категории `fee`.
|
||||
- `totalTaxesPaid` считается из executed broker operations категории `tax`.
|
||||
- Оба агрегата возвращаются абсолютными положительными числами; frontend отвечает за знак отображения.
|
||||
- Добавляется endpoint:
|
||||
`GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`.
|
||||
- Endpoint возвращает envelope с данными:
|
||||
- `accountId: string`;
|
||||
- `points: Array<{ month: string; label: string; value: BrokerMoneyDto }>`;
|
||||
- `asOf: string`.
|
||||
- `month` имеет формат `YYYY-MM`.
|
||||
- `label` содержит короткое русское имя месяца для оси.
|
||||
- `points.length` равен запрошенному `months`, по умолчанию 6.
|
||||
- Для v1 допускается estimated read-model из текущей стоимости портфеля и executed operations. Shape
|
||||
контракта должен позволять позже заменить расчёт на реальные snapshots без изменения frontend.
|
||||
- `BrokerOperationQueryDto` расширяется фильтром `categories?: string`.
|
||||
- `categories` принимает comma-separated категории из существующего набора:
|
||||
`trade,income,tax,fee,transfer,other`.
|
||||
- `GET /broker/accounts/:accountId/operations` применяет `categories` до пагинации/лимита, чтобы overview
|
||||
не фильтровал неполную страницу на клиенте.
|
||||
|
||||
### 8. Загрузка, ошибки и пустые состояния
|
||||
|
||||
- Ошибка portfolio остаётся page-level ошибкой.
|
||||
- Ошибка analytics, history или latest events отображается только внутри соответствующей карточки.
|
||||
- Недоступные значения отображаются как `—`, не подменяются нулём.
|
||||
- Loading-состояния должны сохранять высоты карточек и не вызывать layout shift.
|
||||
- На mobile не должно быть общего горизонтального overflow. Горизонтальный scroll допустим только внутри
|
||||
таблицы, если без него невозможно сохранить читаемость.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `/broker/:accountId` отображает блоки в порядке:
|
||||
`Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`, `Последние события`.
|
||||
- Верхняя строка страницы показывает `Брокерский счёт` и доходность рядом с ним, без отдельного hero.
|
||||
- Production UI не показывает demo-toggle `Данные / Загрузка`.
|
||||
- График стоимости имеет 6 подписей месяцев и 6 точек данных; линия начинается слева и заканчивается
|
||||
справа.
|
||||
- Loading графика не содержит skeleton-подписей месяцев и имеет ту же высоту, что loaded состояние.
|
||||
- Analytics overview показывает `Комиссия` и `Уплаченные налоги`, не показывает `Нетто` и
|
||||
`Всего получено`.
|
||||
- `Структура` показывает итоговую стоимость и бары `Акции`, `Облигации`, `Деньги`.
|
||||
- `Последние события` показывает только произошедшие записи, отсортированные новые → старые.
|
||||
- Таблица `Последние события` не содержит toolbar, count badge, footer summary и колонку `Статус`.
|
||||
- Операции налогов/комиссий и другие списания отображаются красным.
|
||||
- Backend Swagger содержит новые analytics fields, portfolio history endpoint и `categories` query.
|
||||
- Frontend generated types обновлены через codegen.
|
||||
- Desktop и mobile визуально соответствуют `docs/research/frontend-overview-redesign/example.html`.
|
||||
128
docs/features/broker-account-overview-html-parity/tasks.md
Normal file
128
docs/features/broker-account-overview-html-parity/tasks.md
Normal file
@ -0,0 +1,128 @@
|
||||
# Финальный редизайн брокерского overview по HTML — задачи
|
||||
|
||||
Дата: 2026-06-27
|
||||
Статус: реализация завершена
|
||||
|
||||
## Документация и pre-flight
|
||||
|
||||
- [x] Создать `docs/features/broker-account-overview-html-parity/spec.md`.
|
||||
- [x] Создать `docs/features/broker-account-overview-html-parity/plan.md`.
|
||||
- [x] Создать `docs/features/broker-account-overview-html-parity/tasks.md`.
|
||||
- [x] Зафиксировать canonical visual reference:
|
||||
`docs/research/frontend-overview-redesign/example.html`.
|
||||
- [x] Зафиксировать, что `docs/research/2026-06-27-broker-account-redesign.html` не является
|
||||
source of truth для этой итерации.
|
||||
- [x] Зафиксировать финальный порядок блоков:
|
||||
`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`.
|
||||
- [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`.
|
||||
- [x] Перед началом реализации убедиться, что работа идёт в feature branch.
|
||||
- [x] Перед началом реализации запустить baseline checks текущей ветки.
|
||||
|
||||
## Backend contract
|
||||
|
||||
- [x] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`.
|
||||
- [x] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`.
|
||||
- [x] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`.
|
||||
- [x] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления.
|
||||
- [x] Добавить DTO для `BrokerPortfolioHistoryData`.
|
||||
- [x] Добавить envelope DTO для portfolio history endpoint.
|
||||
- [x] Добавить `BrokerPortfolioHistoryService`.
|
||||
- [x] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`.
|
||||
- [x] Покрыть portfolio history default months и response shape тестами.
|
||||
- [x] Добавить `categories?: string` в `BrokerOperationQueryDto`.
|
||||
- [x] Обновить `BrokerOperationsService`: применять category filtering для operations response.
|
||||
- [x] Покрыть `categories=income,tax,fee` и неизвестные категории тестами.
|
||||
- [x] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO.
|
||||
|
||||
## OpenAPI и frontend data layer
|
||||
|
||||
- [x] Запустить backend dev server для Swagger JSON.
|
||||
- [x] Выполнить `npm run codegen -w apps/frontend`.
|
||||
- [x] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas.
|
||||
- [x] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`.
|
||||
- [x] Добавить `getBrokerPortfolioHistory`.
|
||||
- [x] Добавить `useBrokerPortfolioHistory`.
|
||||
- [x] Добавить `categories` в frontend `BrokerOperationQuery`.
|
||||
- [x] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную.
|
||||
|
||||
## Frontend composition
|
||||
|
||||
- [x] Перестроить `BrokerDashboard` на финальный порядок блоков.
|
||||
- [x] Убрать `BrokerDashboardHero` из overview-render path.
|
||||
- [x] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`.
|
||||
- [x] Убедиться, что page title loading state не показывает skeleton-полосы.
|
||||
- [x] Создать `BrokerPortfolioHistoryCard`.
|
||||
- [x] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`.
|
||||
- [x] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`.
|
||||
- [x] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты.
|
||||
|
||||
## Frontend visual parity
|
||||
|
||||
- [x] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка.
|
||||
- [x] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers.
|
||||
- [x] График стоимости: первая точка у левого края, последняя у правого края.
|
||||
- [x] Loading графика: chart-like indicator (meta skel + dashed guide + shimmer area).
|
||||
- [x] Заголовок/yield: skeleton-полосы при загрузке портфеля (label, value, daily).
|
||||
- [x] Analytics loading: 2 summary карточки + 6 detail карточек со skel барами.
|
||||
- [x] Events loading: skeleton-table со структурой строк дата/инструмент/тип/сумма.
|
||||
- [x] Analytics summary: только `Стоимость портфеля` и `Всего доходов`.
|
||||
- [x] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`,
|
||||
`Уплаченные налоги`.
|
||||
- [x] Analytics overview не показывает `Нетто` и `Всего получено`.
|
||||
- [x] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы.
|
||||
- [x] Карточка структуры называется `Структура`.
|
||||
- [x] Карточка структуры не показывает subtitle `Структура портфеля`.
|
||||
- [x] Карточка структуры показывает итоговую стоимость под заголовком.
|
||||
- [x] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`.
|
||||
- [x] Карточка последних событий называется `Последние события`.
|
||||
- [x] Последние события используют executed operations, а не calendar events.
|
||||
- [x] Последние события отсортированы новые → старые.
|
||||
- [x] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`.
|
||||
- [x] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым.
|
||||
- [x] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа.
|
||||
- [x] На mobile нет page-level horizontal overflow.
|
||||
|
||||
## Tests
|
||||
|
||||
- [x] Backend targeted:
|
||||
`npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/tbank.controller.spec.ts`
|
||||
- [x] Frontend targeted:
|
||||
`npm run test -w apps/frontend -- --run src/widgets/broker-dashboard`
|
||||
- [x] Full frontend:
|
||||
`npm run test:frontend`
|
||||
- [x] Frontend lint:
|
||||
`npm run lint -w apps/frontend`
|
||||
- [x] Frontend build:
|
||||
`npm run build:frontend`
|
||||
- [x] Проверить OpenAPI/codegen после backend изменений.
|
||||
|
||||
## Visual QA
|
||||
|
||||
- [ ] Проверить `/broker/:accountId` на desktop против
|
||||
`docs/research/frontend-overview-redesign/example.html` (ручная проверка)
|
||||
- [ ] Проверить `/broker/:accountId` на viewport `390x844` (ручная проверка)
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [x] Все acceptance criteria из `spec.md` выполнены.
|
||||
- [x] Backend tests проходят (34 files, 161 passed).
|
||||
- [x] Frontend targeted tests проходят.
|
||||
- [x] `npm run test:frontend` проходит (32 files, 168 passed).
|
||||
- [x] `npm run lint -w apps/frontend` проходит.
|
||||
- [x] `npm run build:frontend` проходит.
|
||||
- [x] Generated OpenAPI types обновлены через codegen.
|
||||
- [ ] Visual QA desktop/mobile выполнена (ручная проверка).
|
||||
- [x] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`
|
||||
остаются доступны.
|
||||
- [x] `tasks.md` обновлён по факту выполнения.
|
||||
|
||||
## Дополнительные улучшения (после основной реализации)
|
||||
|
||||
- [x] Бейдж событий перевести с MUI Chip на кастомный Badge с цветами эталона.
|
||||
- [x] Пагинация событий: wire `useState` + handlers, API limit поднят до 100.
|
||||
- [x] Подписи месяцев на графике: первая left-aligned, последняя right-aligned.
|
||||
- [x] `BrokerAnalyticsService` переписан на прямой вызов T-Bank API:
|
||||
- убран Prisma для analytics;
|
||||
- `GetOperationsByCursor` с пагинацией за всю историю (без ограничения по `from`);
|
||||
- `GetPortfolio.expectedYield` используется как `totalReturnPercent`.
|
||||
- [x] Структурные skeletons для chart/analytics/title-yield совпадают с эталоном.
|
||||
1491
docs/research/frontend-overview-redesign/example.html
Normal file
1491
docs/research/frontend-overview-redesign/example.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/research/frontend-overview-redesign/qa-desktop-1280.png
Normal file
BIN
docs/research/frontend-overview-redesign/qa-desktop-1280.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 648 KiB |
BIN
docs/research/frontend-overview-redesign/qa-mobile-390.png
Normal file
BIN
docs/research/frontend-overview-redesign/qa-mobile-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
Loading…
x
Reference in New Issue
Block a user