codex/broker-operations-ui #18
@ -9,6 +9,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
|||||||
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||||
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||||
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||||
|
- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
|
||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export default registerAs('app', () => ({
|
|||||||
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
|
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
|
||||||
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
|
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
|
||||||
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
|
tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10),
|
||||||
|
tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10),
|
||||||
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
|
||||||
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||||
|
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
|
|
||||||
export class BrokerResponseMetaDto {
|
export class BrokerResponseMetaDto {
|
||||||
@ -36,6 +37,14 @@ export class BrokerOperationsEnvelopeDto {
|
|||||||
meta!: BrokerResponseMetaDto;
|
meta!: BrokerResponseMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerPositionsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||||||
|
data!: BrokerPositionsPageResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
|
meta!: BrokerResponseMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
export class BrokerOperationSyncEnvelopeDto {
|
export class BrokerOperationSyncEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||||
data!: BrokerOperationSyncResponseDto;
|
data!: BrokerOperationSyncResponseDto;
|
||||||
|
|||||||
@ -28,6 +28,9 @@ export class BrokerOperationResponseDto {
|
|||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
description!: string | null;
|
description!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
state!: string | null;
|
state!: string | null;
|
||||||
|
|
||||||
|
|||||||
@ -2,50 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
import { BrokerMoneyDto } from './broker-money.dto';
|
import { BrokerMoneyDto } from './broker-money.dto';
|
||||||
|
|
||||||
export class BrokerPositionResponseDto {
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
figi!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
instrumentUid!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
positionUid!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
ticker!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
classCode!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
instrumentType!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
name!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
quantity!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
blockedLots!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
|
||||||
currentPrice!: BrokerMoneyDto | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
|
||||||
currentValue!: BrokerMoneyDto | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
|
||||||
averagePositionPrice!: BrokerMoneyDto | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
expectedYieldPercent!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
|
||||||
dailyYield!: BrokerMoneyDto | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BrokerPortfolioTotalsDto {
|
export class BrokerPortfolioTotalsDto {
|
||||||
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||||
shares!: BrokerMoneyDto | null;
|
shares!: BrokerMoneyDto | null;
|
||||||
@ -102,9 +58,6 @@ export class BrokerPortfolioResponseDto {
|
|||||||
@ApiProperty({ type: [BrokerMoneyDto] })
|
@ApiProperty({ type: [BrokerMoneyDto] })
|
||||||
blockedCash!: BrokerMoneyDto[];
|
blockedCash!: BrokerMoneyDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [BrokerPositionResponseDto] })
|
|
||||||
positions!: BrokerPositionResponseDto[];
|
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
asOf!: string;
|
asOf!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class BrokerPositionQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Cursor for pagination (positionUid)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cursor?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { BrokerMoneyDto } from './broker-money.dto';
|
||||||
|
|
||||||
|
export class BrokerPositionResponseDto {
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
figi!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
instrumentUid!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
positionUid!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
ticker!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
classCode!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
instrumentType!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
quantity!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
blockedLots!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||||
|
currentPrice!: BrokerMoneyDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||||
|
currentValue!: BrokerMoneyDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||||
|
averagePositionPrice!: BrokerMoneyDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
expectedYieldPercent!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
|
||||||
|
dailyYield!: BrokerMoneyDto | null;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { BrokerPositionResponseDto } from './broker-position-response.dto';
|
||||||
|
|
||||||
|
export class BrokerPositionsPageResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
accountId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [BrokerPositionResponseDto] })
|
||||||
|
items!: BrokerPositionResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
nextCursor!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
hasNext!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
asOf!: string;
|
||||||
|
}
|
||||||
@ -108,6 +108,7 @@ export function mapOperation(item: TBankOperationItem, accountId: string): Broke
|
|||||||
type,
|
type,
|
||||||
category: categorizeOperationType(type),
|
category: categorizeOperationType(type),
|
||||||
description: item.description || item.name || null,
|
description: item.description || item.name || null,
|
||||||
|
name: item.name ?? null,
|
||||||
state: item.state ?? null,
|
state: item.state ?? null,
|
||||||
instrumentUid: item.instrumentUid ?? null,
|
instrumentUid: item.instrumentUid ?? null,
|
||||||
figi: item.figi ?? null,
|
figi: item.figi ?? null,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ describe('portfolio.mapper', () => {
|
|||||||
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
|
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
|
||||||
};
|
};
|
||||||
|
|
||||||
it('combines portfolio totals, cash, and enriched positions', () => {
|
it('combines portfolio totals and cash (positions removed)', () => {
|
||||||
const result = mapBrokerPortfolio({
|
const result = mapBrokerPortfolio({
|
||||||
account,
|
account,
|
||||||
portfolio: {
|
portfolio: {
|
||||||
@ -44,11 +44,5 @@ describe('portfolio.mapper', () => {
|
|||||||
expect(result.totals.shares?.value).toBe(1000);
|
expect(result.totals.shares?.value).toBe(1000);
|
||||||
expect(result.cash[0].value).toBe(500);
|
expect(result.cash[0].value).toBe(500);
|
||||||
expect(result.blockedCash[0].value).toBe(10);
|
expect(result.blockedCash[0].value).toBe(10);
|
||||||
expect(result.positions[0]).toMatchObject({
|
|
||||||
ticker: 'SBER',
|
|
||||||
name: 'Sberbank',
|
|
||||||
quantity: 10,
|
|
||||||
currentValue: { value: 2500 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type {
|
|||||||
BrokerMoney,
|
BrokerMoney,
|
||||||
BrokerPortfolio,
|
BrokerPortfolio,
|
||||||
BrokerPosition,
|
BrokerPosition,
|
||||||
|
BrokerPositionsPage,
|
||||||
} from '../types/broker.types';
|
} from '../types/broker.types';
|
||||||
import type {
|
import type {
|
||||||
TBankInstrument,
|
TBankInstrument,
|
||||||
@ -22,10 +23,25 @@ function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
|
|||||||
return value !== null;
|
return value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
|
export function mapBrokerPosition(input: {
|
||||||
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
|
position: {
|
||||||
const quantity = mapQuotationToNumber(position.quantity);
|
figi?: string;
|
||||||
const currentPrice = mapMoneyValue(position.currentPrice);
|
instrumentUid?: string;
|
||||||
|
positionUid?: string;
|
||||||
|
ticker?: string;
|
||||||
|
classCode?: string;
|
||||||
|
instrumentType?: string;
|
||||||
|
quantity?: { units?: string | number; nano?: number };
|
||||||
|
blockedLots?: { units?: string | number; nano?: number };
|
||||||
|
currentPrice?: { currency?: string; units?: string | number; nano?: number };
|
||||||
|
averagePositionPrice?: { currency?: string; units?: string | number; nano?: number };
|
||||||
|
expectedYield?: { units?: string | number; nano?: number };
|
||||||
|
dailyYield?: { currency?: string; units?: string | number; nano?: number };
|
||||||
|
};
|
||||||
|
instruments: Map<string, Partial<TBankInstrument>>;
|
||||||
|
}): BrokerPosition {
|
||||||
|
const quantity = mapQuotationToNumber(input.position.quantity);
|
||||||
|
const currentPrice = mapMoneyValue(input.position.currentPrice);
|
||||||
const currentValue =
|
const currentValue =
|
||||||
currentPrice && quantity !== null
|
currentPrice && quantity !== null
|
||||||
? {
|
? {
|
||||||
@ -36,28 +52,29 @@ export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfo
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const instrument =
|
const instrument =
|
||||||
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
|
(input.position.instrumentUid && input.instruments.get(input.position.instrumentUid)) ||
|
||||||
(position.positionUid && input.instruments.get(position.positionUid)) ||
|
(input.position.positionUid && input.instruments.get(input.position.positionUid)) ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
figi: position.figi ?? null,
|
figi: input.position.figi ?? null,
|
||||||
instrumentUid: position.instrumentUid ?? null,
|
instrumentUid: input.position.instrumentUid ?? null,
|
||||||
positionUid: position.positionUid ?? null,
|
positionUid: input.position.positionUid ?? null,
|
||||||
ticker: position.ticker || instrument?.ticker || null,
|
ticker: input.position.ticker || instrument?.ticker || null,
|
||||||
classCode: position.classCode || instrument?.classCode || null,
|
classCode: input.position.classCode || instrument?.classCode || null,
|
||||||
instrumentType: position.instrumentType || instrument?.instrumentType || null,
|
instrumentType: input.position.instrumentType || instrument?.instrumentType || null,
|
||||||
name: instrument?.name ?? null,
|
name: instrument?.name ?? null,
|
||||||
quantity,
|
quantity,
|
||||||
blockedLots: mapQuotationToNumber(position.blockedLots),
|
blockedLots: mapQuotationToNumber(input.position.blockedLots),
|
||||||
currentPrice,
|
currentPrice,
|
||||||
currentValue,
|
currentValue,
|
||||||
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
|
averagePositionPrice: mapMoneyValue(input.position.averagePositionPrice),
|
||||||
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
|
expectedYieldPercent: mapQuotationToNumber(input.position.expectedYield),
|
||||||
dailyYield: mapMoneyValue(position.dailyYield),
|
dailyYield: mapMoneyValue(input.position.dailyYield),
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
|
||||||
return {
|
return {
|
||||||
account: input.account,
|
account: input.account,
|
||||||
totals: {
|
totals: {
|
||||||
@ -78,7 +95,36 @@ export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfo
|
|||||||
},
|
},
|
||||||
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||||||
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
|
||||||
positions: mappedPositions,
|
asOf: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBrokerPositionsPage(input: {
|
||||||
|
accountId: string;
|
||||||
|
portfolio: TBankPortfolioResponse;
|
||||||
|
instruments: Map<string, Partial<TBankInstrument>>;
|
||||||
|
cursor?: string;
|
||||||
|
limit: number;
|
||||||
|
}): BrokerPositionsPage {
|
||||||
|
const allPositions = (input.portfolio.positions ?? []).map((position) =>
|
||||||
|
mapBrokerPosition({ position, instruments: input.instruments }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let startIndex = 0;
|
||||||
|
if (input.cursor) {
|
||||||
|
const found = allPositions.findIndex((p) => p.positionUid === input.cursor);
|
||||||
|
startIndex = found >= 0 ? found + 1 : allPositions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageItems = allPositions.slice(startIndex, startIndex + input.limit);
|
||||||
|
const hasNext = startIndex + input.limit < allPositions.length;
|
||||||
|
const nextCursor = hasNext ? (pageItems[pageItems.length - 1]?.positionUid ?? null) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: input.accountId,
|
||||||
|
items: pageItems,
|
||||||
|
nextCursor,
|
||||||
|
hasNext,
|
||||||
asOf: new Date().toISOString(),
|
asOf: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
category: 'trade',
|
category: 'trade',
|
||||||
description: null,
|
description: null,
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
name: null,
|
||||||
instrumentUid: 'uid-1',
|
instrumentUid: 'uid-1',
|
||||||
figi: null,
|
figi: null,
|
||||||
ticker: 'SBER',
|
ticker: 'SBER',
|
||||||
@ -98,6 +99,7 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
category: 'trade',
|
category: 'trade',
|
||||||
description: null,
|
description: null,
|
||||||
state: null,
|
state: null,
|
||||||
|
name: null,
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
figi: null,
|
figi: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
|
|||||||
@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
|
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches portfolio and positions through cache', async () => {
|
it('fetches portfolio through cache without positions', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue({
|
vi.mocked(accounts.findById).mockResolvedValue({
|
||||||
id: 'acc-1',
|
id: 'acc-1',
|
||||||
type: 'brokerage',
|
type: 'brokerage',
|
||||||
@ -60,6 +60,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
|
|
||||||
expect(result.data.account.id).toBe('acc-1');
|
expect(result.data.account.id).toBe('acc-1');
|
||||||
expect(result.data.cash[0].value).toBe(1000);
|
expect(result.data.cash[0].value).toBe(1000);
|
||||||
|
expect('positions' in result.data).toBe(false);
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
'tbank:portfolio',
|
'tbank:portfolio',
|
||||||
['acc-1'],
|
['acc-1'],
|
||||||
@ -68,7 +69,8 @@ describe('BrokerPortfolioService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns portfolio when one instrument enrichment request fails', async () => {
|
describe('getPositions', () => {
|
||||||
|
function mockAccount() {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue({
|
vi.mocked(accounts.findById).mockResolvedValue({
|
||||||
id: 'acc-1',
|
id: 'acc-1',
|
||||||
type: 'brokerage',
|
type: 'brokerage',
|
||||||
@ -77,6 +79,9 @@ describe('BrokerPortfolioService', () => {
|
|||||||
openedAt: null,
|
openedAt: null,
|
||||||
accessLevel: null,
|
accessLevel: null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockCache() {
|
||||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||||
data: await fetchFn(),
|
data: await fetchFn(),
|
||||||
@ -84,57 +89,217 @@ describe('BrokerPortfolioService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws 404 for missing account', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
|
||||||
|
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first page of positions', async () => {
|
||||||
|
mockAccount();
|
||||||
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
getPositions: vi.fn(),
|
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary)
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({
|
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
figi: 'figi-1',
|
figi: 'figi-1',
|
||||||
instrumentUid: 'uid-1',
|
instrumentUid: 'uid-1',
|
||||||
quantity: { units: '1', nano: 0 },
|
positionUid: 'pos-1',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
figi: 'figi-2',
|
figi: 'figi-2',
|
||||||
instrumentUid: 'uid-2',
|
instrumentUid: 'uid-2',
|
||||||
quantity: { units: '2', nano: 0 },
|
positionUid: 'pos-2',
|
||||||
|
quantity: { units: '20', nano: 0 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
accountId: 'acc-1',
|
|
||||||
money: [],
|
|
||||||
blocked: [],
|
|
||||||
securities: [],
|
|
||||||
});
|
});
|
||||||
vi.mocked(instruments.findByInstrumentUid)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
uid: 'uid-1',
|
|
||||||
figi: 'figi-1',
|
|
||||||
ticker: 'AAA',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
name: 'First share',
|
|
||||||
instrumentType: 'share',
|
|
||||||
})
|
|
||||||
.mockRejectedValueOnce(new Error('instrument lookup failed'));
|
|
||||||
|
|
||||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
const result = await service.getPortfolio('acc-1');
|
const result = await service.getPositions('acc-1', undefined, 1);
|
||||||
|
|
||||||
expect(result.data.positions).toHaveLength(2);
|
expect(result.data.accountId).toBe('acc-1');
|
||||||
expect(result.data.positions[0]).toMatchObject({
|
expect(result.data.items).toHaveLength(1);
|
||||||
instrumentUid: 'uid-1',
|
expect(result.data.items[0].positionUid).toBe('pos-1');
|
||||||
ticker: 'AAA',
|
expect(result.data.hasNext).toBe(true);
|
||||||
name: 'First share',
|
expect(result.data.nextCursor).toBe('pos-1');
|
||||||
});
|
});
|
||||||
expect(result.data.positions[1]).toMatchObject({
|
|
||||||
instrumentUid: 'uid-2',
|
it('paginates using cursor', async () => {
|
||||||
ticker: null,
|
mockAccount();
|
||||||
name: null,
|
mockCache();
|
||||||
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
|
getPortfolio: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'f1',
|
||||||
|
instrumentUid: 'u1',
|
||||||
|
positionUid: 'p1',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: 'f2',
|
||||||
|
instrumentUid: 'u2',
|
||||||
|
positionUid: 'p2',
|
||||||
|
quantity: { units: '20', nano: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: 'f3',
|
||||||
|
instrumentUid: 'u3',
|
||||||
|
positionUid: 'p3',
|
||||||
|
quantity: { units: '30', nano: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
const result = await service.getPositions('acc-1', 'p1', 1);
|
||||||
|
|
||||||
|
expect(result.data.items).toHaveLength(1);
|
||||||
|
expect(result.data.items[0].positionUid).toBe('p2');
|
||||||
|
expect(result.data.nextCursor).toBe('p2');
|
||||||
|
expect(result.data.hasNext).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns last page with hasNext=false', async () => {
|
||||||
|
mockAccount();
|
||||||
|
mockCache();
|
||||||
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
|
getPortfolio: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'f1',
|
||||||
|
instrumentUid: 'u1',
|
||||||
|
positionUid: 'p1',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
const result = await service.getPositions('acc-1', undefined, 10);
|
||||||
|
|
||||||
|
expect(result.data.items).toHaveLength(1);
|
||||||
|
expect(result.data.hasNext).toBe(false);
|
||||||
|
expect(result.data.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
||||||
|
mockAccount();
|
||||||
|
mockCache();
|
||||||
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
|
getPortfolio: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||||
|
positions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
await service.getPositions('acc-1', 'some-cursor', 5);
|
||||||
|
|
||||||
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
|
'tbank:positions',
|
||||||
|
['acc-1', 'some-cursor', '5', ''],
|
||||||
|
expect.any(Function),
|
||||||
|
'tbankPositionsTtl',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by instrument type and caches with type in key', async () => {
|
||||||
|
mockAccount();
|
||||||
|
mockCache();
|
||||||
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
|
getPortfolio: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'f1',
|
||||||
|
instrumentUid: 'u1',
|
||||||
|
positionUid: 'p1',
|
||||||
|
instrumentType: 'share',
|
||||||
|
ticker: 'SBER',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: 'f2',
|
||||||
|
instrumentUid: 'u2',
|
||||||
|
positionUid: 'p2',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
quantity: { units: '5', nano: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: 'f3',
|
||||||
|
instrumentUid: 'u3',
|
||||||
|
positionUid: 'p3',
|
||||||
|
instrumentType: 'share',
|
||||||
|
ticker: 'GAZP',
|
||||||
|
quantity: { units: '3', nano: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
const result = await service.getPositions('acc-1', undefined, 10, 'share');
|
||||||
|
|
||||||
|
expect(result.data.items).toHaveLength(2);
|
||||||
|
expect(result.data.items.map((i) => i.ticker)).toEqual(['SBER', 'GAZP']);
|
||||||
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
|
'tbank:positions',
|
||||||
|
['acc-1', '', '10', 'share'],
|
||||||
|
expect.any(Function),
|
||||||
|
'tbankPositionsTtl',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty items when type filter matches nothing', async () => {
|
||||||
|
mockAccount();
|
||||||
|
mockCache();
|
||||||
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
|
getPortfolio: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'f1',
|
||||||
|
instrumentUid: 'u1',
|
||||||
|
positionUid: 'p1',
|
||||||
|
instrumentType: 'share',
|
||||||
|
ticker: 'SBER',
|
||||||
|
quantity: { units: '10', nano: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
const result = await service.getPositions('acc-1', undefined, 10, 'etf');
|
||||||
|
|
||||||
|
expect(result.data.items).toHaveLength(0);
|
||||||
|
expect(result.data.hasNext).toBe(false);
|
||||||
|
expect(result.data.nextCursor).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
|
import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import type { BrokerPortfolio } from '../types/broker.types';
|
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
|
||||||
import type {
|
import type {
|
||||||
TBankInstrument,
|
TBankInstrument,
|
||||||
TBankPortfolioResponse,
|
TBankPortfolioResponse,
|
||||||
@ -62,6 +62,57 @@ export class BrokerPortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPositions(
|
||||||
|
accountId: string,
|
||||||
|
cursor?: string,
|
||||||
|
limit = 10,
|
||||||
|
type?: string,
|
||||||
|
): Promise<{
|
||||||
|
data: BrokerPositionsPage;
|
||||||
|
meta: { fromCache: boolean; cachedAt: string | null };
|
||||||
|
}> {
|
||||||
|
const account = await this.accountsService.findById(accountId);
|
||||||
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
|
const result = await this.cacheService.getOrFetch(
|
||||||
|
TBANK_CACHE_KEYS.positions,
|
||||||
|
[accountId, cursor ?? '', String(limit), type ?? ''],
|
||||||
|
async () => {
|
||||||
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
|
const portfolio = await this.tbankClient.callUnary<
|
||||||
|
{ accountId: string; currency: string },
|
||||||
|
TBankPortfolioResponse
|
||||||
|
>('OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), {
|
||||||
|
accountId,
|
||||||
|
currency: 'RUB',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPositions = type
|
||||||
|
? (portfolio.positions ?? []).filter(
|
||||||
|
(p) => p.instrumentType?.toLowerCase() === type.toLowerCase(),
|
||||||
|
)
|
||||||
|
: portfolio.positions;
|
||||||
|
|
||||||
|
const filteredPortfolio = { ...portfolio, positions: filteredPositions };
|
||||||
|
const instrumentMap = await this.buildInstrumentMap(filteredPortfolio);
|
||||||
|
|
||||||
|
return mapBrokerPositionsPage({
|
||||||
|
accountId,
|
||||||
|
portfolio: filteredPortfolio,
|
||||||
|
instruments: instrumentMap,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'tbankPositionsTtl',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data,
|
||||||
|
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async buildInstrumentMap(
|
private async buildInstrumentMap(
|
||||||
portfolio: TBankPortfolioResponse,
|
portfolio: TBankPortfolioResponse,
|
||||||
): Promise<Map<string, Partial<TBankInstrument>>> {
|
): Promise<Map<string, Partial<TBankInstrument>>> {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ describe('T-Bank configuration', () => {
|
|||||||
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
|
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
|
||||||
expect(config.tbank.rateLimitPerSecond).toBe(5);
|
expect(config.tbank.rateLimitPerSecond).toBe(5);
|
||||||
expect(config.cache.tbankPortfolioTtl).toBe(60);
|
expect(config.cache.tbankPortfolioTtl).toBe(60);
|
||||||
|
expect(config.cache.tbankPositionsTtl).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads T-Bank token and TTL overrides from environment', () => {
|
it('reads T-Bank token and TTL overrides from environment', () => {
|
||||||
@ -29,6 +30,7 @@ describe('T-Bank configuration', () => {
|
|||||||
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
|
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
|
||||||
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
||||||
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
||||||
|
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
|
||||||
|
|
||||||
const config = configuration();
|
const config = configuration();
|
||||||
|
|
||||||
@ -37,5 +39,6 @@ describe('T-Bank configuration', () => {
|
|||||||
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
|
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
|
||||||
expect(config.tbank.rateLimitPerSecond).toBe(2);
|
expect(config.tbank.rateLimitPerSecond).toBe(2);
|
||||||
expect(config.cache.tbankAccountsTtl).toBe(120);
|
expect(config.cache.tbankAccountsTtl).toBe(120);
|
||||||
|
expect(config.cache.tbankPositionsTtl).toBe(45);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import {
|
|||||||
BrokerOperationSyncEnvelopeDto,
|
BrokerOperationSyncEnvelopeDto,
|
||||||
BrokerOperationsEnvelopeDto,
|
BrokerOperationsEnvelopeDto,
|
||||||
BrokerPortfolioEnvelopeDto,
|
BrokerPortfolioEnvelopeDto,
|
||||||
|
BrokerPositionsEnvelopeDto,
|
||||||
} from './dto/broker-envelope.dto';
|
} from './dto/broker-envelope.dto';
|
||||||
|
import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
|
||||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
@ -43,6 +45,22 @@ export class TBankController {
|
|||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('accounts/:accountId/positions')
|
||||||
|
@ApiOperation({ summary: 'Get paginated T-Bank broker account positions' })
|
||||||
|
@ApiOkResponse({ type: BrokerPositionsEnvelopeDto })
|
||||||
|
async getPositions(
|
||||||
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: BrokerPositionQueryDto,
|
||||||
|
) {
|
||||||
|
const result = await this.brokerPortfolioService.getPositions(
|
||||||
|
accountId,
|
||||||
|
query.cursor,
|
||||||
|
query.limit,
|
||||||
|
query.type,
|
||||||
|
);
|
||||||
|
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/operations')
|
@Get('accounts/:accountId/operations')
|
||||||
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
|
@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' })
|
||||||
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerOperationsEnvelopeDto })
|
||||||
|
|||||||
@ -51,7 +51,14 @@ export type BrokerPortfolio = {
|
|||||||
};
|
};
|
||||||
cash: BrokerMoney[];
|
cash: BrokerMoney[];
|
||||||
blockedCash: BrokerMoney[];
|
blockedCash: BrokerMoney[];
|
||||||
positions: BrokerPosition[];
|
asOf: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrokerPositionsPage = {
|
||||||
|
accountId: string;
|
||||||
|
items: BrokerPosition[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasNext: boolean;
|
||||||
asOf: string;
|
asOf: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +73,7 @@ export type BrokerOperation = {
|
|||||||
type: string;
|
type: string;
|
||||||
category: BrokerOperationCategory;
|
category: BrokerOperationCategory;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
name: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
instrumentUid: string | null;
|
instrumentUid: string | null;
|
||||||
figi: string | null;
|
figi: string | null;
|
||||||
|
|||||||
@ -21,18 +21,18 @@
|
|||||||
"react-router-dom": "^6.20.0"
|
"react-router-dom": "^6.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
|
||||||
"eslint": "^8.0.0",
|
|
||||||
"eslint-plugin-react": "^7.34.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-plugin-react": "^7.34.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.14.6",
|
"msw": "^2.14.6",
|
||||||
"openapi-typescript": "^7.0.0",
|
"openapi-typescript": "^7.0.0",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getBrokerOperations } from './broker';
|
import { getBrokerOperations, getBrokerPositions } from './broker';
|
||||||
|
|
||||||
describe('broker api', () => {
|
describe('broker api', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -24,4 +24,23 @@ describe('broker api', () => {
|
|||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes positions query parameters', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
BrokerAccount,
|
BrokerAccount,
|
||||||
BrokerOperationsPage,
|
BrokerOperationsPage,
|
||||||
BrokerPortfolio,
|
BrokerPortfolio,
|
||||||
|
BrokerPositionsPage,
|
||||||
} from './responses';
|
} from './responses';
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
export type BrokerOperationQuery = {
|
||||||
@ -49,3 +50,17 @@ export function getBrokerOperations(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBrokerPositions(
|
||||||
|
accountId: string,
|
||||||
|
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||||
|
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
||||||
|
return request<BrokerPositionsPage>(
|
||||||
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
||||||
|
{
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
|
type: query.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -299,7 +299,6 @@ export interface BrokerPortfolio {
|
|||||||
};
|
};
|
||||||
cash: BrokerMoney[];
|
cash: BrokerMoney[];
|
||||||
blockedCash: BrokerMoney[];
|
blockedCash: BrokerMoney[];
|
||||||
positions: BrokerPosition[];
|
|
||||||
asOf: string;
|
asOf: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,6 +313,7 @@ export interface BrokerOperation {
|
|||||||
type: string;
|
type: string;
|
||||||
category: BrokerOperationCategory;
|
category: BrokerOperationCategory;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
name: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
instrumentUid: string | null;
|
instrumentUid: string | null;
|
||||||
figi: string | null;
|
figi: string | null;
|
||||||
@ -336,3 +336,11 @@ export interface BrokerOperationsPage {
|
|||||||
hasNext: boolean;
|
hasNext: boolean;
|
||||||
asOf: string;
|
asOf: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrokerPositionsPage {
|
||||||
|
accountId: string;
|
||||||
|
items: BrokerPosition[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasNext: boolean;
|
||||||
|
asOf: string;
|
||||||
|
}
|
||||||
|
|||||||
20
apps/frontend/src/components/SkeletonBlock.tsx
Normal file
20
apps/frontend/src/components/SkeletonBlock.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export function SkeletonBlock({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius = 4,
|
||||||
|
}: {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
borderRadius?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{
|
||||||
|
width: width ?? '100%',
|
||||||
|
height: height ?? 16,
|
||||||
|
borderRadius,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/frontend/src/components/TableSkeleton.tsx
Normal file
25
apps/frontend/src/components/TableSkeleton.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { SkeletonBlock } from './SkeletonBlock';
|
||||||
|
|
||||||
|
const tdStyle = {
|
||||||
|
borderBottom: '1px solid #eeeeee',
|
||||||
|
padding: '10px 8px',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
type Column = { width: string };
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col, j) => (
|
||||||
|
<td key={j} style={tdStyle}>
|
||||||
|
<SkeletonBlock height={12} width={col.width} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
|
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
|
||||||
import type { BrokerOperationsPage } from '../api/responses';
|
import type { BrokerOperationsPage } from '../api/responses';
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ export function useBrokerOperations(
|
|||||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||||
staleTime: 300_000,
|
staleTime: 300_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/frontend/src/hooks/useBrokerPositions.ts
Normal file
18
apps/frontend/src/hooks/useBrokerPositions.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { getBrokerPositions } from '../api/broker';
|
||||||
|
import type { BrokerPositionsPage } from '../api/responses';
|
||||||
|
|
||||||
|
export function useBrokerPositions(
|
||||||
|
accountId: string | undefined,
|
||||||
|
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||||
|
) {
|
||||||
|
return useQuery<BrokerPositionsPage>({
|
||||||
|
queryKey: ['broker', 'positions', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerPositions(accountId!, query)).data,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,10 +5,10 @@ import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
|||||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||||
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
||||||
|
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
|
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: value.currency || 'RUB',
|
currency: value.currency || 'RUB',
|
||||||
@ -23,7 +23,40 @@ export function BrokerAccountDetailPage() {
|
|||||||
const portfolio = useBrokerPortfolio(accountId);
|
const portfolio = useBrokerPortfolio(accountId);
|
||||||
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
||||||
|
|
||||||
if (portfolio.isLoading) return <p>Загрузка портфеля...</p>;
|
if (portfolio.isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 24 }}>
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
<SkeletonBlock height={32} width="60%" />
|
||||||
|
<SkeletonBlock height={24} width="40%" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkeletonBlock height={14} width="40%" />
|
||||||
|
<div style={{ height: 8 }} />
|
||||||
|
<SkeletonBlock height={20} width="60%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (portfolio.error || !portfolio.data) {
|
if (portfolio.error || !portfolio.data) {
|
||||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
||||||
}
|
}
|
||||||
@ -31,14 +64,12 @@ export function BrokerAccountDetailPage() {
|
|||||||
function handleNextOperationsPage() {
|
function handleNextOperationsPage() {
|
||||||
const nextCursor = operations.data?.nextCursor;
|
const nextCursor = operations.data?.nextCursor;
|
||||||
if (!nextCursor || !operations.data?.hasNext) return;
|
if (!nextCursor || !operations.data?.hasNext) return;
|
||||||
|
|
||||||
setOperationCursorStack((previous) => [...previous, operationCursor]);
|
setOperationCursorStack((previous) => [...previous, operationCursor]);
|
||||||
setOperationCursor(nextCursor);
|
setOperationCursor(nextCursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviousOperationsPage() {
|
function handlePreviousOperationsPage() {
|
||||||
if (operationCursorStack.length === 0) return;
|
if (operationCursorStack.length === 0) return;
|
||||||
|
|
||||||
const nextStack = operationCursorStack.slice(0, -1);
|
const nextStack = operationCursorStack.slice(0, -1);
|
||||||
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
|
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
|
||||||
setOperationCursorStack(nextStack);
|
setOperationCursorStack(nextStack);
|
||||||
@ -87,7 +118,7 @@ export function BrokerAccountDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<BrokerPositionsSection positions={portfolio.data.positions} />
|
<BrokerPositionsSection accountId={accountId!} />
|
||||||
|
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
isLoading={operations.isLoading}
|
isLoading={operations.isLoading}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
import { useBrokerAccounts } from '../../hooks/useBrokerAccounts';
|
||||||
|
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||||
|
|
||||||
const cardStyle = {
|
const cardStyle = {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@ -15,7 +16,43 @@ const cardStyle = {
|
|||||||
export function BrokerAccountsPage() {
|
export function BrokerAccountsPage() {
|
||||||
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
||||||
|
|
||||||
if (isLoading) return <p>Загрузка брокерских счетов...</p>;
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontSize: 28, lineHeight: 1.2 }}>Брокерские счета</h1>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 16,
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: 20,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkeletonBlock height={20} width="60%" />
|
||||||
|
<div style={{ height: 10 }} />
|
||||||
|
<SkeletonBlock height={12} width="40%" />
|
||||||
|
<div style={{ height: 6 }} />
|
||||||
|
<SkeletonBlock height={12} width="30%" />
|
||||||
|
<div style={{ height: 6 }} />
|
||||||
|
<SkeletonBlock height={12} width="50%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
if (error) return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../a
|
|||||||
import {
|
import {
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationImpactLabel,
|
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
type BrokerOperationImpact,
|
type BrokerOperationImpact,
|
||||||
} from './brokerDisplay';
|
} from './brokerDisplay';
|
||||||
|
import { TableSkeleton } from '../../components/TableSkeleton';
|
||||||
|
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -27,33 +27,14 @@ const tdStyle = {
|
|||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
const impactStyles: Record<BrokerOperationImpact, React.CSSProperties> = {
|
|
||||||
adds: {
|
|
||||||
background: 'rgba(46, 125, 50, 0.1)',
|
|
||||||
color: 'var(--color-positive)',
|
|
||||||
},
|
|
||||||
reduces: {
|
|
||||||
background: 'rgba(198, 40, 40, 0.1)',
|
|
||||||
color: 'var(--color-negative)',
|
|
||||||
},
|
|
||||||
neutral: {
|
|
||||||
background: 'rgba(25, 118, 210, 0.1)',
|
|
||||||
color: 'var(--color-primary)',
|
|
||||||
},
|
|
||||||
unknown: {
|
|
||||||
background: 'rgba(102, 102, 102, 0.12)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
|
const formatted = new Intl.NumberFormat('ru-RU', {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: value.currency || 'RUB',
|
currency: value.currency || 'RUB',
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(value.value);
|
}).format(value.value);
|
||||||
|
return value.value > 0 ? `+${formatted}` : formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
@ -70,43 +51,48 @@ function moneyColor(impact: BrokerOperationImpact): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||||
const label = operation.ticker || operation.description || '-';
|
const ticker = operation.ticker || operation.description || '-';
|
||||||
const path = getBrokerInstrumentPath({
|
const path = getBrokerInstrumentPath({
|
||||||
ticker: operation.ticker,
|
ticker: operation.ticker,
|
||||||
instrumentType: operation.instrumentType,
|
instrumentType: operation.instrumentType,
|
||||||
classCode: operation.classCode,
|
classCode: operation.classCode,
|
||||||
});
|
});
|
||||||
|
const name = operation.name || operation.description;
|
||||||
|
|
||||||
if (!path || label === '-') {
|
if (!path && !name) return <span>-</span>;
|
||||||
return <span>{label}</span>;
|
if (!path) return <span>{name}</span>;
|
||||||
}
|
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
||||||
|
|
||||||
return <Link to={path}>{label}</Link>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationType({ operation }: { operation: BrokerOperation }) {
|
|
||||||
const impact = getBrokerOperationImpact(operation);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 4 }}>
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
<Link to={path} style={{ fontWeight: 700 }}>
|
||||||
<span
|
{ticker}
|
||||||
style={{
|
</Link>
|
||||||
justifySelf: 'start',
|
{name && name !== ticker && (
|
||||||
borderRadius: 999,
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
||||||
fontSize: 12,
|
)}
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '5px 8px',
|
|
||||||
...impactStyles[impact],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getBrokerOperationImpactLabel(impact)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagButtonStyle = {
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const pagButtonDisabledStyle = {
|
||||||
|
...pagButtonStyle,
|
||||||
|
opacity: 0.35,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
export function BrokerOperationsTable({
|
export function BrokerOperationsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
page,
|
page,
|
||||||
@ -139,20 +125,61 @@ export function BrokerOperationsTable({
|
|||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button type="button" onClick={onPrevious} disabled={!canGoBack}>
|
<button
|
||||||
Назад
|
type="button"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
←
|
||||||
</button>
|
</button>
|
||||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
<span
|
||||||
Страница {pageNumber}
|
style={{
|
||||||
|
minWidth: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={onNext} disabled={!canGoForward}>
|
<button
|
||||||
Вперед
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canGoForward}
|
||||||
|
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
→
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p>Загрузка операций...</p>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Дата
|
||||||
|
</th>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Тип
|
||||||
|
</th>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Инструмент
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Сумма
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<TableSkeleton
|
||||||
|
rows={5}
|
||||||
|
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
) : operations.length === 0 ? (
|
) : operations.length === 0 ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||||
) : (
|
) : (
|
||||||
@ -182,7 +209,7 @@ export function BrokerOperationsTable({
|
|||||||
<tr key={operation.cursor || operation.id}>
|
<tr key={operation.cursor || operation.id}>
|
||||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<OperationType operation={operation} />
|
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<OperationInstrument operation={operation} />
|
<OperationInstrument operation={operation} />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { type ReactElement } from 'react';
|
import { type ReactElement } from 'react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
@ -7,6 +7,8 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import * as accountHook from '../../hooks/useBrokerAccounts';
|
import * as accountHook from '../../hooks/useBrokerAccounts';
|
||||||
import * as operationsHook from '../../hooks/useBrokerOperations';
|
import * as operationsHook from '../../hooks/useBrokerOperations';
|
||||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||||
|
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||||||
|
import type { BrokerPosition } from '../../api/responses';
|
||||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
|
||||||
@ -20,6 +22,45 @@ function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPosition(input: Partial<BrokerPosition>): BrokerPosition {
|
||||||
|
return {
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
positionUid: null,
|
||||||
|
ticker: null,
|
||||||
|
classCode: null,
|
||||||
|
instrumentType: null,
|
||||||
|
name: null,
|
||||||
|
quantity: null,
|
||||||
|
blockedLots: null,
|
||||||
|
currentPrice: null,
|
||||||
|
currentValue: null,
|
||||||
|
averagePositionPrice: null,
|
||||||
|
expectedYieldPercent: null,
|
||||||
|
dailyYield: null,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spy on useBrokerPositions and return only positions matching query.type . */
|
||||||
|
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
||||||
|
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
|
||||||
|
const type = query.type?.toLowerCase();
|
||||||
|
const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items: filtered,
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('Broker pages', () => {
|
describe('Broker pages', () => {
|
||||||
it('renders broker and IIS accounts', () => {
|
it('renders broker and IIS accounts', () => {
|
||||||
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
||||||
@ -66,24 +107,6 @@ describe('Broker pages', () => {
|
|||||||
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||||||
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
|
cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: null,
|
|
||||||
instrumentUid: 'uid-1',
|
|
||||||
positionUid: null,
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
instrumentType: 'share',
|
|
||||||
name: 'Sberbank',
|
|
||||||
quantity: 10,
|
|
||||||
blockedLots: null,
|
|
||||||
currentPrice: null,
|
|
||||||
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
|
||||||
averagePositionPrice: null,
|
|
||||||
expectedYieldPercent: null,
|
|
||||||
dailyYield: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
asOf: '2026-06-16T00:00:00.000Z',
|
asOf: '2026-06-16T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -124,6 +147,17 @@ describe('Broker pages', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
mockUseBrokerPositions(
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'uid-1',
|
||||||
|
ticker: 'SBER',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
quantity: 10,
|
||||||
|
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
renderWithClient(
|
renderWithClient(
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -151,40 +185,6 @@ describe('Broker pages', () => {
|
|||||||
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||||||
cash: [],
|
cash: [],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: null,
|
|
||||||
instrumentUid: 'share-uid',
|
|
||||||
positionUid: null,
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
instrumentType: 'share',
|
|
||||||
name: 'Sberbank',
|
|
||||||
quantity: 10,
|
|
||||||
blockedLots: null,
|
|
||||||
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
|
||||||
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
|
||||||
averagePositionPrice: null,
|
|
||||||
expectedYieldPercent: 20,
|
|
||||||
dailyYield: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
figi: null,
|
|
||||||
instrumentUid: 'bond-uid',
|
|
||||||
positionUid: null,
|
|
||||||
ticker: 'SU26238RMFS5',
|
|
||||||
classCode: 'TQOB',
|
|
||||||
instrumentType: 'bond',
|
|
||||||
name: 'ОФЗ 26238',
|
|
||||||
quantity: 2,
|
|
||||||
blockedLots: null,
|
|
||||||
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
|
||||||
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
|
||||||
averagePositionPrice: null,
|
|
||||||
expectedYieldPercent: 10,
|
|
||||||
dailyYield: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -201,6 +201,30 @@ describe('Broker pages', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
mockUseBrokerPositions(
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
ticker: 'SBER',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
quantity: 10,
|
||||||
|
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
||||||
|
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||||||
|
expectedYieldPercent: 20,
|
||||||
|
}),
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'bond-uid',
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
name: 'ОФЗ 26238',
|
||||||
|
quantity: 2,
|
||||||
|
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
||||||
|
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
||||||
|
expectedYieldPercent: 10,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
renderWithClient(
|
renderWithClient(
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -222,7 +246,7 @@ describe('Broker pages', () => {
|
|||||||
expect(screen.getByText(/900,00/)).toBeInTheDocument();
|
expect(screen.getByText(/900,00/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders broker operations with Russian labels, linked instruments and impact badges', () => {
|
it('renders broker operations with Russian labels, linked instruments and colored amounts', () => {
|
||||||
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
account: {
|
account: {
|
||||||
@ -237,7 +261,6 @@ describe('Broker pages', () => {
|
|||||||
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||||
cash: [],
|
cash: [],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
positions: [],
|
|
||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -301,6 +324,7 @@ describe('Broker pages', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
mockUseBrokerPositions();
|
||||||
|
|
||||||
renderWithClient(
|
renderWithClient(
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -311,8 +335,7 @@ describe('Broker pages', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
|
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Налог')).toBeInTheDocument();
|
expect(screen.getByText('Налог')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Пополняет')).toBeInTheDocument();
|
expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Списывает')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/bonds/SU26238RMFS5',
|
'/bonds/SU26238RMFS5',
|
||||||
@ -335,7 +358,6 @@ describe('Broker pages', () => {
|
|||||||
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||||
cash: [],
|
cash: [],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
positions: [],
|
|
||||||
asOf: '2026-06-17T00:00:00.000Z',
|
asOf: '2026-06-17T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -412,6 +434,7 @@ describe('Broker pages', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
|
mockUseBrokerPositions();
|
||||||
|
|
||||||
renderWithClient(
|
renderWithClient(
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -421,19 +444,26 @@ describe('Broker pages', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
expect(screen.getByText('Страница 1')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Вперед' }));
|
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
|
||||||
|
const withinOperations = within(operationsSection);
|
||||||
|
const nextButton = withinOperations.getByRole('button', { name: '→' });
|
||||||
|
const prevButton = withinOperations.getByRole('button', { name: '←' });
|
||||||
|
expect(prevButton).toBeDisabled();
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
cursor: 'cursor-page-2',
|
cursor: 'cursor-page-2',
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Страница 2')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Назад' }));
|
expect(withinOperations.getByText('2')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(prevButton);
|
||||||
|
|
||||||
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
expect(screen.getByText('Страница 1')).toBeInTheDocument();
|
expect(withinOperations.getByText('1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
||||||
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
|
import { getBrokerInstrumentPath } from './brokerDisplay';
|
||||||
|
import { TableSkeleton } from '../../components/TableSkeleton';
|
||||||
|
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||||||
|
|
||||||
type BrokerPositionGroupConfig = {
|
type BrokerPositionGroupConfig = {
|
||||||
key: 'shares' | 'bonds' | 'other';
|
key: string;
|
||||||
|
type?: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUPS: BrokerPositionGroupConfig[] = [
|
const GROUPS: BrokerPositionGroupConfig[] = [
|
||||||
{ key: 'shares', title: 'Акции' },
|
{ key: 'shares', type: 'share', title: 'Акции' },
|
||||||
{ key: 'bonds', title: 'Облигации' },
|
{ key: 'bonds', type: 'bond', title: 'Облигации' },
|
||||||
{ key: 'other', title: 'Другие инструменты' },
|
{ key: 'etf', type: 'etf', title: 'ETF' },
|
||||||
|
{ key: 'fund', type: 'fund', title: 'Фонды' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
|
||||||
|
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
@ -32,9 +39,26 @@ const tdStyle = {
|
|||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const pagButtonStyle = {
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const pagButtonDisabledStyle = {
|
||||||
|
...pagButtonStyle,
|
||||||
|
opacity: 0.35,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
|
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: value.currency || 'RUB',
|
currency: value.currency || 'RUB',
|
||||||
@ -65,12 +89,132 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
|
function PositionGroupTable({
|
||||||
|
accountId,
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
group: BrokerPositionGroupConfig;
|
||||||
|
}) {
|
||||||
|
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
|
||||||
|
const { data: page, isLoading } = useBrokerPositions(accountId, query);
|
||||||
|
|
||||||
|
const rawPositions = page?.items ?? [];
|
||||||
|
const positions = group.type
|
||||||
|
? rawPositions
|
||||||
|
: rawPositions.filter(
|
||||||
|
(p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageNumber = cursorStack.length + 1;
|
||||||
|
const canGoBack = cursorStack.length > 0;
|
||||||
|
const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
const nextCursor = page?.nextCursor;
|
||||||
|
if (!nextCursor || !page?.hasNext || !group.type) return;
|
||||||
|
setCursorStack((prev) => [...prev, cursor]);
|
||||||
|
setCursor(nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevious() {
|
||||||
|
if (cursorStack.length === 0) return;
|
||||||
|
const prev = cursorStack[cursorStack.length - 1];
|
||||||
|
setCursorStack((prevStack) => prevStack.slice(0, -1));
|
||||||
|
setCursor(prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && positions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
|
||||||
|
{group.type && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoForward}
|
||||||
|
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
<table style={tableStyle}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Тикер
|
||||||
|
</th>
|
||||||
|
<th align="left" style={thStyle}>
|
||||||
|
Название
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Количество
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Цена
|
||||||
|
</th>
|
||||||
|
<th align="right" style={thStyle}>
|
||||||
|
Стоимость
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<TableSkeleton
|
||||||
|
rows={4}
|
||||||
|
columns={[
|
||||||
|
{ width: '30%' },
|
||||||
|
{ width: '50%' },
|
||||||
|
{ width: '20%' },
|
||||||
|
{ width: '25%' },
|
||||||
|
{ width: '25%' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && positions.length > 0 && (
|
||||||
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
|
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th align="left" style={thStyle}>
|
<th align="left" style={thStyle}>
|
||||||
@ -94,7 +238,10 @@ function PositionTable({ title, positions }: { title: string; positions: BrokerP
|
|||||||
{positions.map((position) => (
|
{positions.map((position) => (
|
||||||
<tr
|
<tr
|
||||||
key={
|
key={
|
||||||
position.positionUid || position.instrumentUid || position.ticker || position.figi
|
position.positionUid ||
|
||||||
|
position.instrumentUid ||
|
||||||
|
position.ticker ||
|
||||||
|
position.figi
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
@ -119,33 +266,22 @@ function PositionTable({ title, positions }: { title: string; positions: BrokerP
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) {
|
type BrokerPositionsSectionProps = {
|
||||||
const grouped = GROUPS.map((group) => ({
|
accountId: string;
|
||||||
...group,
|
};
|
||||||
positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key),
|
|
||||||
})).filter((group) => group.positions.length > 0);
|
|
||||||
|
|
||||||
if (grouped.length === 0) {
|
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section>
|
|
||||||
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
|
|
||||||
<div style={{ display: 'grid', gap: 20 }}>
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
{grouped.map((group) => (
|
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
||||||
<PositionTable key={group.key} title={group.title} positions={group.positions} />
|
{GROUPS.map((group) => (
|
||||||
|
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
|||||||
import {
|
import {
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationImpactLabel,
|
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
getBrokerPositionGroup,
|
getBrokerPositionGroup,
|
||||||
} from './brokerDisplay';
|
} from './brokerDisplay';
|
||||||
@ -191,11 +190,4 @@ describe('broker display helpers', () => {
|
|||||||
),
|
),
|
||||||
).toBe('adds');
|
).toBe('adds');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides Russian impact labels', () => {
|
|
||||||
expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет');
|
|
||||||
expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает');
|
|
||||||
expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка');
|
|
||||||
expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -161,16 +161,3 @@ export function getBrokerOperationImpact(
|
|||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string {
|
|
||||||
switch (impact) {
|
|
||||||
case 'adds':
|
|
||||||
return 'Пополняет';
|
|
||||||
case 'reduces':
|
|
||||||
return 'Списывает';
|
|
||||||
case 'neutral':
|
|
||||||
return 'Перекладка';
|
|
||||||
case 'unknown':
|
|
||||||
return 'Неясно';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -33,3 +33,20 @@ a {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg) 25%,
|
||||||
|
#f0f0f0 50%,
|
||||||
|
var(--color-bg) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
# Улучшение UI операций и пагинации в брокерском портфеле
|
||||||
|
|
||||||
|
Дата: 2026-06-17
|
||||||
|
Статус: черновик
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Страница брокерского счета (`BrokerAccountDetailPage.tsx`) показывает таблицу операций и таблицы позиций.
|
||||||
|
Текущая реализация имеет несколько UI-недостатков, описанных ниже.
|
||||||
|
|
||||||
|
Изменения затрагивают только frontend. Бэкенд, OpenAPI-контракт, DTO и codegen не меняются.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Убрать бейджи impact из таблицы "Операции"
|
||||||
|
|
||||||
|
**Проблема:** В колонке "Тип" операции показывается label (напр. "Покупка") и под ним цветной бейдж с текстом "Пополняет", "Списывает", "Перекладка" или "Неясно". Это визуальный шум — пользователю достаточно знать тип операции и сумму.
|
||||||
|
|
||||||
|
**Решение:** Удалить `OperationType`-компонент, который рендерит бейдж. Вместо него в ячейке "Тип" отображать только `getBrokerOperationTypeLabel(operation)`.
|
||||||
|
|
||||||
|
**Удаляемый код:**
|
||||||
|
- Компонент `OperationType` (строки 87-108)
|
||||||
|
- Объект `impactStyles` (строки 30-47)
|
||||||
|
- Импорт `getBrokerOperationImpactLabel` (не используется больше)
|
||||||
|
- Импорт `type BrokerOperationImpact` не удаляется — он всё ещё используется в сигнатуре `moneyColor(impact: BrokerOperationImpact)`
|
||||||
|
|
||||||
|
**Сохраняется:**
|
||||||
|
- `getBrokerOperationImpact()` — всё ещё нужна для `moneyColor()` (цвет суммы)
|
||||||
|
- `moneyColor()` и `formatMoney()` — без изменений
|
||||||
|
|
||||||
|
### 2. Префикс "+" для положительных сумм
|
||||||
|
|
||||||
|
**Проблема:** Отрицательные суммы уже отображаются с минусом ("−11,00 ₽"), а положительные без знака ("90,00 ₽"). Визуально неочевидно, что это приход.
|
||||||
|
|
||||||
|
**Решение:** В функции `formatMoney()` в `BrokerOperationsTable.tsx` добавить префикс `'+'` если `value > 0`.
|
||||||
|
|
||||||
|
Цвет суммы по-прежнему определяется через `moneyColor(impact)`.
|
||||||
|
|
||||||
|
### 3. Пагинация: keepPreviousData и стилизация
|
||||||
|
|
||||||
|
**Проблема (скачок):** При нажатии "Вперед" `isLoading` становится `true` → таблица исчезает, показывается "Загрузка операций..." → затем таблица возвращается с новыми данными.
|
||||||
|
|
||||||
|
**Решение:** Использовать `placeholderData: keepPreviousData` из TanStack Query v5 в `useBrokerOperations.ts`.
|
||||||
|
|
||||||
|
**Проблема (стили кнопок):** Кнопки "Назад" / "Вперед" используют браузерные стили по умолчанию, выглядят неаккуратно. При `disabled` состоянии визуально не отличить от активного.
|
||||||
|
|
||||||
|
**Решение:** Добавить inline-стили для кнопок пагинации с padding, border, background, hover, disabled state.
|
||||||
|
|
||||||
|
### 4. "Другие инструменты"
|
||||||
|
|
||||||
|
Текущая реализация корректна: в эту секцию попадают позиции с `instrumentType !== 'share' && !== 'bond'` (ETF, валюты, фьючерсы и т.д.). Пока таких позиций нет — секция скрыта. Изменений не требуется.
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
| Файл | Что меняется |
|
||||||
|
|---|---|
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Удалить `OperationType`, `impactStyles`. Модифицировать `formatMoney` с "+". Стилизовать кнопки пагинации |
|
||||||
|
| `apps/frontend/src/hooks/useBrokerOperations.ts` | Добавить `placeholderData: keepPreviousData` |
|
||||||
|
| `apps/frontend/src/pages/broker/brokerDisplay.ts` | Удалить `getBrokerOperationImpactLabel` (становится dead code). `BrokerOperationImpact` сохраняется — используется в типе возврата `getBrokerOperationImpact` и параметре `moneyColor` |
|
||||||
|
| `apps/frontend/src/pages/broker/brokerDisplay.test.ts` | Удалить тест `getBrokerOperationImpactLabel` |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Убрать проверки бейджей "Пополняет"/"Списывает". Обновить тест пагинации для новых стилей |
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- `npm run test:frontend` — существующие тесты должны проходить с учётом изменений
|
||||||
|
- Проверить, что пагинация не дёргает интерфейс при переключении страниц
|
||||||
|
- Проверить, что положительные суммы отображаются с "+"
|
||||||
|
- Проверить, что бейджи impact больше не показываются
|
||||||
@ -0,0 +1,269 @@
|
|||||||
|
# Пагинация позиций, скелетоны, название инструмента в операциях
|
||||||
|
|
||||||
|
Дата: 2026-06-17
|
||||||
|
Статус: черновик
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Страница брокерского счёта показывает таблицы позиций (Акции, Облигации, Другие инструменты) и
|
||||||
|
операций. Сейчас позиции приходят единым списком внутри `GET /portfolio`, что неэффективно при
|
||||||
|
большом количестве позиций. Также отсутствуют loading-индикаторы (просто текст "Загрузка...").
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
1. Выделить позиции в отдельный paginated endpoint (10 на страницу)
|
||||||
|
2. Заменить текстовые loading-индикаторы на shimmer-скелетоны
|
||||||
|
3. Добавить название инструмента в колонку "Инструмент" таблицы операций
|
||||||
|
4. Добавить визуальный loading-индикатор при переключении страниц таблиц
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Backend: отдельный endpoint для позиций
|
||||||
|
|
||||||
|
**Новый endpoint:** `GET /api/v1/broker/accounts/:accountId/positions`
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `cursor` — positionUid последней позиции на тек. странице (string, опционально)
|
||||||
|
- `limit` — размер страницы (number, default 10)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```ts
|
||||||
|
interface BrokerPositionsPage {
|
||||||
|
accountId: string;
|
||||||
|
items: BrokerPosition[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasNext: boolean;
|
||||||
|
asOf: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Логика:**
|
||||||
|
- `broker-portfolio.service.ts` уже делает gRPC вызов `GetPortfolio`, который возвращает все позиции
|
||||||
|
- Новый метод `getPositions(accountId, cursor?, limit?)` делает тот же gRPC вызов, кэширует полный список,
|
||||||
|
затем возвращает paginated slice
|
||||||
|
- Cursor: позиция с `positionUid === cursor` — начало следующей страницы
|
||||||
|
- Кэширование: `CACHE_POSITIONS_TTL` (60s) — отдельно от портфеля, т.к. цены меняются быстро
|
||||||
|
- Если `cursor` не указан — возвращается первая страница
|
||||||
|
|
||||||
|
**Изменение `BrokerPortfolio`:** убрать `positions` из типа/DTO портфеля.
|
||||||
|
Фронтенд теперь грузит позиции отдельным запросом.
|
||||||
|
|
||||||
|
**Новый файл:** `dto/broker-positions-page-response.dto.ts`
|
||||||
|
|
||||||
|
**Изменяемые backend-файлы:**
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `types/broker.types.ts` | Добавить `BrokerPositionsPage` тип. Убрать `positions` из `BrokerPortfolio` |
|
||||||
|
| `dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto` |
|
||||||
|
| `dto/broker-position-response.dto.ts` | Создать (перенести `BrokerPositionResponseDto` сюда из portfolio) |
|
||||||
|
| `dto/broker-positions-page-response.dto.ts` | Создать |
|
||||||
|
| `services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из `getPortfolio()` |
|
||||||
|
| `mappers/portfolio.mapper.ts` | Разделить маппинг: `mapBrokerPortfolio()` без positions, `mapBrokerPosition()` отдельно |
|
||||||
|
| `tbank.controller.ts` | Добавить `GET /accounts/:accountId/positions` |
|
||||||
|
| `tbank.config.ts` | Добавить `CACHE_POSITIONS_TTL` (60s) |
|
||||||
|
| `operation.mapper.ts` | Добавить `name: item.name ?? null` в `mapOperation()` |
|
||||||
|
| `types/broker.types.ts` | Добавить `name` в `BrokerOperation` |
|
||||||
|
| `dto/broker-operation-response.dto.ts` | Добавить `name` |
|
||||||
|
|
||||||
|
### 2. Frontend: новый хук и типы для позиций
|
||||||
|
|
||||||
|
**Новый хук:** `apps/frontend/src/hooks/useBrokerPositions.ts`
|
||||||
|
```ts
|
||||||
|
export function useBrokerPositions(accountId, query = {}) {
|
||||||
|
return useQuery<BrokerPositionsPage>({
|
||||||
|
queryKey: ['broker', 'positions', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: () => getBrokerPositions(accountId!, query),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Новый API-вызов:** `apps/frontend/src/api/broker.ts`
|
||||||
|
```ts
|
||||||
|
export function getBrokerPositions(accountId, query) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Новые типы в `responses.ts`:**
|
||||||
|
- `BrokerPositionsPage` — интерфейс с items, nextCursor, hasNext
|
||||||
|
- `name: string | null` в `BrokerOperation`
|
||||||
|
- Убрать `positions` из `BrokerPortfolio`
|
||||||
|
|
||||||
|
### 3. BrokerPositionsSection с пагинацией
|
||||||
|
|
||||||
|
Компонент теперь принимает пропсы для пагинации (как BrokerOperationsTable):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface Props {
|
||||||
|
page: BrokerPositionsPage | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
pageNumber: number;
|
||||||
|
canGoBack: boolean;
|
||||||
|
canGoForward: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Логика:**
|
||||||
|
- `BrokerPositionsSection` рендерит те же группы (Акции / Облигации / Другие инструменты),
|
||||||
|
но только для позиций с текущей страницы
|
||||||
|
- Снизу — кнопки пагинации ← N →
|
||||||
|
- При `isLoading=true` — показывать 5 shimmer-строк (вместо реальных данных)
|
||||||
|
- При `isLoading=true` и отсутствии данных (первая загрузка) — показывать
|
||||||
|
PositionTable skeleton (shimmer-строки для заглушки)
|
||||||
|
|
||||||
|
### 4. Shimmer-скелетоны (CSS + компоненты)
|
||||||
|
|
||||||
|
**CSS в `styles.css`:**
|
||||||
|
```css
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#eee 25%,
|
||||||
|
#f5f5f5 50%,
|
||||||
|
#eee 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Компонент `SkeletonBlock`:**
|
||||||
|
```tsx
|
||||||
|
function SkeletonBlock({ width, height, borderRadius = 4 }: {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
borderRadius?: number;
|
||||||
|
}) {
|
||||||
|
return <div className="skeleton" style={{ width, height, borderRadius }} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**BrokerAccountsPage:**
|
||||||
|
- Вместо `<p>Загрузка...</p>` — 3 карточки-скелетона в grid
|
||||||
|
```tsx
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} style={{ padding: 20, background: 'var(--color-surface)', borderRadius: 8 }}>
|
||||||
|
<SkeletonBlock height={20} width="60%" />
|
||||||
|
<div style={{ height: 10 }} />
|
||||||
|
<SkeletonBlock height={12} width="40%" />
|
||||||
|
<div style={{ height: 6 }} />
|
||||||
|
<SkeletonBlock height={12} width="30%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**BrokerAccountDetailPage:**
|
||||||
|
- Вместо `<p>Загрузка портфеля...</p>` — shimmer-блоки под header + cash + positions
|
||||||
|
- Позиции грузятся отдельно через `useBrokerPositions` — свой skeleton
|
||||||
|
|
||||||
|
### 5. Название инструмента в операциях
|
||||||
|
|
||||||
|
**Изменение `OperationInstrument`:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||||
|
const ticker = operation.ticker;
|
||||||
|
const path = getBrokerInstrumentPath({ ticker, instrumentType: operation.instrumentType, classCode: operation.classCode });
|
||||||
|
const name = operation.name || operation.description;
|
||||||
|
|
||||||
|
if (!path && !name) return <span>-</span>;
|
||||||
|
if (!path) return <span>{name}</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 2 }}>
|
||||||
|
<Link to={path} style={{ fontWeight: 700 }}>{ticker}</Link>
|
||||||
|
{name && name !== ticker && (
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Loading-индикатор при переключении страниц (shimmer-строки)
|
||||||
|
|
||||||
|
**BrokerOperationsTable:**
|
||||||
|
- При `isLoading=true` и наличии `page` (уже были данные, но грузится новая страница):
|
||||||
|
показываем 5 shimmer-строк вместо table body
|
||||||
|
- При `isLoading=true` и отсутствии `page` (первая загрузка):
|
||||||
|
показываем header таблицы + 5 shimmer-строк
|
||||||
|
- Используем `keepPreviousData` в TanStack Query, но визуально не показываем старые данные —
|
||||||
|
показываем shimmer-строки
|
||||||
|
|
||||||
|
**BrokerPositionsSection:**
|
||||||
|
- Аналогичное поведение при переключении страниц позиций
|
||||||
|
|
||||||
|
**Компонент `TableSkeleton`:**
|
||||||
|
```tsx
|
||||||
|
function TableSkeleton({ rows = 5 }) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={tdStyle}><SkeletonBlock height={12} width="70%" /></td>
|
||||||
|
<td style={tdStyle}><SkeletonBlock height={12} width="50%" /></td>
|
||||||
|
<td style={tdStyle}><SkeletonBlock height={12} width="30%" /></td>
|
||||||
|
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
|
||||||
|
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Количество колонок и их ширина зависит от таблицы (operations vs positions).
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `apps/backend/src/modules/tbank/types/broker.types.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`. Добавить `name` в `BrokerOperation` |
|
||||||
|
| `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto`. Вынести `BrokerPositionResponseDto` |
|
||||||
|
| `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` | Создать (из `BrokerPositionResponseDto`) |
|
||||||
|
| `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` | Создать |
|
||||||
|
| `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` | Добавить `name` |
|
||||||
|
| `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` | Разделить маппинг portfolio/positions |
|
||||||
|
| `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` | Добавить `name` в mapOperation |
|
||||||
|
| `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из portfolio |
|
||||||
|
| `apps/backend/src/modules/tbank/tbank.controller.ts` | Добавить GET /positions endpoint |
|
||||||
|
| `apps/backend/src/modules/tbank/tbank.config.ts` | Добавить CACHE_POSITIONS_TTL |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `apps/frontend/src/styles.css` | Добавить `@keyframes shimmer` и `.skeleton` |
|
||||||
|
| `apps/frontend/src/api/responses.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`, `name` в `BrokerOperation` |
|
||||||
|
| `apps/frontend/src/api/broker.ts` | Добавить `getBrokerPositions()` |
|
||||||
|
| `apps/frontend/src/hooks/useBrokerPositions.ts` | Создать |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Пагинация + shimmer-строки |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Shimmer-строки при loading, обновить OperationInstrument |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Скелетоны, хук позиций |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` | Скелетоны |
|
||||||
|
| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты |
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- Backend: обновить `broker-portfolio.service.spec.ts` — убрать positions из portfolio, покрыть getPositions
|
||||||
|
- Backend: обновить `portfolio.mapper.spec.ts`
|
||||||
|
- Frontend: `npm run test:frontend` — все тесты должны проходить
|
||||||
|
- Проверить, что скелетоны отображаются при загрузке
|
||||||
|
- Проверить, что пагинация позиций работает
|
||||||
|
- Проверить, что shimmer-строки показываются при переключении страниц
|
||||||
|
- Проверить, что название инструмента отображается в операциях
|
||||||
Loading…
x
Reference in New Issue
Block a user