295 lines
8.9 KiB
TypeScript
295 lines
8.9 KiB
TypeScript
import { useState } from 'react';
|
||
import { useParams, Link } from 'react-router-dom';
|
||
import {
|
||
usePortfolio,
|
||
usePortfolioMutations,
|
||
usePositionMutations,
|
||
} from '../../entities/portfolio';
|
||
import { PortfolioForm } from '../../widgets/portfolio-form';
|
||
import { PortfolioSummary } from '../../widgets/portfolio-summary';
|
||
import { AnalyticsSummary } from '../../widgets/portfolio-analytics';
|
||
import { SharePositionTable } from '../../widgets/share-positions-table';
|
||
import { BondPositionTable } from '../../widgets/bond-positions-table';
|
||
|
||
export function PortfolioDetailPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const portfolioId = parseInt(id!, 10);
|
||
|
||
const { data: portfolio, 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');
|
||
const [newPrice, setNewPrice] = useState('');
|
||
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||
Загрузка...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !portfolio) {
|
||
return (
|
||
<div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>
|
||
Ошибка загрузки портфеля
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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),
|
||
buyPrice: newPrice ? parseFloat(newPrice) : undefined,
|
||
buyDate: newDate || undefined,
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
setShowAddForm(false);
|
||
setNewSecid('');
|
||
setNewQty('1');
|
||
setNewPrice('');
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
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} />
|
||
|
||
{portfolio.analytics && <AnalyticsSummary summary={portfolio.analytics} />}
|
||
|
||
<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>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||
Цена покупки
|
||
</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={newPrice}
|
||
onChange={(e) => setNewPrice(e.target.value)}
|
||
placeholder="0.00"
|
||
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="date"
|
||
value={newDate}
|
||
onChange={(e) => setNewDate(e.target.value)}
|
||
style={{
|
||
padding: '8px 12px',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: 'var(--border-radius)',
|
||
fontSize: 14,
|
||
width: 150,
|
||
}}
|
||
/>
|
||
</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>
|
||
)}
|
||
|
||
<SharePositionTable
|
||
positions={portfolio.positions.filter((p) => p.type === 'share')}
|
||
onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })}
|
||
onDeletePosition={(positionId) => {
|
||
if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId);
|
||
}}
|
||
/>
|
||
|
||
<BondPositionTable
|
||
positions={portfolio.positions.filter((p) => p.type === 'bond')}
|
||
onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })}
|
||
onDeletePosition={(positionId) => {
|
||
if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|