# 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('/api/v1/portfolios'); } export function getPortfolio(id: number): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/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('/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(`/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(`/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(`/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(`/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(`/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 = { DIVIDEND: '#2e7d32', GROWTH: '#1565c0', DEFENSIVE: '#6a1b9a', SPECULATIVE: '#e65100', BOND: '#00838f', ETF: '#4a148c', GOVERNMENT: '#37474f', CASH: '#546e7a', }; export function TagBadge({ tag }: { tag: string }) { return ( {tag} ); } ``` - [ ] **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 ( (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} >

{portfolio.name}

{portfolio.description && (

{portfolio.description}

)} {portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} ); } ``` - [ ] **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 (
setName(e.target.value)} required maxLength={100} style={{ width: '100%', padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14 }} />