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 (
+
+
+ Облигации
+
+
+
+
+
+ |
+ Тикер
+ |
+
+ Название
+ |
+
+ Тип
+ |
+
+ Количество
+ |
+
+ Цена пок.
+ |
+
+ Цена
+ |
+
+ Бид
+ |
+
+ Оффер
+ |
+
+ Доходность
+ |
+
+ Дюрация
+ |
+
+ Купон
+ |
+
+ Куп. %
+ |
+
+ Период
+ |
+
+ НКД
+ |
+
+ Затраты
+ |
+
+ P&L
+ |
+
+ P&L %
+ |
+
+ След. купон
+ |
+
+ Погашение
+ |
+
+ Оферта
+ |
+
+ Доля
+ |
+ |
+
+
+
+ {positions.map((pos) => (
+ onUpdatePosition(pos.id, data)}
+ onDelete={() => onDeletePosition(pos.id)}
+ />
+ ))}
+
+
+
+
+ );
+}
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 (
+
+ );
+}
diff --git a/apps/frontend/src/widgets/portfolio-summary/index.ts b/apps/frontend/src/widgets/portfolio-summary/index.ts
new file mode 100644
index 0000000..aea75ad
--- /dev/null
+++ b/apps/frontend/src/widgets/portfolio-summary/index.ts
@@ -0,0 +1 @@
+export { PortfolioSummary } from './ui/PortfolioSummary';
diff --git a/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx
new file mode 100644
index 0000000..275b9ed
--- /dev/null
+++ b/apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx
@@ -0,0 +1,162 @@
+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 (
+
+
+
+ {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/widgets/portfolio-summary/ui/PortfolioSummary.tsx b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx
new file mode 100644
index 0000000..98dfabf
--- /dev/null
+++ b/apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx
@@ -0,0 +1,46 @@
+import { AllocationChart } from './AllocationChart';
+import type { PortfolioDetail } from '@/shared/api/responses';
+
+export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
+ return (
+
+
+
+
+ Общая стоимость
+
+
+ {portfolio.totalValue?.toLocaleString('ru-RU', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ {portfolio.currency}
+
+
+
+
+
+ Позиций
+
+
{portfolio.positions.length}
+
+
+ );
+}
diff --git a/apps/frontend/src/widgets/share-positions-table/index.ts b/apps/frontend/src/widgets/share-positions-table/index.ts
new file mode 100644
index 0000000..59ee3d8
--- /dev/null
+++ b/apps/frontend/src/widgets/share-positions-table/index.ts
@@ -0,0 +1 @@
+export { SharePositionTable } from './ui/SharePositionTable';
diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx
new file mode 100644
index 0000000..4aca8e7
--- /dev/null
+++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx
@@ -0,0 +1,203 @@
+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 SharePositionRow({ 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 }); // Or handle null if backend supports it
+ }
+ setEditingPrice(false);
+ }
+
+ return (
+
+ |
+
+ {position.secid}
+
+ |
+
+ {position.shortName ?? '—'}
+ |
+
+ {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}
+
+ )}
+ |
+
+ {editingPrice ? (
+ setPrice(e.target.value)}
+ onBlur={handleSavePrice}
+ onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()}
+ autoFocus
+ style={{
+ width: 100,
+ 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',
+ }}
+ >
+ {position.buyPrice !== null
+ ? position.buyPrice.toLocaleString('ru-RU', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+ : '—'}
+
+ )}
+ |
+
+ {position.currentPrice !== null
+ ? position.currentPrice.toLocaleString('ru-RU', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+ : '—'}
+ |
+
+ {position.change != null && position.change !== 0 ? (
+ 0 ? '#43a047' : '#e53935' }}>
+ {position.change > 0 ? '+' : ''}
+ {position.change.toLocaleString('ru-RU', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ ) : (
+ '—'
+ )}
+ |
+
+ {position.currentValue !== null
+ ? position.currentValue.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)}%
+
+ ) : (
+ '—'
+ )}
+ |
+
+ {position.weightPercent.toFixed(1)}%
+ |
+
+
+ |
+
+ );
+}
diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx
new file mode 100644
index 0000000..0463244
--- /dev/null
+++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx
@@ -0,0 +1,163 @@
+import { SharePositionRow } from './SharePositionRow';
+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 SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
+ if (positions.length === 0) return null;
+
+ return (
+
+
+ Акции
+
+
+
+
+
+ |
+ Тикер
+ |
+
+ Название
+ |
+
+ Количество
+ |
+
+ Цена пок.
+ |
+
+ Цена
+ |
+
+ Изм.
+ |
+
+ Стоимость
+ |
+
+ Затраты
+ |
+
+ P&L
+ |
+
+ P&L %
+ |
+
+ Доля
+ |
+ |
+
+
+
+ {positions.map((pos) => (
+ onUpdatePosition(pos.id, data)}
+ onDelete={() => onDeletePosition(pos.id)}
+ />
+ ))}
+
+
+
+
+ );
+}