55 KiB
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.tsapps/backend/src/modules/portfolio/portfolio.controller.tsapps/backend/src/modules/portfolio/portfolio.service.tsapps/backend/src/modules/portfolio/dto/create-portfolio.dto.tsapps/backend/src/modules/portfolio/dto/update-portfolio.dto.tsapps/backend/src/modules/portfolio/dto/portfolio-response.dto.tsapps/backend/src/modules/portfolio/dto/add-position.dto.tsapps/backend/src/modules/portfolio/dto/update-position.dto.tsapps/backend/src/modules/portfolio/dto/position-response.dto.ts
Backend (modified files)
apps/backend/prisma/schema.prisma— add Portfolio + Position modelsapps/backend/src/app.module.ts— add PortfolioModule import
Frontend (new files)
apps/frontend/src/api/portfolio.tsapps/frontend/src/hooks/usePortfolios.tsapps/frontend/src/hooks/usePortfolio.tsapps/frontend/src/hooks/usePortfolioMutations.tsapps/frontend/src/hooks/usePositionMutations.tsapps/frontend/src/pages/portfolios/PortfoliosListPage.tsxapps/frontend/src/pages/portfolios/PortfolioDetailPage.tsxapps/frontend/src/components/portfolios/PortfolioCard.tsxapps/frontend/src/components/portfolios/PortfolioForm.tsxapps/frontend/src/components/portfolios/PositionTable.tsxapps/frontend/src/components/portfolios/PositionRow.tsxapps/frontend/src/components/portfolios/PortfolioSummary.tsxapps/frontend/src/components/portfolios/TargetAllocationEditor.tsxapps/frontend/src/components/portfolios/TagBadge.tsx
Frontend (modified files)
apps/frontend/src/routes.tsx— add portfolio routesapps/frontend/src/components/Layout.tsx— add portfolio nav linkapps/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
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
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
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
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
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
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
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
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
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
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
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
// 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
// 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
// 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
- 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.
- Placeholder scan: No TBD, TODOs, or placeholders.
- Type consistency: All type names match between DTOs, hooks, components, and API client.