diff --git a/apps/frontend/src/widgets/bond-positions-table/index.ts b/apps/frontend/src/widgets/bond-positions-table/index.ts new file mode 100644 index 0000000..add2bda --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/index.ts @@ -0,0 +1 @@ +export { BondPositionTable } from './ui/BondPositionTable'; diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx new file mode 100644 index 0000000..e1ae9b6 --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx @@ -0,0 +1,222 @@ +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/widgets/bond-positions-table/ui/BondPositionTable.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx new file mode 100644 index 0000000..c1dfc2b --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx @@ -0,0 +1,273 @@ +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/widgets/portfolio-analytics/index.ts b/apps/frontend/src/widgets/portfolio-analytics/index.ts new file mode 100644 index 0000000..e89a5f5 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-analytics/index.ts @@ -0,0 +1 @@ +export { AnalyticsSummary } from './ui/AnalyticsSummary'; diff --git a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx new file mode 100644 index 0000000..b2139ba --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx @@ -0,0 +1,68 @@ +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/widgets/portfolio-card/index.ts b/apps/frontend/src/widgets/portfolio-card/index.ts new file mode 100644 index 0000000..e2914ab --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-card/index.ts @@ -0,0 +1 @@ +export { PortfolioCard } from './ui/PortfolioCard'; diff --git a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx new file mode 100644 index 0000000..9c36320 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx @@ -0,0 +1,87 @@ +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/widgets/portfolio-form/index.ts b/apps/frontend/src/widgets/portfolio-form/index.ts new file mode 100644 index 0000000..a030012 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-form/index.ts @@ -0,0 +1 @@ +export { PortfolioForm } from './ui/PortfolioForm'; diff --git a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx new file mode 100644 index 0000000..870f288 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx @@ -0,0 +1,119 @@ +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, + }} + /> +
+
+ +