feat(backend): add broker analytics DTO and service
This commit is contained in:
parent
15427be384
commit
61e3398c71
@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BrokerAnalyticsDto {
|
||||
@ApiProperty()
|
||||
totalDeposits!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalWithdrawn!: number;
|
||||
|
||||
@ApiProperty()
|
||||
netInvested!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalDividends!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalCoupons!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalReceived!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
totalReturnPercent!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
|
||||
const DEPOSIT_TYPES = new Set([
|
||||
'OPERATION_TYPE_INPUT',
|
||||
'OPERATION_TYPE_INPUT_SWIFT',
|
||||
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_INP_MULTI',
|
||||
'OPERATION_TYPE_OVER_PLACEMENT',
|
||||
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||
'OPERATION_TYPE_TRANS_BS_BS',
|
||||
]);
|
||||
|
||||
const WITHDRAWAL_TYPES = new Set([
|
||||
'OPERATION_TYPE_OUTPUT',
|
||||
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_OUT_MULTI',
|
||||
]);
|
||||
|
||||
const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']);
|
||||
|
||||
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
||||
|
||||
const ANALYTICS_TYPES = new Set([
|
||||
...DEPOSIT_TYPES,
|
||||
...WITHDRAWAL_TYPES,
|
||||
...DIVIDEND_TYPES,
|
||||
...COUPON_TYPES,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAnalyticsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getAnalytics(accountId: string): Promise<{
|
||||
data: BrokerAnalyticsDto;
|
||||
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.analytics,
|
||||
[accountId],
|
||||
() => this.computeAnalytics(accountId),
|
||||
'tbankAnalyticsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
}
|
||||
|
||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||
const operations = await this.prisma.brokerOperation.findMany({
|
||||
where: {
|
||||
accountId,
|
||||
type: { in: Array.from(ANALYTICS_TYPES) },
|
||||
payment: { not: null },
|
||||
},
|
||||
select: { type: true, payment: true },
|
||||
});
|
||||
|
||||
let totalDeposits = 0;
|
||||
let totalWithdrawn = 0;
|
||||
let totalDividends = 0;
|
||||
let totalCoupons = 0;
|
||||
|
||||
for (const op of operations) {
|
||||
const payment = JSON.parse(op.payment!);
|
||||
const value = payment.value ?? 0;
|
||||
|
||||
if (DEPOSIT_TYPES.has(op.type)) {
|
||||
totalDeposits += value;
|
||||
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
||||
totalWithdrawn += Math.abs(value);
|
||||
} else if (DIVIDEND_TYPES.has(op.type)) {
|
||||
totalDividends += value;
|
||||
} else if (COUPON_TYPES.has(op.type)) {
|
||||
totalCoupons += value;
|
||||
}
|
||||
}
|
||||
|
||||
const netInvested = totalDeposits - totalWithdrawn;
|
||||
const totalReceived = totalDividends + totalCoupons;
|
||||
const totalReturnPercent =
|
||||
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
|
||||
|
||||
return {
|
||||
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
||||
totalWithdrawn: Math.round(totalWithdrawn * 100) / 100,
|
||||
netInvested: Math.round(netInvested * 100) / 100,
|
||||
totalDividends: Math.round(totalDividends * 100) / 100,
|
||||
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||
totalReturnPercent,
|
||||
currency: 'RUB',
|
||||
};
|
||||
}
|
||||
}
|
||||
652
docs/features/broker-account-analytics/plan.md
Normal file
652
docs/features/broker-account-analytics/plan.md
Normal file
@ -0,0 +1,652 @@
|
||||
# Аналитика прибыльности брокерского счёта — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development or executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add an analytics tab to the broker account page showing net invested vs received (dividends + coupons).
|
||||
|
||||
**Architecture:** New backend service aggregates BrokerOperation records by type via Prisma, returns DTO. New frontend tab page displays invested/received blocks.
|
||||
|
||||
**Tech Stack:** NestJS + Prisma (SQLite), React + TanStack Query + react-router
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend (new/modified)
|
||||
- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
|
||||
- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — add `GET /analytics` endpoint
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` — register service
|
||||
|
||||
### Frontend (new/modified)
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts`
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts`
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/index.ts`
|
||||
- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx`
|
||||
- Create: `apps/frontend/src/pages/broker-analytics/index.ts`
|
||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
||||
- Modify: `apps/frontend/src/app/routing/routeTree.tsx`
|
||||
- Modify: `apps/frontend/src/shared/api/types.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend DTO и сервис аналитики
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
|
||||
- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
|
||||
|
||||
#### Шаг 1.1: Создать DTO
|
||||
|
||||
```typescript
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BrokerAnalyticsDto {
|
||||
@ApiProperty()
|
||||
totalDeposits!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalWithdrawn!: number;
|
||||
|
||||
@ApiProperty()
|
||||
netInvested!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalDividends!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalCoupons!: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalReceived!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
totalReturnPercent!: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 1.2: Создать сервис `BrokerAnalyticsService`
|
||||
|
||||
```typescript
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
|
||||
const DEPOSIT_TYPES = new Set([
|
||||
'OPERATION_TYPE_INPUT',
|
||||
'OPERATION_TYPE_INPUT_SWIFT',
|
||||
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_INP_MULTI',
|
||||
'OPERATION_TYPE_OVER_PLACEMENT',
|
||||
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||
'OPERATION_TYPE_TRANS_BS_BS',
|
||||
]);
|
||||
|
||||
const WITHDRAWAL_TYPES = new Set([
|
||||
'OPERATION_TYPE_OUTPUT',
|
||||
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||
'OPERATION_TYPE_OUT_MULTI',
|
||||
]);
|
||||
|
||||
const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']);
|
||||
|
||||
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
||||
|
||||
const ANALYTICS_TYPES = new Set([
|
||||
...DEPOSIT_TYPES,
|
||||
...WITHDRAWAL_TYPES,
|
||||
...DIVIDEND_TYPES,
|
||||
...COUPON_TYPES,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAnalyticsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getAnalytics(accountId: string): Promise<{
|
||||
data: BrokerAnalyticsDto;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
return this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.analytics,
|
||||
[accountId],
|
||||
() => this.computeAnalytics(accountId),
|
||||
'tbankAnalyticsTtl',
|
||||
);
|
||||
}
|
||||
|
||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||
const operations = await this.prisma.brokerOperation.findMany({
|
||||
where: {
|
||||
accountId,
|
||||
type: { in: Array.from(ANALYTICS_TYPES) },
|
||||
payment: { not: null },
|
||||
},
|
||||
select: { type: true, payment: true },
|
||||
});
|
||||
|
||||
let totalDeposits = 0;
|
||||
let totalWithdrawn = 0;
|
||||
let totalDividends = 0;
|
||||
let totalCoupons = 0;
|
||||
|
||||
for (const op of operations) {
|
||||
const payment = JSON.parse(op.payment!);
|
||||
const value = payment.value ?? 0;
|
||||
|
||||
if (DEPOSIT_TYPES.has(op.type)) {
|
||||
totalDeposits += value;
|
||||
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
||||
totalWithdrawn += Math.abs(value);
|
||||
} else if (DIVIDEND_TYPES.has(op.type)) {
|
||||
totalDividends += value;
|
||||
} else if (COUPON_TYPES.has(op.type)) {
|
||||
totalCoupons += value;
|
||||
}
|
||||
}
|
||||
|
||||
const netInvested = totalDeposits - totalWithdrawn;
|
||||
const totalReceived = totalDividends + totalCoupons;
|
||||
const totalReturnPercent =
|
||||
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
|
||||
|
||||
return {
|
||||
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
||||
totalWithdrawn: Math.round(totalWithdrawn * 100) / 100,
|
||||
netInvested: Math.round(netInvested * 100) / 100,
|
||||
totalDividends: Math.round(totalDividends * 100) / 100,
|
||||
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||
totalReturnPercent,
|
||||
currency: 'RUB',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 1.3: Проверить сборку
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
```
|
||||
|
||||
#### Шаг 1.4: Закоммитить
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts
|
||||
git commit -m "feat(backend): add broker analytics DTO and service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend controller, module registration и конфиг кеша
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts`
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.module.ts`
|
||||
- Modify: `apps/backend/src/modules/tbank/tbank.config.ts`
|
||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`
|
||||
- Modify: `apps/backend/src/config/configuration.ts`
|
||||
|
||||
#### Шаг 2.1: Добавить cache key
|
||||
|
||||
В `apps/backend/src/modules/tbank/tbank.config.ts`:
|
||||
|
||||
```typescript
|
||||
export const TBANK_CACHE_KEYS = {
|
||||
// ... existing keys
|
||||
analytics: 'tbank:analytics',
|
||||
} as const;
|
||||
```
|
||||
|
||||
#### Шаг 2.2: Добавить TTL config
|
||||
|
||||
В `apps/backend/src/config/configuration.ts`, в секцию `cache`:
|
||||
|
||||
```typescript
|
||||
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
||||
```
|
||||
|
||||
#### Шаг 2.3: Зарегистрировать сервис в `TbankModule`
|
||||
|
||||
В `apps/backend/src/modules/tbank/tbank.module.ts`:
|
||||
- Добавить `BrokerAnalyticsService` в `providers`
|
||||
|
||||
```typescript
|
||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
// ... existing services
|
||||
BrokerAnalyticsService,
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Шаг 2.4: Добавить envelope DTO
|
||||
|
||||
В `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`:
|
||||
|
||||
```typescript
|
||||
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||
|
||||
export class BrokerAnalyticsEnvelopeDto {
|
||||
@ApiProperty({ type: BrokerAnalyticsDto })
|
||||
data!: BrokerAnalyticsDto;
|
||||
|
||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||
meta!: BrokerResponseMetaDto;
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 2.5: Добавить endpoint в `TbankController`
|
||||
|
||||
```typescript
|
||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
import { BrokerAnalyticsEnvelopeDto } from './dto/broker-envelope.dto';
|
||||
|
||||
@Get('accounts/:accountId/analytics')
|
||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||
async getAnalytics(@Param('accountId') accountId: string) {
|
||||
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
```
|
||||
|
||||
Добавить `private readonly brokerAnalyticsService: BrokerAnalyticsService` в конструктор.
|
||||
|
||||
#### Шаг 2.6: Проверить сборку
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
```
|
||||
|
||||
#### Шаг 2.7: Закоммитить
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/tbank/
|
||||
git add apps/backend/src/config/configuration.ts
|
||||
git commit -m "feat(backend): add broker analytics endpoint with caching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — shared types barrel, entity API и хук
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/shared/api/index.ts`
|
||||
- Modify: `apps/frontend/src/shared/api/types.ts` (codegen)
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts`
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts`
|
||||
- Create: `apps/frontend/src/entities/broker-analytics/index.ts`
|
||||
|
||||
#### Шаг 3.1: Добавить тип `BrokerAnalyticsDto` в `types.ts`
|
||||
|
||||
Добавить в `components['schemas']` секцию `shared/api/types.ts`:
|
||||
|
||||
```typescript
|
||||
BrokerAnalyticsDto: {
|
||||
totalDeposits: number
|
||||
totalWithdrawn: number
|
||||
netInvested: number
|
||||
totalDividends: number
|
||||
totalCoupons: number
|
||||
totalReceived: number
|
||||
totalReturnPercent: number | null
|
||||
currency: string
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 3.2: Добавить брокерский тип в barrel export
|
||||
|
||||
В `apps/frontend/src/shared/api/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Broker analytics
|
||||
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
||||
```
|
||||
|
||||
#### Шаг 3.3: Создать API функцию
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts
|
||||
import type { ApiResponseMeta, BrokerAnalytics } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
|
||||
export function getBrokerAnalytics(
|
||||
accountId: string,
|
||||
): Promise<{ data: BrokerAnalytics; meta: ApiResponseMeta }> {
|
||||
return request<BrokerAnalytics>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 3.4: Создать хук
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerAnalytics } from '@/shared/api'
|
||||
import { getBrokerAnalytics } from '../api/brokerAnalyticsApi'
|
||||
|
||||
export function useBrokerAnalytics(accountId: string | undefined) {
|
||||
return useQuery<BrokerAnalytics>({
|
||||
queryKey: ['broker', 'analytics', accountId],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: async () => (await getBrokerAnalytics(accountId!)).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 3.5: Создать barrel export
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/entities/broker-analytics/index.ts
|
||||
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
|
||||
export { useBrokerAnalytics } from './model/useBrokerAnalytics'
|
||||
```
|
||||
|
||||
#### Шаг 3.6: Проверить сборку
|
||||
|
||||
```bash
|
||||
npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
#### Шаг 3.7: Закоммитить
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/entities/broker-analytics/
|
||||
git add apps/frontend/src/shared/api/
|
||||
git commit -m "feat(frontend): add broker analytics data layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — страница аналитики
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx`
|
||||
- Create: `apps/frontend/src/pages/broker-analytics/index.ts`
|
||||
|
||||
#### Шаг 4.1: Создать страницу
|
||||
|
||||
```tsx
|
||||
// apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx
|
||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||
import { useBrokerAnalytics } from '@/entities/broker-analytics';
|
||||
|
||||
function formatAmount(value: number, currency: string): string {
|
||||
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
export function BrokerAnalyticsPage() {
|
||||
const { accountId } = useBrokerAccountContext();
|
||||
const { data, isLoading, isError } = useBrokerAnalytics(accountId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>Загрузка...</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section>
|
||||
<div style={{ padding: '24px', color: 'var(--color-text-negative)' }}>
|
||||
Не удалось загрузить аналитику
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) {
|
||||
return (
|
||||
<section>
|
||||
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>
|
||||
Нет данных для аналитики
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
{/* Вложено */}
|
||||
<div style={{
|
||||
background: 'var(--color-surface-card)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Вложено</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<Row label="Пополнения" value={formatAmount(data.totalDeposits, data.currency)} />
|
||||
<Row label="Выводы" value={`−${formatAmount(data.totalWithdrawn, data.currency)}`} negative />
|
||||
<Divider />
|
||||
<Row label="Нетто" value={formatAmount(data.netInvested, data.currency)} bold />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Получено */}
|
||||
<div style={{
|
||||
background: 'var(--color-surface-card)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Получено</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<Row label="Дивиденды" value={formatAmount(data.totalDividends, data.currency)} positive />
|
||||
<Row label="Купоны" value={formatAmount(data.totalCoupons, data.currency)} positive />
|
||||
<Divider />
|
||||
<Row label="Итого" value={formatAmount(data.totalReceived, data.currency)} bold positive />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сводка */}
|
||||
<div style={{
|
||||
background: 'var(--color-surface-card)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Сводка</h3>
|
||||
<div style={{ display: 'flex', gap: '48px' }}>
|
||||
<SummaryItem label="Вложено нетто" value={formatAmount(data.netInvested, data.currency)} />
|
||||
<SummaryItem label="Получено" value={formatAmount(data.totalReceived, data.currency)} />
|
||||
{data.totalReturnPercent !== null && (
|
||||
<SummaryItem
|
||||
label="Доходность"
|
||||
value={`${data.totalReturnPercent.toFixed(2)}%`}
|
||||
positive
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, bold, positive, negative }: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
positive?: boolean;
|
||||
negative?: boolean;
|
||||
}) {
|
||||
const color = positive
|
||||
? 'var(--color-text-positive, #22c55e)'
|
||||
: negative
|
||||
? 'var(--color-text-negative, #ef4444)'
|
||||
: 'var(--color-text-primary)';
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: '14px' }}>{label}</span>
|
||||
<span style={{ fontWeight: bold ? 600 : 400, color, fontSize: '15px' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: 'var(--color-border)',
|
||||
margin: '4px 0',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ label, value, positive }: {
|
||||
label: string;
|
||||
value: string;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: '13px', marginBottom: '4px' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: positive ? 'var(--color-text-positive, #22c55e)' : 'var(--color-text-primary)',
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 4.2: Создать barrel
|
||||
|
||||
```typescript
|
||||
// apps/frontend/src/pages/broker-analytics/index.ts
|
||||
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'
|
||||
```
|
||||
|
||||
#### Шаг 4.3: Проверить сборку
|
||||
|
||||
```bash
|
||||
npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
#### Шаг 4.4: Закоммитить
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/pages/broker-analytics/
|
||||
git commit -m "feat(frontend): add broker analytics page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — роут и таб навигации
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/app/routing/routeTree.tsx`
|
||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
||||
|
||||
#### Шаг 5.1: Добавить роут
|
||||
|
||||
В `apps/frontend/src/app/routing/routeTree.tsx`:
|
||||
|
||||
```typescript
|
||||
import { BrokerAnalyticsPage } from '@/pages/broker-analytics'
|
||||
|
||||
const brokerAnalyticsRoute = createRoute({
|
||||
getParentRoute: () => brokerAccountRoot,
|
||||
path: '/analytics',
|
||||
component: BrokerAnalyticsPage,
|
||||
})
|
||||
|
||||
// Добавить в brokerAccountRoot.addChildren([...])
|
||||
brokerAccountRoot.addChildren([
|
||||
brokerAccountIndexRoute,
|
||||
brokerSharesRoute,
|
||||
brokerBondsRoute,
|
||||
brokerOperationsRoute,
|
||||
brokerEventsRoute,
|
||||
brokerAnalyticsRoute, // <-- добавить
|
||||
])
|
||||
```
|
||||
|
||||
#### Шаг 5.2: Добавить таб в навигацию
|
||||
|
||||
В `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`:
|
||||
|
||||
```typescript
|
||||
const links = [
|
||||
{ to: '', label: 'Обзор' },
|
||||
{ to: '/shares', label: 'Акции' },
|
||||
{ to: '/bonds', label: 'Облигации' },
|
||||
{ to: '/operations', label: 'Операции' },
|
||||
{ to: '/events', label: 'События' },
|
||||
{ to: '/analytics', label: 'Аналитика' },
|
||||
]
|
||||
```
|
||||
|
||||
#### Шаг 5.3: Проверить сборку
|
||||
|
||||
```bash
|
||||
npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
#### Шаг 5.4: Закоммитить
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/app/routing/routeTree.tsx
|
||||
git add apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx
|
||||
git commit -m "feat(frontend): add analytics route and tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Проверка линта и тестов
|
||||
|
||||
#### Шаг 6.1: Запустить линт и сборку
|
||||
|
||||
```bash
|
||||
npm run lint -w apps/backend && npm run build -w apps/backend
|
||||
npm run lint -w apps/frontend && npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
#### Шаг 6.2: Запустить тесты
|
||||
|
||||
```bash
|
||||
npm test -w apps/backend -- --run
|
||||
npm test -w apps/frontend -- --run
|
||||
```
|
||||
|
||||
#### Шаг 6.3: Если всё ок — закоммитить финальные правки и запушить
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: fix lint and tests"
|
||||
```
|
||||
16
docs/features/broker-account-analytics/tasks.md
Normal file
16
docs/features/broker-account-analytics/tasks.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Аналитика прибыльности брокерского счёта — Tasks
|
||||
|
||||
## Backend
|
||||
|
||||
- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем
|
||||
- [ ] **T2** Controller endpoint, module registration, cache key, config TTL
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics`
|
||||
- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков
|
||||
- [ ] **T5** Роут `/analytics` + таб в навигации
|
||||
|
||||
## Проверка
|
||||
|
||||
- [ ] **T6** Линт, сборка, тесты
|
||||
Loading…
x
Reference in New Issue
Block a user