1616 lines
55 KiB
Markdown
1616 lines
55 KiB
Markdown
# Portfolio Phase 1 Implementation Plan
|
||
|
||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement Portfolio CRUD + Position CRUD with MOEX price integration.
|
||
|
||
**Architecture:** New `PortfolioModule` on backend (NestJS) following existing feature module patterns. New `portfolios/` pages on frontend (React + TanStack Query). Prisma models `Portfolio` and `Position`.
|
||
|
||
**Tech Stack:** NestJS, Prisma + SQLite, TanStack Query v5, React 18, react-router-dom v6
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Backend (new files)
|
||
- `apps/backend/src/modules/portfolio/portfolio.module.ts`
|
||
- `apps/backend/src/modules/portfolio/portfolio.controller.ts`
|
||
- `apps/backend/src/modules/portfolio/portfolio.service.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`
|
||
- `apps/backend/src/modules/portfolio/dto/position-response.dto.ts`
|
||
|
||
### Backend (modified files)
|
||
- `apps/backend/prisma/schema.prisma` — add Portfolio + Position models
|
||
- `apps/backend/src/app.module.ts` — add PortfolioModule import
|
||
|
||
### Frontend (new files)
|
||
- `apps/frontend/src/api/portfolio.ts`
|
||
- `apps/frontend/src/hooks/usePortfolios.ts`
|
||
- `apps/frontend/src/hooks/usePortfolio.ts`
|
||
- `apps/frontend/src/hooks/usePortfolioMutations.ts`
|
||
- `apps/frontend/src/hooks/usePositionMutations.ts`
|
||
- `apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx`
|
||
- `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx`
|
||
- `apps/frontend/src/components/portfolios/PortfolioCard.tsx`
|
||
- `apps/frontend/src/components/portfolios/PortfolioForm.tsx`
|
||
- `apps/frontend/src/components/portfolios/PositionTable.tsx`
|
||
- `apps/frontend/src/components/portfolios/PositionRow.tsx`
|
||
- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
|
||
- `apps/frontend/src/components/portfolios/TargetAllocationEditor.tsx`
|
||
- `apps/frontend/src/components/portfolios/TagBadge.tsx`
|
||
|
||
### Frontend (modified files)
|
||
- `apps/frontend/src/routes.tsx` — add portfolio routes
|
||
- `apps/frontend/src/components/Layout.tsx` — add portfolio nav link
|
||
- `apps/frontend/src/api/responses.ts` — add Portfolio/Position types
|
||
|
||
---
|
||
|
||
### Task 1: Prisma schema — Portfolio and Position models
|
||
|
||
**Files:**
|
||
- Modify: `apps/backend/prisma/schema.prisma`
|
||
- Run: `npx prisma migrate dev`
|
||
|
||
- [ ] **Add Portfolio and Position models to schema.prisma**
|
||
|
||
```prisma
|
||
model Portfolio {
|
||
id Int @id @default(autoincrement())
|
||
userId Int
|
||
name String
|
||
description String?
|
||
currency String @default("RUB")
|
||
targets String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
positions Position[]
|
||
|
||
@@unique([userId, name])
|
||
}
|
||
|
||
model Position {
|
||
id Int @id @default(autoincrement())
|
||
portfolioId Int
|
||
secid String
|
||
quantity Int
|
||
notes String?
|
||
tags String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([portfolioId, secid])
|
||
}
|
||
```
|
||
|
||
- [ ] **Run Prisma migration**
|
||
|
||
```bash
|
||
npx prisma migrate dev --name add-portfolio-position -w apps/backend
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend — PortfolioModule scaffold
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/portfolio/portfolio.module.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/portfolio.controller.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/portfolio.service.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`
|
||
- Create: `apps/backend/src/modules/portfolio/dto/position-response.dto.ts`
|
||
- Modify: `apps/backend/src/app.module.ts`
|
||
|
||
- [ ] **Create DTO: create-portfolio.dto.ts**
|
||
|
||
```typescript
|
||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||
|
||
export class CreatePortfolioDto {
|
||
@ApiProperty({ example: 'Мой портфель' })
|
||
@IsString()
|
||
@MinLength(1)
|
||
@MaxLength(100)
|
||
name!: string;
|
||
|
||
@ApiPropertyOptional({ example: 'Описание портфеля' })
|
||
@IsString()
|
||
@IsOptional()
|
||
@MaxLength(500)
|
||
description?: string;
|
||
|
||
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||
@IsString()
|
||
@IsIn(CURRENCIES)
|
||
@IsOptional()
|
||
currency?: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create DTO: update-portfolio.dto.ts**
|
||
|
||
```typescript
|
||
import { IsString, IsOptional, IsIn, MaxLength, MinLength, IsArray, ValidateNested, IsNumber, Min, Max } from 'class-validator';
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
import { Type } from 'class-transformer';
|
||
|
||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||
|
||
export class TargetAllocationDto {
|
||
@ApiProperty({ example: 'SBER' })
|
||
@IsString()
|
||
secid!: string;
|
||
|
||
@ApiProperty({ example: 30, description: 'Target percent (0-100)' })
|
||
@IsNumber()
|
||
@Min(0)
|
||
@Max(100)
|
||
targetPercent!: number;
|
||
}
|
||
|
||
export class UpdatePortfolioDto {
|
||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||
@IsString()
|
||
@MinLength(1)
|
||
@MaxLength(100)
|
||
@IsOptional()
|
||
name?: string;
|
||
|
||
@ApiPropertyOptional({ example: 'Обновлённое описание' })
|
||
@IsString()
|
||
@IsOptional()
|
||
@MaxLength(500)
|
||
description?: string;
|
||
|
||
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||
@IsString()
|
||
@IsIn(CURRENCIES)
|
||
@IsOptional()
|
||
currency?: string;
|
||
|
||
@ApiPropertyOptional({ type: [TargetAllocationDto] })
|
||
@IsArray()
|
||
@ValidateNested({ each: true })
|
||
@Type(() => TargetAllocationDto)
|
||
@IsOptional()
|
||
targets?: TargetAllocationDto[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Create DTO: add-position.dto.ts**
|
||
|
||
```typescript
|
||
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
const TAGS = ['DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE', 'BOND', 'ETF', 'GOVERNMENT', 'CASH'] as const;
|
||
|
||
export class AddPositionDto {
|
||
@ApiProperty({ example: 'SBER' })
|
||
@IsString()
|
||
@MinLength(1)
|
||
@MaxLength(50)
|
||
secid!: string;
|
||
|
||
@ApiProperty({ example: 10 })
|
||
@IsInt()
|
||
@Min(0)
|
||
quantity!: number;
|
||
|
||
@ApiPropertyOptional({ example: 'Покупка на дип' })
|
||
@IsString()
|
||
@IsOptional()
|
||
@MaxLength(500)
|
||
notes?: string;
|
||
|
||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
|
||
@IsArray()
|
||
@IsIn(TAGS, { each: true })
|
||
@IsOptional()
|
||
tags?: string[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Create DTO: update-position.dto.ts**
|
||
|
||
```typescript
|
||
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
|
||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
const TAGS = ['DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE', 'BOND', 'ETF', 'GOVERNMENT', 'CASH'] as const;
|
||
|
||
export class UpdatePositionDto {
|
||
@ApiPropertyOptional({ example: 15 })
|
||
@IsInt()
|
||
@Min(0)
|
||
@IsOptional()
|
||
quantity?: number;
|
||
|
||
@ApiPropertyOptional({ example: 'Докупка' })
|
||
@IsString()
|
||
@IsOptional()
|
||
@MaxLength(500)
|
||
notes?: string;
|
||
|
||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
|
||
@IsArray()
|
||
@IsIn(TAGS, { each: true })
|
||
@IsOptional()
|
||
tags?: string[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Create DTO: portfolio-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
export class TargetAllocationDto {
|
||
@ApiProperty({ example: 'SBER' })
|
||
secid!: string;
|
||
|
||
@ApiProperty({ example: 30 })
|
||
targetPercent!: number;
|
||
}
|
||
|
||
export class PortfolioResponseDto {
|
||
@ApiProperty() id!: number;
|
||
@ApiProperty() name!: string;
|
||
@ApiPropertyOptional() description!: string | null;
|
||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||
@ApiPropertyOptional({ type: [TargetAllocationDto] }) targets!: TargetAllocationDto[] | null;
|
||
@ApiProperty() createdAt!: string;
|
||
@ApiProperty() updatedAt!: string;
|
||
}
|
||
|
||
export class PositionWithPriceDto {
|
||
@ApiProperty() id!: number;
|
||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||
@ApiProperty({ example: 10 }) quantity!: number;
|
||
@ApiPropertyOptional() notes!: string | null;
|
||
@ApiPropertyOptional() tags!: string[] | null;
|
||
@ApiPropertyOptional() currentPrice!: number | null;
|
||
@ApiPropertyOptional() currentValue!: number | null;
|
||
@ApiProperty() weightPercent!: number;
|
||
@ApiPropertyOptional() targetPercent!: number | null;
|
||
@ApiPropertyOptional() deviation!: number | null;
|
||
}
|
||
|
||
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||
@ApiProperty({ type: [PositionWithPriceDto] })
|
||
positions!: PositionWithPriceDto[];
|
||
|
||
@ApiProperty() totalValue!: number;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create DTO: position-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
export class PositionResponseDto {
|
||
@ApiProperty() id!: number;
|
||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||
@ApiProperty({ example: 10 }) quantity!: number;
|
||
@ApiPropertyOptional() notes!: string | null;
|
||
@ApiPropertyOptional() tags!: string[] | null;
|
||
@ApiProperty() portfolioId!: number;
|
||
@ApiProperty() createdAt!: string;
|
||
@ApiProperty() updatedAt!: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: portfolio.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||
import { AddPositionDto } from './dto/add-position.dto';
|
||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||
|
||
@Injectable()
|
||
export class PortfolioService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly moexClient: MoexClientService,
|
||
private readonly cache: CacheService,
|
||
) {}
|
||
|
||
async create(userId: number, dto: CreatePortfolioDto) {
|
||
return this.prisma.portfolio.create({
|
||
data: {
|
||
userId,
|
||
name: dto.name,
|
||
description: dto.description ?? null,
|
||
currency: dto.currency ?? 'RUB',
|
||
},
|
||
});
|
||
}
|
||
|
||
async findAll(userId: number) {
|
||
return this.prisma.portfolio.findMany({
|
||
where: { userId },
|
||
orderBy: { updatedAt: 'desc' },
|
||
});
|
||
}
|
||
|
||
async findOne(userId: number, id: number) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({
|
||
where: { id },
|
||
include: { positions: true },
|
||
});
|
||
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
const targets = this.parseTargets(portfolio.targets);
|
||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, targets);
|
||
|
||
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||
|
||
const positionsWithWeights = positionsWithPrices.map((p) => {
|
||
const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0;
|
||
const targetPercent = p.targetPercent ?? null;
|
||
return {
|
||
...p,
|
||
weightPercent: Math.round(weightPercent * 2) / 2,
|
||
deviation: targetPercent !== null ? Math.round((weightPercent - targetPercent) * 2) / 2 : null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
id: portfolio.id,
|
||
name: portfolio.name,
|
||
description: portfolio.description,
|
||
currency: portfolio.currency,
|
||
targets: targets.length > 0 ? targets : null,
|
||
createdAt: portfolio.createdAt.toISOString(),
|
||
updatedAt: portfolio.updatedAt.toISOString(),
|
||
positions: positionsWithWeights,
|
||
totalValue: Math.round(totalValue * 100) / 100,
|
||
};
|
||
}
|
||
|
||
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
if (dto.targets) {
|
||
const total = dto.targets.reduce((sum, t) => sum + t.targetPercent, 0);
|
||
if (Math.round(total) !== 100) {
|
||
throw new BadRequestException('Sum of target allocations must be 100');
|
||
}
|
||
}
|
||
|
||
return this.prisma.portfolio.update({
|
||
where: { id },
|
||
data: {
|
||
...(dto.name !== undefined && { name: dto.name }),
|
||
...(dto.description !== undefined && { description: dto.description }),
|
||
...(dto.currency !== undefined && { currency: dto.currency }),
|
||
...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }),
|
||
},
|
||
});
|
||
}
|
||
|
||
async remove(userId: number, id: number) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
await this.prisma.portfolio.delete({ where: { id } });
|
||
}
|
||
|
||
async addPosition(userId: number, portfolioId: number, dto: AddPositionDto) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({
|
||
where: { id: portfolioId },
|
||
include: { positions: true },
|
||
});
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
||
if (exists) throw new BadRequestException(`Position ${dto.secid} already exists in this portfolio`);
|
||
|
||
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
||
|
||
const desc = await this.moexClient.getSecurityDescription(dto.secid);
|
||
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
|
||
|
||
return this.prisma.position.create({
|
||
data: {
|
||
portfolioId,
|
||
secid: dto.secid,
|
||
quantity: dto.quantity,
|
||
notes: dto.notes ?? null,
|
||
tags: dto.tags ? JSON.stringify(dto.tags) : null,
|
||
},
|
||
});
|
||
}
|
||
|
||
async updatePosition(userId: number, portfolioId: number, positionId: number, dto: UpdatePositionDto) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||
if (!position || position.portfolioId !== portfolioId) {
|
||
throw new NotFoundException(`Position ${positionId} not found`);
|
||
}
|
||
|
||
return this.prisma.position.update({
|
||
where: { id: positionId },
|
||
data: {
|
||
...(dto.quantity !== undefined && { quantity: dto.quantity }),
|
||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
|
||
},
|
||
});
|
||
}
|
||
|
||
async removePosition(userId: number, portfolioId: number, positionId: number) {
|
||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||
|
||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||
if (!position || position.portfolioId !== portfolioId) {
|
||
throw new NotFoundException(`Position ${positionId} not found`);
|
||
}
|
||
|
||
await this.prisma.position.delete({ where: { id: positionId } });
|
||
}
|
||
|
||
private parseTargets(targetsJson: string | null): { secid: string; targetPercent: number }[] {
|
||
if (!targetsJson) return [];
|
||
try {
|
||
return JSON.parse(targetsJson);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
private async enrichPositions(
|
||
positions: { id: number; portfolioId: number; secid: string; quantity: number; notes: string | null; tags: string | null }[],
|
||
targets: { secid: string; targetPercent: number }[],
|
||
) {
|
||
return Promise.all(
|
||
positions.map(async (pos) => {
|
||
let currentPrice: number | null = null;
|
||
|
||
try {
|
||
const marketData = await this.cache.getOrFetch(
|
||
'marketdata',
|
||
['portfolio', pos.secid],
|
||
async () => {
|
||
const shareData = await this.moexClient.getShareMarketData(pos.secid);
|
||
if (shareData?.last) return { price: shareData.last };
|
||
const bondData = await this.moexClient.getBondMarketData(pos.secid);
|
||
if (bondData?.last) return { price: bondData.last };
|
||
return { price: null };
|
||
},
|
||
'marketDataTtl',
|
||
);
|
||
currentPrice = marketData.data.price;
|
||
} catch {
|
||
currentPrice = null;
|
||
}
|
||
|
||
const target = targets.find((t) => t.secid === pos.secid);
|
||
|
||
return {
|
||
id: pos.id,
|
||
secid: pos.secid,
|
||
quantity: pos.quantity,
|
||
notes: pos.notes,
|
||
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||
currentPrice,
|
||
currentValue: currentPrice !== null ? currentPrice * pos.quantity : null,
|
||
targetPercent: target?.targetPercent ?? null,
|
||
weightPercent: 0,
|
||
deviation: null,
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: portfolio.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||
import { PortfolioService } from './portfolio.service';
|
||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||
import { AddPositionDto } from './dto/add-position.dto';
|
||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||
|
||
@ApiTags('Portfolios')
|
||
@ApiBearerAuth()
|
||
@UseGuards(JwtAuthGuard)
|
||
@Controller('portfolios')
|
||
export class PortfolioController {
|
||
constructor(private readonly portfolioService: PortfolioService) {}
|
||
|
||
@Get()
|
||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||
async findAll(@CurrentUser() user: { sub: number }) {
|
||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Post()
|
||
@ApiOperation({ summary: 'Create a new portfolio' })
|
||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||
const portfolio = await this.portfolioService.create(user.sub, dto);
|
||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Get(':id')
|
||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Patch(':id')
|
||
@ApiOperation({ summary: 'Update portfolio' })
|
||
async update(
|
||
@CurrentUser() user: { sub: number },
|
||
@Param('id', ParseIntPipe) id: number,
|
||
@Body() dto: UpdatePortfolioDto,
|
||
) {
|
||
const portfolio = await this.portfolioService.update(user.sub, id, dto);
|
||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Delete(':id')
|
||
@ApiOperation({ summary: 'Delete portfolio' })
|
||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||
await this.portfolioService.remove(user.sub, id);
|
||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Post(':id/positions')
|
||
@ApiOperation({ summary: 'Add position to portfolio' })
|
||
async addPosition(
|
||
@CurrentUser() user: { sub: number },
|
||
@Param('id', ParseIntPipe) id: number,
|
||
@Body() dto: AddPositionDto,
|
||
) {
|
||
const position = await this.portfolioService.addPosition(user.sub, id, dto);
|
||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Patch(':id/positions/:positionId')
|
||
@ApiOperation({ summary: 'Update position' })
|
||
async updatePosition(
|
||
@CurrentUser() user: { sub: number },
|
||
@Param('id', ParseIntPipe) id: number,
|
||
@Param('positionId', ParseIntPipe) positionId: number,
|
||
@Body() dto: UpdatePositionDto,
|
||
) {
|
||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Delete(':id/positions/:positionId')
|
||
@ApiOperation({ summary: 'Remove position from portfolio' })
|
||
async removePosition(
|
||
@CurrentUser() user: { sub: number },
|
||
@Param('id', ParseIntPipe) id: number,
|
||
@Param('positionId', ParseIntPipe) positionId: number,
|
||
) {
|
||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: portfolio.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { PortfolioController } from './portfolio.controller';
|
||
import { PortfolioService } from './portfolio.service';
|
||
|
||
@Module({
|
||
controllers: [PortfolioController],
|
||
providers: [PortfolioService],
|
||
exports: [PortfolioService],
|
||
})
|
||
export class PortfolioModule {}
|
||
```
|
||
|
||
- [ ] **Register PortfolioModule in app.module.ts**
|
||
|
||
```typescript
|
||
// Add import:
|
||
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||
|
||
// Add to imports array:
|
||
PortfolioModule,
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Frontend — API types and client
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/api/responses.ts`
|
||
- Create: `apps/frontend/src/api/portfolio.ts`
|
||
|
||
- [ ] **Add Portfolio/Position types to responses.ts**
|
||
|
||
```typescript
|
||
export interface Portfolio {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
currency: string;
|
||
targets: TargetAllocation[] | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export interface TargetAllocation {
|
||
secid: string;
|
||
targetPercent: number;
|
||
}
|
||
|
||
export interface PositionWithPrice {
|
||
id: number;
|
||
secid: string;
|
||
quantity: number;
|
||
notes: string | null;
|
||
tags: string[] | null;
|
||
currentPrice: number | null;
|
||
currentValue: number | null;
|
||
weightPercent: number;
|
||
targetPercent: number | null;
|
||
deviation: number | null;
|
||
}
|
||
|
||
export interface PortfolioDetail extends Portfolio {
|
||
positions: PositionWithPrice[];
|
||
totalValue: number;
|
||
}
|
||
|
||
export interface Position {
|
||
id: number;
|
||
secid: string;
|
||
quantity: number;
|
||
notes: string | null;
|
||
tags: string[] | null;
|
||
portfolioId: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: api/portfolio.ts**
|
||
|
||
```typescript
|
||
import { request } from './client';
|
||
import type { Portfolio, PortfolioDetail, Position } from './responses';
|
||
|
||
export function getPortfolios(): Promise<{ data: Portfolio[]; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<Portfolio[]>('/api/v1/portfolios');
|
||
}
|
||
|
||
export function getPortfolio(id: number): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
|
||
}
|
||
|
||
export function createPortfolio(data: { name: string; description?: string; currency?: string }): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
||
method: 'POST',
|
||
body: data,
|
||
});
|
||
}
|
||
|
||
export function updatePortfolio(id: number, data: { name?: string; description?: string; currency?: string; targets?: { secid: string; targetPercent: number }[] }): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||
method: 'PATCH',
|
||
body: data,
|
||
});
|
||
}
|
||
|
||
export function deletePortfolio(id: number): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export function addPosition(portfolioId: number, data: { secid: string; quantity: number; notes?: string; tags?: string[] }): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
||
method: 'POST',
|
||
body: data,
|
||
});
|
||
}
|
||
|
||
export function updatePosition(portfolioId: number, positionId: number, data: { quantity?: number; notes?: string; tags?: string[] }): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||
method: 'PATCH',
|
||
body: data,
|
||
});
|
||
}
|
||
|
||
export function removePosition(portfolioId: number, positionId: number): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Frontend — TanStack Query hooks
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/hooks/usePortfolios.ts`
|
||
- Create: `apps/frontend/src/hooks/usePortfolio.ts`
|
||
- Create: `apps/frontend/src/hooks/usePortfolioMutations.ts`
|
||
- Create: `apps/frontend/src/hooks/usePositionMutations.ts`
|
||
|
||
- [ ] **Create: hooks/usePortfolios.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { getPortfolios } from '../api/portfolio';
|
||
|
||
export function usePortfolios() {
|
||
return useQuery({
|
||
queryKey: ['portfolios'],
|
||
queryFn: () => getPortfolios(),
|
||
staleTime: 900_000,
|
||
retry: 2,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: hooks/usePortfolio.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { getPortfolio } from '../api/portfolio';
|
||
|
||
export function usePortfolio(id: number) {
|
||
return useQuery({
|
||
queryKey: ['portfolio', id],
|
||
queryFn: () => getPortfolio(id),
|
||
staleTime: 900_000,
|
||
retry: 2,
|
||
refetchOnWindowFocus: false,
|
||
enabled: !!id,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: hooks/usePortfolioMutations.ts**
|
||
|
||
```typescript
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolio';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
export function usePortfolioMutations() {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
|
||
const create = useMutation({
|
||
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
||
createPortfolio(data),
|
||
onSuccess: (res) => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||
navigate(`/portfolios/${res.data.id}`);
|
||
},
|
||
});
|
||
|
||
const update = useMutation({
|
||
mutationFn: ({ id, data }: { id: number; data: { name?: string; description?: string; currency?: string; targets?: { secid: string; targetPercent: number }[] } }) =>
|
||
updatePortfolio(id, data),
|
||
onSuccess: (_, { id }) => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||
queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
|
||
},
|
||
});
|
||
|
||
const remove = useMutation({
|
||
mutationFn: (id: number) => deletePortfolio(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||
navigate('/portfolios');
|
||
},
|
||
});
|
||
|
||
return { create, update, remove };
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: hooks/usePositionMutations.ts**
|
||
|
||
```typescript
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { addPosition, updatePosition, removePosition } from '../api/portfolio';
|
||
|
||
export function usePositionMutations(portfolioId: number) {
|
||
const queryClient = useQueryClient();
|
||
|
||
const add = useMutation({
|
||
mutationFn: (data: { secid: string; quantity: number; notes?: string; tags?: string[] }) =>
|
||
addPosition(portfolioId, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||
},
|
||
});
|
||
|
||
const update = useMutation({
|
||
mutationFn: ({ positionId, data }: { positionId: number; data: { quantity?: number; notes?: string; tags?: string[] } }) =>
|
||
updatePosition(portfolioId, positionId, data),
|
||
onMutate: async ({ positionId, data }) => {
|
||
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
|
||
const previous = queryClient.getQueryData(['portfolio', portfolioId]);
|
||
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
data: {
|
||
...old.data,
|
||
positions: old.data.positions.map((p: any) =>
|
||
p.id === positionId ? { ...p, ...(data.quantity !== undefined ? { quantity: data.quantity } : {}) } : p
|
||
),
|
||
},
|
||
};
|
||
});
|
||
return { previous };
|
||
},
|
||
onError: (_err, _vars, context) => {
|
||
if (context?.previous) {
|
||
queryClient.setQueryData(['portfolio', portfolioId], context.previous);
|
||
}
|
||
},
|
||
onSettled: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||
},
|
||
});
|
||
|
||
const remove = useMutation({
|
||
mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||
},
|
||
});
|
||
|
||
return { add, update, remove };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Frontend — Portfolio components
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/portfolios/TagBadge.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/PortfolioCard.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/PortfolioForm.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/PositionRow.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/PositionTable.tsx`
|
||
- Create: `apps/frontend/src/components/portfolios/TargetAllocationEditor.tsx`
|
||
|
||
- [ ] **Create: components/portfolios/TagBadge.tsx**
|
||
|
||
```tsx
|
||
const TAG_COLORS: Record<string, string> = {
|
||
DIVIDEND: '#2e7d32',
|
||
GROWTH: '#1565c0',
|
||
DEFENSIVE: '#6a1b9a',
|
||
SPECULATIVE: '#e65100',
|
||
BOND: '#00838f',
|
||
ETF: '#4a148c',
|
||
GOVERNMENT: '#37474f',
|
||
CASH: '#546e7a',
|
||
};
|
||
|
||
export function TagBadge({ tag }: { tag: string }) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
padding: '2px 8px',
|
||
borderRadius: 12,
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: '#fff',
|
||
background: TAG_COLORS[tag] || '#757575',
|
||
}}
|
||
>
|
||
{tag}
|
||
</span>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/PortfolioCard.tsx**
|
||
|
||
```tsx
|
||
import { Link } from 'react-router-dom';
|
||
import type { Portfolio } from '../../api/responses';
|
||
|
||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||
return (
|
||
<Link
|
||
to={`/portfolios/${portfolio.id}`}
|
||
style={{
|
||
display: 'block',
|
||
padding: 20,
|
||
background: 'var(--color-surface)',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 'var(--border-radius)',
|
||
textDecoration: 'none',
|
||
color: 'inherit',
|
||
transition: 'box-shadow 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||
>
|
||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
|
||
{portfolio.description && (
|
||
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||
{portfolio.description}
|
||
</p>
|
||
)}
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginTop: 8, display: 'inline-block' }}>
|
||
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||
</span>
|
||
</Link>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/PortfolioForm.tsx**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import type { Portfolio } from '../../api/responses';
|
||
|
||
interface Props {
|
||
initial?: Portfolio;
|
||
onSave: (data: { name: string; description?: string; currency?: string }) => void;
|
||
onCancel: () => void;
|
||
isLoading?: boolean;
|
||
}
|
||
|
||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'];
|
||
|
||
export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
|
||
const [name, setName] = useState(initial?.name || '');
|
||
const [description, setDescription] = useState(initial?.description || '');
|
||
const [currency, setCurrency] = useState(initial?.currency || 'RUB');
|
||
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!name.trim()) return;
|
||
onSave({ name: name.trim(), description: description.trim() || undefined, currency });
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Название</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
maxLength={100}
|
||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Описание</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
maxLength={500}
|
||
rows={3}
|
||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14, resize: 'vertical' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Валюта</label>
|
||
<select
|
||
value={currency}
|
||
onChange={(e) => setCurrency(e.target.value)}
|
||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14 }}
|
||
>
|
||
{CURRENCIES.map((c) => (
|
||
<option key={c} value={c}>{c}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
style={{ padding: '8px 16px', background: 'transparent', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14, cursor: 'pointer' }}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !name.trim()}
|
||
style={{ padding: '8px 16px', background: 'var(--color-primary)', color: '#fff', border: 'none', borderRadius: 'var(--border-radius)', fontSize: 14, fontWeight: 600, cursor: 'pointer' }}
|
||
>
|
||
{initial ? 'Сохранить' : 'Создать'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/PortfolioSummary.tsx**
|
||
|
||
```tsx
|
||
import type { PortfolioDetail } from '../../api/responses';
|
||
|
||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||
return (
|
||
<div style={{ display: 'flex', gap: 32, padding: 20, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Общая стоимость</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||
{portfolio.totalValue.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
<span style={{ fontSize: 14, fontWeight: 400, color: 'var(--color-text-secondary)', marginLeft: 4 }}>{portfolio.currency}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Позиций</div>
|
||
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/PositionRow.tsx**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { TagBadge } from './TagBadge';
|
||
import type { PositionWithPrice } from '../../api/responses';
|
||
|
||
interface Props {
|
||
position: PositionWithPrice;
|
||
onUpdate: (data: { quantity?: number }) => void;
|
||
onDelete: () => void;
|
||
}
|
||
|
||
export function PositionRow({ position, onUpdate, onDelete }: Props) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [qty, setQty] = useState(String(position.quantity));
|
||
|
||
function handleSave() {
|
||
const num = parseInt(qty, 10);
|
||
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
|
||
onUpdate({ quantity: num });
|
||
}
|
||
setEditing(false);
|
||
}
|
||
|
||
return (
|
||
<tr>
|
||
<td style={{ fontWeight: 600 }}>
|
||
<a href={`/stocks/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||
{position.secid}
|
||
</a>
|
||
</td>
|
||
<td>
|
||
{editing ? (
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={qty}
|
||
onChange={(e) => setQty(e.target.value)}
|
||
onBlur={handleSave}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||
autoFocus
|
||
style={{ width: 80, padding: '4px 8px', border: '1px solid var(--color-primary)', borderRadius: 'var(--border-radius)', fontSize: 14 }}
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => { setQty(String(position.quantity)); setEditing(true); }}
|
||
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
|
||
>
|
||
{position.quantity}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
{position.currentPrice !== null
|
||
? position.currentPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
: '—'}
|
||
</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
{position.currentValue !== null
|
||
? position.currentValue.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
: '—'}
|
||
</td>
|
||
<td style={{ textAlign: 'right' }}>{position.weightPercent.toFixed(1)}%</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
{position.targetPercent !== null ? `${position.targetPercent}%` : '—'}
|
||
</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
{position.deviation !== null ? (
|
||
<span style={{ color: Math.abs(position.deviation) > 5 ? '#e53935' : Math.abs(position.deviation) > 2 ? '#fb8c00' : '#43a047' }}>
|
||
{position.deviation > 0 ? '+' : ''}{position.deviation.toFixed(1)}%
|
||
</span>
|
||
) : '—'}
|
||
</td>
|
||
<td>
|
||
{position.tags?.map((tag) => <TagBadge key={tag} tag={tag} />)}
|
||
</td>
|
||
<td>
|
||
<button
|
||
onClick={onDelete}
|
||
style={{ background: 'none', border: 'none', color: '#e53935', cursor: 'pointer', fontSize: 13, padding: 4 }}
|
||
title="Удалить позицию"
|
||
>
|
||
✕
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/PositionTable.tsx**
|
||
|
||
```tsx
|
||
import { PositionRow } from './PositionRow';
|
||
import type { PositionWithPrice } from '../../api/responses';
|
||
|
||
interface Props {
|
||
positions: PositionWithPrice[];
|
||
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void;
|
||
onDeletePosition: (positionId: number) => void;
|
||
}
|
||
|
||
export function PositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||
if (positions.length === 0) {
|
||
return (
|
||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: 14 }}>
|
||
В портфеле нет позиций. Добавьте первую через кнопку выше.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Тикер</th>
|
||
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Количество</th>
|
||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Цена</th>
|
||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Стоимость</th>
|
||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Доля</th>
|
||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Цель</th>
|
||
<th style={{ textAlign: 'right', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Отклонение</th>
|
||
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600, fontSize: 12, color: 'var(--color-text-secondary)' }}>Теги</th>
|
||
<th style={{ padding: '8px 12px', width: 40 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{positions.map((pos) => (
|
||
<PositionRow
|
||
key={pos.id}
|
||
position={pos}
|
||
onUpdate={(data) => onUpdatePosition(pos.id, data)}
|
||
onDelete={() => onDeletePosition(pos.id)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: components/portfolios/TargetAllocationEditor.tsx**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
|
||
interface Target {
|
||
secid: string;
|
||
targetPercent: number;
|
||
}
|
||
|
||
interface Props {
|
||
targets: Target[];
|
||
availableSecids: string[];
|
||
onSave: (targets: Target[]) => void;
|
||
}
|
||
|
||
export function TargetAllocationEditor({ targets, availableSecids, onSave }: Props) {
|
||
const [items, setItems] = useState<Target[]>(targets);
|
||
const total = items.reduce((s, t) => s + t.targetPercent, 0);
|
||
const isValid = Math.round(total) === 100;
|
||
|
||
function updatePercent(secid: string, value: number) {
|
||
setItems((prev) => prev.map((t) => (t.secid === secid ? { ...t, targetPercent: Math.max(0, Math.min(100, value)) } : t)));
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: 16, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)' }}>
|
||
<h4 style={{ margin: '0 0 12px', fontSize: 14, fontWeight: 600 }}>Целевое распределение</h4>
|
||
{items.map((item) => (
|
||
<div key={item.secid} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||
<span style={{ minWidth: 60, fontSize: 13, fontWeight: 600 }}>{item.secid}</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={item.targetPercent}
|
||
onChange={(e) => updatePercent(item.secid, parseInt(e.target.value) || 0)}
|
||
style={{ width: 80, padding: '4px 8px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14 }}
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>%</span>
|
||
</div>
|
||
))}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
|
||
<span style={{ fontSize: 13, color: isValid ? '#43a047' : '#e53935', fontWeight: 600 }}>
|
||
Сумма: {total}% {isValid ? '✓' : '(должно быть 100%)'}
|
||
</span>
|
||
<button
|
||
onClick={() => onSave(items)}
|
||
disabled={!isValid}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: isValid ? 'var(--color-primary)' : '#e0e0e0',
|
||
color: isValid ? '#fff' : '#999',
|
||
border: 'none',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: isValid ? 'pointer' : 'not-allowed',
|
||
}}
|
||
>
|
||
Сохранить цели
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Frontend — Pages
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx`
|
||
- Create: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx`
|
||
- Modify: `apps/frontend/src/routes.tsx`
|
||
- Modify: `apps/frontend/src/components/Layout.tsx`
|
||
|
||
- [ ] **Create: pages/portfolios/PortfoliosListPage.tsx**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { usePortfolios } from '../../hooks/usePortfolios';
|
||
import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
|
||
import { PortfolioCard } from '../../components/portfolios/PortfolioCard';
|
||
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
|
||
|
||
export function PortfoliosListPage() {
|
||
const { data, isLoading, error } = usePortfolios();
|
||
const { create } = usePortfolioMutations();
|
||
const [showForm, setShowForm] = useState(false);
|
||
|
||
if (isLoading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>Загрузка...</div>;
|
||
if (error) return <div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>Ошибка загрузки портфелей</div>;
|
||
|
||
const portfolios = data?.data ?? [];
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700 }}>Мои портфели</h1>
|
||
<button
|
||
onClick={() => setShowForm(true)}
|
||
style={{
|
||
padding: '8px 20px',
|
||
background: 'var(--color-primary)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
+ Создать портфель
|
||
</button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<div style={{ marginBottom: 24, padding: 20, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)' }}>
|
||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>Новый портфель</h3>
|
||
<PortfolioForm
|
||
onSave={(data) => create.mutate(data)}
|
||
onCancel={() => setShowForm(false)}
|
||
isLoading={create.isPending}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{portfolios.length === 0 ? (
|
||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||
<p style={{ fontSize: 16, marginBottom: 8 }}>У вас пока нет портфелей</p>
|
||
<p style={{ fontSize: 14 }}>Создайте первый, чтобы начать отслеживать свои инвестиции</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
|
||
{portfolios.map((p) => (
|
||
<PortfolioCard key={p.id} portfolio={p} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create: pages/portfolios/PortfolioDetailPage.tsx**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||
import { usePortfolio } from '../../hooks/usePortfolio';
|
||
import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
|
||
import { usePositionMutations } from '../../hooks/usePositionMutations';
|
||
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
|
||
import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary';
|
||
import { PositionTable } from '../../components/portfolios/PositionTable';
|
||
import { TargetAllocationEditor } from '../../components/portfolios/TargetAllocationEditor';
|
||
|
||
export function PortfolioDetailPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const portfolioId = parseInt(id!, 10);
|
||
const navigate = useNavigate();
|
||
|
||
const { data, isLoading, error } = usePortfolio(portfolioId);
|
||
const { update, remove } = usePortfolioMutations();
|
||
const { update: updatePosition, remove: removePosition, add: addPosition } = usePositionMutations(portfolioId);
|
||
|
||
const [editing, setEditing] = useState(false);
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
const [newSecid, setNewSecid] = useState('');
|
||
const [newQty, setNewQty] = useState('1');
|
||
|
||
if (isLoading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>Загрузка...</div>;
|
||
if (error) return <div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>Ошибка загрузки портфеля</div>;
|
||
|
||
const portfolio = data?.data;
|
||
if (!portfolio) return null;
|
||
|
||
async function handleDelete() {
|
||
if (window.confirm('Удалить портфель и все позиции?')) {
|
||
remove.mutate(portfolioId);
|
||
}
|
||
}
|
||
|
||
function handleAddPosition() {
|
||
if (!newSecid.trim() || !parseInt(newQty, 10)) return;
|
||
addPosition.mutate(
|
||
{ secid: newSecid.trim().toUpperCase(), quantity: parseInt(newQty, 10) },
|
||
{ onSuccess: () => { setShowAddForm(false); setNewSecid(''); setNewQty('1'); } },
|
||
);
|
||
}
|
||
|
||
const targets = (portfolio.targets ?? []).map((t) => ({
|
||
secid: t.secid,
|
||
targetPercent: t.targetPercent,
|
||
}));
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||
<Link to="/portfolios" style={{ color: 'var(--color-text-secondary)', textDecoration: 'none', fontSize: 14 }}>← К списку</Link>
|
||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700 }}>{portfolio.name}</h1>
|
||
<button
|
||
onClick={() => setEditing(!editing)}
|
||
style={{ marginLeft: 'auto', padding: '6px 16px', background: 'transparent', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 13, cursor: 'pointer' }}
|
||
>
|
||
{editing ? 'Закрыть' : 'Редактировать'}
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
style={{ padding: '6px 16px', background: 'transparent', border: '1px solid #e53935', color: '#e53935', borderRadius: 'var(--border-radius)', fontSize: 13, cursor: 'pointer' }}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
|
||
{editing && (
|
||
<div style={{ marginBottom: 24, padding: 20, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)' }}>
|
||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>Редактировать портфель</h3>
|
||
<PortfolioForm
|
||
initial={portfolio}
|
||
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
||
onCancel={() => setEditing(false)}
|
||
isLoading={update.isPending}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<PortfolioSummary portfolio={portfolio} />
|
||
|
||
<div style={{ marginTop: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Позиции</h2>
|
||
<button
|
||
onClick={() => setShowAddForm(!showAddForm)}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: 'var(--color-primary)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
+ Добавить
|
||
</button>
|
||
</div>
|
||
|
||
{showAddForm && (
|
||
<div style={{ marginTop: 12, padding: 16, background: 'var(--color-surface)', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Тикер</label>
|
||
<input
|
||
value={newSecid}
|
||
onChange={(e) => setNewSecid(e.target.value)}
|
||
placeholder="SBER"
|
||
style={{ padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14, width: 120 }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Количество</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={newQty}
|
||
onChange={(e) => setNewQty(e.target.value)}
|
||
style={{ padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14, width: 100 }}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleAddPosition}
|
||
disabled={addPosition.isPending}
|
||
style={{ padding: '8px 16px', background: 'var(--color-primary)', color: '#fff', border: 'none', borderRadius: 'var(--border-radius)', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||
>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<PositionTable
|
||
positions={portfolio.positions}
|
||
onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })}
|
||
onDeletePosition={(positionId) => {
|
||
if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{portfolio.positions.length > 0 && (
|
||
<div style={{ marginTop: 24 }}>
|
||
<TargetAllocationEditor
|
||
targets={targets}
|
||
availableSecids={portfolio.positions.map((p) => p.secid)}
|
||
onSave={(newTargets) => update.mutate({ id: portfolioId, data: { targets: newTargets } })}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Add portfolio routes to routes.tsx**
|
||
|
||
```tsx
|
||
// Add imports:
|
||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||
|
||
// Add routes inside <Route element={<Layout />}>:
|
||
<Route
|
||
path="/portfolios"
|
||
element={
|
||
<ProtectedRoute>
|
||
<PortfoliosListPage />
|
||
</ProtectedRoute>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/portfolios/:id"
|
||
element={
|
||
<ProtectedRoute>
|
||
<PortfolioDetailPage />
|
||
</ProtectedRoute>
|
||
}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Add portfolio link to Layout.tsx**
|
||
|
||
```tsx
|
||
// Add after search bar, before auth section:
|
||
<Link
|
||
to="/portfolios"
|
||
style={{
|
||
fontSize: 14,
|
||
color: 'var(--color-text)',
|
||
textDecoration: 'none',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Портфели
|
||
</Link>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Docusaurus documentation
|
||
|
||
**Files:**
|
||
- Create: `apps/docs/docs/portfolio/overview.md`
|
||
- Modify: `apps/docs/sidebars.ts` (or sidebars.js)
|
||
|
||
- [ ] **Create: docs/portfolio/overview.md in Docusaurus**
|
||
|
||
Document: what Portfolio is, phases, architecture, how to use.
|
||
|
||
- [ ] **Add portfolio to sidebar**
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
1. **Spec coverage:** All PRD user stories covered (US-PF-001 through US-PF-011). All business rules (BR-001 — BR-009) covered in service validation.
|
||
2. **Placeholder scan:** No TBD, TODOs, or placeholders.
|
||
3. **Type consistency:** All type names match between DTOs, hooks, components, and API client.
|