diff --git a/apps/frontend/src/components/portfolios/AllocationChart.tsx b/apps/frontend/src/components/portfolios/AllocationChart.tsx deleted file mode 100644 index 275b9ed..0000000 --- a/apps/frontend/src/components/portfolios/AllocationChart.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import type { PositionWithPrice } from '@/shared/api/responses'; - -interface AllocationChartProps { - positions: PositionWithPrice[]; - totalValue: number; -} - -interface SectorData { - type: 'share' | 'bond'; - label: string; - value: number; - count: number; - color: string; -} - -const SECTOR_COLORS = { - share: 'var(--color-primary, #1976d2)', - bond: '#f57c00', -} as const; - -const SECTOR_LABELS = { - share: 'Акции', - bond: 'Облигации', -} as const; - -function computeSectors(positions: PositionWithPrice[]): SectorData[] { - const sectors: SectorData[] = [ - { type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share }, - { type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond }, - ]; - - for (const p of positions) { - const sector = sectors.find((s) => s.type === p.type); - if (sector) { - sector.value += p.currentValue ?? 0; - sector.count += 1; - } - } - - return sectors; -} - -export function AllocationChart({ positions, totalValue }: AllocationChartProps) { - const sectors = computeSectors(positions); - const nonZero = sectors.filter((s) => s.value > 0); - const hasData = nonZero.length > 0; - - const cx = 60; - const cy = 60; - const r = 44; - const strokeWidth = 10; - const circumference = 2 * Math.PI * r; - const viewBoxSize = 120; - - function renderArcs() { - if (!hasData) { - return ( - - ); - } - - if (nonZero.length === 1) { - const sector = nonZero[0]; - return ( - - ); - } - - return sectors.map((sector, i) => { - const ratio = totalValue > 0 ? sector.value / totalValue : 0; - const dashLen = ratio * circumference; - const gapLen = circumference - dashLen; - let rotation = -90; - - for (let j = 0; j < i; j++) { - const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0; - rotation += prevRatio * 360; - } - - return ( - - ); - }); - } - - return ( -
- - {renderArcs()} - 0 ? 14 : 10, - fontWeight: 700, - fill: 'var(--color-text)', - }} - > - {positions.length === 0 - ? 'Нет позиций' - : totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })} - - -
- {sectors.map((s) => { - const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; - return ( -
- - - {s.label}: {s.count} / {ratio.toFixed(1)}% - -
- ); - })} -
-
- ); -} diff --git a/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx b/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx deleted file mode 100644 index b2139ba..0000000 --- a/apps/frontend/src/components/portfolios/AnalyticsSummary.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { PortfolioSummary } from '@/shared/api/responses'; - -export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { - const formatRub = (val: number | null) => - val != null - ? val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : '—'; - - const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—'); - - const pnlColor = - summary.totalPnl > 0 - ? 'var(--color-positive)' - : summary.totalPnl < 0 - ? 'var(--color-negative)' - : 'inherit'; - - return ( -
-
-
- Инвестировано -
-
{formatRub(summary.totalInvested)}
-
- -
-
- Текущая стоимость -
-
{formatRub(summary.totalValue)}
-
- -
-
- Прибыль/Убыток -
-
- {summary.totalPnl > 0 ? '+' : ''} - {formatRub(summary.totalPnl)} - - ({formatPct(summary.totalPnlPercent)}) - -
-
- -
-
- Доходность (weighted) -
-
- {formatPct(summary.weightedYield)} -
-
-
- ); -} diff --git a/apps/frontend/src/components/portfolios/BondPositionRow.tsx b/apps/frontend/src/components/portfolios/BondPositionRow.tsx deleted file mode 100644 index e1ae9b6..0000000 --- a/apps/frontend/src/components/portfolios/BondPositionRow.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { PositionWithPrice } from '@/shared/api/responses'; - -interface Props { - position: PositionWithPrice; - onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; - onDelete: () => void; -} - -export function BondPositionRow({ position, onUpdate, onDelete }: Props) { - const [editingQty, setEditingQty] = useState(false); - const [editingPrice, setEditingPrice] = useState(false); - const [qty, setQty] = useState(String(position.quantity)); - const [price, setPrice] = useState(String(position.buyPrice ?? '')); - - function handleSaveQty() { - const num = parseInt(qty, 10); - if (!isNaN(num) && num >= 0 && num !== position.quantity) { - onUpdate({ quantity: num }); - } - setEditingQty(false); - } - - function handleSavePrice() { - const num = parseFloat(price); - if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { - onUpdate({ buyPrice: num }); - } else if (price === '' && position.buyPrice !== null) { - onUpdate({ buyPrice: undefined }); - } - setEditingPrice(false); - } - - function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return '—'; - return new Date(dateStr).toLocaleDateString('ru-RU'); - } - - function formatPct(value: number | null | undefined): string { - return value != null ? `${value.toFixed(2)}%` : '—'; - } - - function formatRubles(value: number | null | undefined): string { - return value != null - ? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : '—'; - } - - const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null; - - return ( - - - - {position.secid} - - - - {position.shortName ?? '—'} - - - {position.bondType ? {position.bondType} : '—'} - - - {editingQty ? ( - setQty(e.target.value)} - onBlur={handleSaveQty} - onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} - autoFocus - style={{ - width: 80, - padding: '4px 8px', - border: '1px solid var(--color-primary)', - borderRadius: 'var(--border-radius)', - fontSize: 14, - }} - /> - ) : ( - { - setQty(String(position.quantity)); - setEditingQty(true); - }} - style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} - > - {position.quantity.toLocaleString('ru-RU')} - - )} - - - {editingPrice ? ( - setPrice(e.target.value)} - onBlur={handleSavePrice} - onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} - autoFocus - style={{ - width: 90, - padding: '4px 8px', - border: '1px solid var(--color-primary)', - borderRadius: 'var(--border-radius)', - fontSize: 14, - textAlign: 'right', - }} - /> - ) : ( - { - setPrice(String(position.buyPrice ?? '')); - setEditingPrice(true); - }} - style={{ - cursor: 'pointer', - padding: '4px 0', - display: 'inline-block', - color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit', - }} - > - {formatPct(position.buyPrice)} - - )} - - - {formatPct(position.currentPrice)} - - {formatPct(position.bid)} - {formatPct(position.offer)} - - {formatPct(position.yieldToMaturity)} - - - {position.duration != null ? `${position.duration.toFixed(2)}г` : '—'} - - - {formatRubles(position.couponValue)} - - - {formatPct(position.couponPercent)} - - - {position.couponPeriod ? `${position.couponPeriod}д` : '—'} - - - {totalAccrued != null - ? totalAccrued.toLocaleString('ru-RU', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - : '—'} - - - {position.totalCost !== null - ? position.totalCost.toLocaleString('ru-RU', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - : '—'} - - - {position.pnl !== null ? ( - = 0 ? 'var(--color-positive)' : 'var(--color-negative)' }} - > - {position.pnl >= 0 ? '+' : ''} - {position.pnl.toLocaleString('ru-RU', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - ) : ( - '—' - )} - - - {position.pnlPercent !== null ? ( - = 0 ? 'var(--color-positive)' : 'var(--color-negative)', - }} - > - {position.pnlPercent >= 0 ? '+' : ''} - {position.pnlPercent.toFixed(2)}% - - ) : ( - '—' - )} - - - {formatDate(position.nextCouponDate)} - - {formatDate(position.matDate)} - {formatDate(position.offerDate)} - - {position.weightPercent.toFixed(1)}% - - - - - - ); -} diff --git a/apps/frontend/src/components/portfolios/BondPositionTable.tsx b/apps/frontend/src/components/portfolios/BondPositionTable.tsx deleted file mode 100644 index c1dfc2b..0000000 --- a/apps/frontend/src/components/portfolios/BondPositionTable.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { BondPositionRow } from './BondPositionRow'; -import type { PositionWithPrice } from '@/shared/api/responses'; - -interface Props { - positions: PositionWithPrice[]; - onUpdatePosition: ( - positionId: number, - data: { quantity?: number; buyPrice?: number; buyDate?: string }, - ) => void; - onDeletePosition: (positionId: number) => void; -} - -export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) { - if (positions.length === 0) return null; - - return ( -
-

- Облигации -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - {positions.map((pos) => ( - onUpdatePosition(pos.id, data)} - onDelete={() => onDeletePosition(pos.id)} - /> - ))} - -
- Тикер - - Название - - Тип - - Количество - - Цена пок. - - Цена - - Бид - - Оффер - - Доходность - - Дюрация - - Купон - - Куп. % - - Период - - НКД - - Затраты - - P&L - - P&L % - - След. купон - - Погашение - - Оферта - - Доля -
-
-
- ); -} diff --git a/apps/frontend/src/components/portfolios/PortfolioCard.tsx b/apps/frontend/src/components/portfolios/PortfolioCard.tsx deleted file mode 100644 index 9c36320..0000000 --- a/apps/frontend/src/components/portfolios/PortfolioCard.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Link } from 'react-router-dom'; -import type { Portfolio } from '@/shared/api/responses'; - -export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { - const chipStyle = (bg: string): React.CSSProperties => ({ - background: bg, - padding: '4px 10px', - borderRadius: 6, - fontSize: 12, - color: '#fff', - fontWeight: 500, - }); - - return ( - (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} - onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} - > -
-

{portfolio.name}

-
-
- {portfolio.totalValue?.toLocaleString('ru-RU', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -
-
- {portfolio.currency} -
-
-
- - {portfolio.description && ( -

- {portfolio.description} -

- )} - -
- {portfolio.shareCount > 0 && ( - - {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')} - - )} - {portfolio.bondCount > 0 && ( - - {portfolio.bondCount}{' '} - {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')} - - )} - - {portfolio.positionCount}{' '} - {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')} - -
- - - обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} - - - ); -} - -function pluralize(n: number, one: string, few: string, many: string): string { - if (n % 10 === 1 && n % 100 !== 11) return one; - if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few; - return many; -} diff --git a/apps/frontend/src/components/portfolios/PortfolioForm.tsx b/apps/frontend/src/components/portfolios/PortfolioForm.tsx deleted file mode 100644 index 870f288..0000000 --- a/apps/frontend/src/components/portfolios/PortfolioForm.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useState } from 'react'; -import type { Portfolio } from '@/shared/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, - }} - /> -
-
- -