codex/remaining-features-completion #46
@ -11,6 +11,24 @@ export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||
@ApiProperty() positionCount!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetSharesPercent?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
targetBondsPercent?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
actualSharesPercent!: number;
|
||||
|
||||
@ApiProperty()
|
||||
actualBondsPercent!: number;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
sharesDeviation?: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
bondsDeviation?: number | null;
|
||||
}
|
||||
|
||||
export class AnalyticsResponseDto {
|
||||
|
||||
@ -9,6 +9,16 @@ export class PortfolioResponseDto {
|
||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: 'object',
|
||||
properties: {
|
||||
sharesPercent: { type: 'number' },
|
||||
bondsPercent: { type: 'number' },
|
||||
},
|
||||
nullable: true,
|
||||
})
|
||||
targets!: { sharesPercent: number; bondsPercent: number } | null;
|
||||
}
|
||||
|
||||
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||
|
||||
@ -1,8 +1,34 @@
|
||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Min,
|
||||
Max,
|
||||
ValidateNested,
|
||||
} 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 PortfolioTargetsDto {
|
||||
@ApiProperty({ example: 70 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
sharesPercent!: number;
|
||||
|
||||
@ApiProperty({ example: 30 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
bondsPercent!: number;
|
||||
}
|
||||
|
||||
export class UpdatePortfolioDto {
|
||||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||
@IsString()
|
||||
@ -22,4 +48,11 @@ export class UpdatePortfolioDto {
|
||||
@IsIn(CURRENCIES)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => PortfolioTargetsDto)
|
||||
targets?: PortfolioTargetsDto;
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ describe('PortfolioService', () => {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
getSecurityDescription: vi.fn(),
|
||||
getDividends: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -7,7 +7,11 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
|
||||
import type {
|
||||
MoexShareMarketData,
|
||||
MoexBondPositionData,
|
||||
MoexDividend,
|
||||
} from '../moex-client/moex-client.types';
|
||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
import { AddPositionDto } from './dto/add-position.dto';
|
||||
@ -59,7 +63,7 @@ export class PortfolioService {
|
||||
) {}
|
||||
|
||||
async create(userId: number, dto: CreatePortfolioDto) {
|
||||
return this.prisma.portfolio.create({
|
||||
const portfolio = await this.prisma.portfolio.create({
|
||||
data: {
|
||||
userId,
|
||||
name: dto.name,
|
||||
@ -67,6 +71,8 @@ export class PortfolioService {
|
||||
currency: dto.currency ?? 'RUB',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...portfolio, targets: null };
|
||||
}
|
||||
|
||||
async findAll(userId: number) {
|
||||
@ -89,6 +95,7 @@ export class PortfolioService {
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -117,6 +124,7 @@ export class PortfolioService {
|
||||
positionCount: positions.length,
|
||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
||||
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -154,6 +162,7 @@ export class PortfolioService {
|
||||
positions: positionsWithWeights,
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
analytics: analytics.summary,
|
||||
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -162,14 +171,20 @@ export class PortfolioService {
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
return this.prisma.portfolio.update({
|
||||
const updated = await 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) }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
targets: updated.targets ? JSON.parse(updated.targets) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(userId: number, id: number) {
|
||||
@ -272,9 +287,10 @@ export class PortfolioService {
|
||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
||||
|
||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
||||
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([
|
||||
this.fetchShareBatch(shareSecids, portfolioId),
|
||||
this.fetchBondBatch(bondSecids, portfolioId),
|
||||
this.fetchDividendsBatch(shareSecids, portfolioId),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
@ -305,7 +321,9 @@ export class PortfolioService {
|
||||
if (pos.type === 'bond') {
|
||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||
} else {
|
||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
||||
enriched.push(
|
||||
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -342,6 +360,26 @@ export class PortfolioService {
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
|
||||
private async fetchDividendsBatch(
|
||||
secids: string[],
|
||||
portfolioId?: number,
|
||||
): Promise<Map<string, MoexDividend[]>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const results = await Promise.all(
|
||||
secids.map(async (secid) => {
|
||||
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'dividends',
|
||||
[cacheKey],
|
||||
() => this.moexClient.getDividends(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return { secid, dividends: data };
|
||||
}),
|
||||
);
|
||||
return new Map(results.map((r) => [r.secid, r.dividends]));
|
||||
}
|
||||
|
||||
private buildSharePosition(
|
||||
pos: {
|
||||
id: number;
|
||||
@ -352,9 +390,16 @@ export class PortfolioService {
|
||||
},
|
||||
base: EnrichedPosition,
|
||||
data: MoexShareMarketData | undefined,
|
||||
dividends?: MoexDividend[],
|
||||
): EnrichedPosition {
|
||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
||||
const dividendIncome = 0;
|
||||
let dividendIncome = 0;
|
||||
if (pos.buyDate && dividends && dividends.length > 0) {
|
||||
const buyDateStr = pos.buyDate.toISOString().split('T')[0];
|
||||
dividendIncome = dividends
|
||||
.filter((d) => d.registryCloseDate >= buyDateStr)
|
||||
.reduce((sum, d) => sum + d.value * pos.quantity, 0);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
@ -494,6 +539,32 @@ export class PortfolioService {
|
||||
)
|
||||
: null;
|
||||
|
||||
let targetSharesPercent: number | null = null;
|
||||
let targetBondsPercent: number | null = null;
|
||||
if (portfolio.targets) {
|
||||
const targets = JSON.parse(portfolio.targets);
|
||||
targetSharesPercent = targets.sharesPercent;
|
||||
targetBondsPercent = targets.bondsPercent;
|
||||
}
|
||||
|
||||
let actualSharesPercent = 0;
|
||||
let actualBondsPercent = 0;
|
||||
if (totalValue > 0) {
|
||||
const shareValue = enrichedPositions
|
||||
.filter((p) => p.type === 'share')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
const bondValue = enrichedPositions
|
||||
.filter((p) => p.type === 'bond')
|
||||
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
|
||||
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sharesDeviation =
|
||||
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
|
||||
const bondsDeviation =
|
||||
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
|
||||
|
||||
const summary = {
|
||||
totalInvested,
|
||||
totalValue,
|
||||
@ -504,6 +575,12 @@ export class PortfolioService {
|
||||
totalReturnPercent,
|
||||
positionCount,
|
||||
weightedYield,
|
||||
targetSharesPercent,
|
||||
targetBondsPercent,
|
||||
actualSharesPercent,
|
||||
actualBondsPercent,
|
||||
sharesDeviation,
|
||||
bondsDeviation,
|
||||
};
|
||||
|
||||
return { positions: enrichedPositions, summary };
|
||||
|
||||
27
apps/backend/src/openapi-artifacts.spec.ts
Normal file
27
apps/backend/src/openapi-artifacts.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
describe('checked-in OpenAPI artifacts', () => {
|
||||
const rootDir = resolve(process.cwd(), '../..');
|
||||
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/shared/api/types.ts'), 'utf8');
|
||||
|
||||
const requiredPaths = [
|
||||
'/api/v1/auth/register',
|
||||
'/api/v1/auth/login',
|
||||
'/api/v1/auth/refresh',
|
||||
'/api/v1/auth/logout',
|
||||
'/api/v1/auth/me',
|
||||
'/api/v1/securities/screener',
|
||||
'/api/v1/portfolios',
|
||||
'/api/v1/portfolios/{id}',
|
||||
'/api/v1/portfolios/{id}/positions',
|
||||
'/api/v1/portfolios/{id}/positions/{positionId}',
|
||||
'/api/v1/portfolios/{id}/analytics',
|
||||
];
|
||||
|
||||
it('frontend generated types include current protected domains', () => {
|
||||
for (const path of requiredPaths) {
|
||||
expect(frontendTypes).toContain(`'${path}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -27,7 +27,12 @@ export function createPortfolio(data: {
|
||||
|
||||
export function updatePortfolio(
|
||||
id: number,
|
||||
data: { name?: string; description?: string; currency?: string },
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
currency?: string
|
||||
targets?: { sharesPercent: number; bondsPercent: number }
|
||||
},
|
||||
): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||||
method: 'PATCH',
|
||||
|
||||
@ -25,6 +25,7 @@ export function usePortfolioMutations() {
|
||||
name?: string
|
||||
description?: string
|
||||
currency?: string
|
||||
targets?: { sharesPercent: number; bondsPercent: number }
|
||||
}
|
||||
}) => updatePortfolio(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
|
||||
@ -103,6 +103,7 @@ export function PortfolioDetailPage() {
|
||||
</h3>
|
||||
<PortfolioForm
|
||||
initial={portfolio}
|
||||
targets={portfolio.targets ?? null}
|
||||
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
||||
onCancel={() => setEditing(false)}
|
||||
isLoading={update.isPending}
|
||||
|
||||
@ -861,6 +861,10 @@ export interface components {
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
targets?: {
|
||||
sharesPercent?: number
|
||||
bondsPercent?: number
|
||||
} | null
|
||||
/** @description Total market value of all positions */
|
||||
totalValue: number
|
||||
/** @description Total number of positions */
|
||||
@ -893,6 +897,10 @@ export interface components {
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
targets?: {
|
||||
sharesPercent?: number
|
||||
bondsPercent?: number
|
||||
} | null
|
||||
}
|
||||
PortfolioEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioResponseDto']
|
||||
@ -948,6 +956,12 @@ export interface components {
|
||||
totalReturnPercent: number | null
|
||||
positionCount: number
|
||||
weightedYield: number | null
|
||||
targetSharesPercent: number | null
|
||||
targetBondsPercent: number | null
|
||||
actualSharesPercent: number
|
||||
actualBondsPercent: number
|
||||
sharesDeviation: number | null
|
||||
bondsDeviation: number | null
|
||||
}
|
||||
PortfolioDetailResponseDto: {
|
||||
id: number
|
||||
@ -957,6 +971,10 @@ export interface components {
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
targets?: {
|
||||
sharesPercent?: number
|
||||
bondsPercent?: number
|
||||
} | null
|
||||
positions: components['schemas']['PositionWithPriceDto'][]
|
||||
totalValue: number
|
||||
analytics: components['schemas']['PortfolioSummaryDto']
|
||||
@ -965,6 +983,12 @@ export interface components {
|
||||
data: components['schemas']['PortfolioDetailResponseDto']
|
||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||
}
|
||||
PortfolioTargetsDto: {
|
||||
/** @example 70 */
|
||||
sharesPercent: number
|
||||
/** @example 30 */
|
||||
bondsPercent: number
|
||||
}
|
||||
UpdatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
name?: string
|
||||
@ -975,6 +999,13 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'
|
||||
/**
|
||||
* @example {
|
||||
* "sharesPercent": 70,
|
||||
* "bondsPercent": 30
|
||||
* }
|
||||
*/
|
||||
targets?: components['schemas']['PortfolioTargetsDto']
|
||||
}
|
||||
AddPositionDto: {
|
||||
/** @example SBER */
|
||||
|
||||
@ -15,7 +15,20 @@ export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
||||
? 'var(--color-negative)'
|
||||
: 'inherit'
|
||||
|
||||
const returnColor =
|
||||
summary.totalReturnPercent != null && summary.totalReturnPercent > 0
|
||||
? 'var(--color-positive)'
|
||||
: summary.totalReturnPercent != null && summary.totalReturnPercent < 0
|
||||
? 'var(--color-negative)'
|
||||
: 'inherit'
|
||||
|
||||
const deviationColor = (val: number | null | undefined) => {
|
||||
if (val == null) return 'inherit'
|
||||
return val > 0 ? 'var(--color-negative)' : val < 0 ? 'var(--color-positive)' : 'inherit'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -63,6 +76,86 @@ export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
||||
{formatPct(summary.weightedYield)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Дивиденды
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{summary.totalDividends > 0 ? '+' : ''}
|
||||
{formatRub(summary.totalDividends)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Общая доходность
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: returnColor }}>
|
||||
{formatPct(summary.totalReturnPercent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.targetSharesPercent != null && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Целевое распределение
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||
Акции: цель {summary.targetSharesPercent}% / факт{' '}
|
||||
{summary.actualSharesPercent.toFixed(1)}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: deviationColor(summary.sharesDeviation),
|
||||
}}
|
||||
>
|
||||
{summary.sharesDeviation != null
|
||||
? `Отклонение: ${summary.sharesDeviation > 0 ? '+' : ''}${summary.sharesDeviation.toFixed(1)}%`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||
Облигации: цель {summary.targetBondsPercent}% / факт{' '}
|
||||
{summary.actualBondsPercent.toFixed(1)}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: deviationColor(summary.bondsDeviation),
|
||||
}}
|
||||
>
|
||||
{summary.bondsDeviation != null
|
||||
? `Отклонение: ${summary.bondsDeviation > 0 ? '+' : ''}${summary.bondsDeviation.toFixed(1)}%`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,22 +3,53 @@ import type { Portfolio } from '@/shared/api'
|
||||
|
||||
interface Props {
|
||||
initial?: Portfolio
|
||||
onSave: (data: { name: string; description?: string; currency?: string }) => void
|
||||
targets?: { sharesPercent: number; bondsPercent: number } | null
|
||||
onSave: (data: {
|
||||
name: string
|
||||
description?: string
|
||||
currency?: string
|
||||
targets?: { sharesPercent: number; bondsPercent: number }
|
||||
}) => void
|
||||
onCancel: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN']
|
||||
|
||||
export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
|
||||
export function PortfolioForm({
|
||||
initial,
|
||||
targets: initialTargets,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const [name, setName] = useState(initial?.name || '')
|
||||
const [description, setDescription] = useState(initial?.description || '')
|
||||
const [currency, setCurrency] = useState(initial?.currency || 'RUB')
|
||||
const [sharesPercent, setSharesPercent] = useState(initialTargets?.sharesPercent ?? 70)
|
||||
const [bondsPercent, setBondsPercent] = useState(initialTargets?.bondsPercent ?? 30)
|
||||
|
||||
function handleSharesChange(value: string) {
|
||||
const num = Math.min(100, Math.max(0, Number(value) || 0))
|
||||
setSharesPercent(num)
|
||||
setBondsPercent(100 - num)
|
||||
}
|
||||
|
||||
function handleBondsChange(value: string) {
|
||||
const num = Math.min(100, Math.max(0, Number(value) || 0))
|
||||
setBondsPercent(num)
|
||||
setSharesPercent(100 - num)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
onSave({ name: name.trim(), description: description.trim() || undefined, currency })
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
currency,
|
||||
targets: { sharesPercent, bondsPercent },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -82,6 +113,51 @@ export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Целевое распределение
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||
Акции %
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sharesPercent}
|
||||
onChange={(e) => handleSharesChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||
Облигации %
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={bondsPercent}
|
||||
onChange={(e) => handleBondsChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user