1616 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.