55 KiB
Raw Blame History

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

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

  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.