[OQI-008] feat: Add portfolio Phase 3 - Performance chart and edit allocations
- Add PerformanceChart component with canvas-based line chart - Add EditAllocations page for modifying target allocations - Integrate PerformanceChart into PortfolioDashboard - Add route for /portfolio/:portfolioId/edit - Extend portfolio.service with performance API functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8a7cbe691
commit
c02625f37b
@ -34,6 +34,7 @@ const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
|||||||
const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard'));
|
const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard'));
|
||||||
const CreatePortfolio = lazy(() => import('./modules/portfolio/pages/CreatePortfolio'));
|
const CreatePortfolio = lazy(() => import('./modules/portfolio/pages/CreatePortfolio'));
|
||||||
const CreateGoal = lazy(() => import('./modules/portfolio/pages/CreateGoal'));
|
const CreateGoal = lazy(() => import('./modules/portfolio/pages/CreateGoal'));
|
||||||
|
const EditAllocations = lazy(() => import('./modules/portfolio/pages/EditAllocations'));
|
||||||
|
|
||||||
// Lazy load modules - Education
|
// Lazy load modules - Education
|
||||||
const Courses = lazy(() => import('./modules/education/pages/Courses'));
|
const Courses = lazy(() => import('./modules/education/pages/Courses'));
|
||||||
@ -91,6 +92,7 @@ function App() {
|
|||||||
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
||||||
<Route path="/portfolio/new" element={<CreatePortfolio />} />
|
<Route path="/portfolio/new" element={<CreatePortfolio />} />
|
||||||
<Route path="/portfolio/goals/new" element={<CreateGoal />} />
|
<Route path="/portfolio/goals/new" element={<CreateGoal />} />
|
||||||
|
<Route path="/portfolio/:portfolioId/edit" element={<EditAllocations />} />
|
||||||
|
|
||||||
{/* Education */}
|
{/* Education */}
|
||||||
<Route path="/education/courses" element={<Courses />} />
|
<Route path="/education/courses" element={<Courses />} />
|
||||||
|
|||||||
311
src/modules/portfolio/components/PerformanceChart.tsx
Normal file
311
src/modules/portfolio/components/PerformanceChart.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Performance Chart Component
|
||||||
|
* Line chart showing portfolio performance over time
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
getPortfolioPerformance,
|
||||||
|
type PerformanceDataPoint,
|
||||||
|
type PerformancePeriod,
|
||||||
|
} from '../../../services/portfolio.service';
|
||||||
|
|
||||||
|
interface PerformanceChartProps {
|
||||||
|
portfolioId: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { value: PerformancePeriod; label: string }[] = [
|
||||||
|
{ value: 'week', label: '7D' },
|
||||||
|
{ value: 'month', label: '1M' },
|
||||||
|
{ value: '3months', label: '3M' },
|
||||||
|
{ value: 'year', label: '1A' },
|
||||||
|
{ value: 'all', label: 'Todo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PerformanceChart: React.FC<PerformanceChartProps> = ({
|
||||||
|
portfolioId,
|
||||||
|
height = 300,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [data, setData] = useState<PerformanceDataPoint[]>([]);
|
||||||
|
const [period, setPeriod] = useState<PerformancePeriod>('month');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hoveredPoint, setHoveredPoint] = useState<PerformanceDataPoint | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [portfolioId, period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
drawChart();
|
||||||
|
}
|
||||||
|
}, [data, hoveredPoint]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const performanceData = await getPortfolioPerformance(portfolioId, period);
|
||||||
|
setData(performanceData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading performance data:', error);
|
||||||
|
// Generate mock data for demo
|
||||||
|
setData(generateMockData(period));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockData = (p: PerformancePeriod): PerformanceDataPoint[] => {
|
||||||
|
const days = p === 'week' ? 7 : p === 'month' ? 30 : p === '3months' ? 90 : p === 'year' ? 365 : 180;
|
||||||
|
const mockData: PerformanceDataPoint[] = [];
|
||||||
|
let value = 10000;
|
||||||
|
let pnl = 0;
|
||||||
|
|
||||||
|
for (let i = days; i >= 0; i--) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const change = (Math.random() - 0.4) * value * 0.02;
|
||||||
|
value += change;
|
||||||
|
pnl = value - 10000;
|
||||||
|
|
||||||
|
mockData.push({
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
value,
|
||||||
|
pnl,
|
||||||
|
pnlPercent: (pnl / 10000) * 100,
|
||||||
|
change,
|
||||||
|
changePercent: (change / (value - change)) * 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mockData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawChart = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || data.length === 0) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = rect.width;
|
||||||
|
const chartHeight = rect.height;
|
||||||
|
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
|
||||||
|
const chartWidth = width - padding.left - padding.right;
|
||||||
|
const innerHeight = chartHeight - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, chartHeight);
|
||||||
|
|
||||||
|
// Get min/max values
|
||||||
|
const values = data.map((d) => d.value);
|
||||||
|
const minValue = Math.min(...values) * 0.995;
|
||||||
|
const maxValue = Math.max(...values) * 1.005;
|
||||||
|
const valueRange = maxValue - minValue;
|
||||||
|
|
||||||
|
// Calculate points
|
||||||
|
const points = data.map((d, i) => ({
|
||||||
|
x: padding.left + (i / (data.length - 1)) * chartWidth,
|
||||||
|
y: padding.top + innerHeight - ((d.value - minValue) / valueRange) * innerHeight,
|
||||||
|
data: d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine color based on overall performance
|
||||||
|
const isPositive = data[data.length - 1].value >= data[0].value;
|
||||||
|
const lineColor = isPositive ? '#10B981' : '#EF4444';
|
||||||
|
const fillColor = isPositive ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)';
|
||||||
|
|
||||||
|
// Draw grid lines
|
||||||
|
ctx.strokeStyle = 'rgba(156, 163, 175, 0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const gridLines = 5;
|
||||||
|
for (let i = 0; i <= gridLines; i++) {
|
||||||
|
const y = padding.top + (i / gridLines) * innerHeight;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, y);
|
||||||
|
ctx.lineTo(width - padding.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y-axis labels
|
||||||
|
const value = maxValue - (i / gridLines) * valueRange;
|
||||||
|
ctx.fillStyle = '#9CA3AF';
|
||||||
|
ctx.font = '11px system-ui';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(`$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`, padding.left - 8, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw fill gradient
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, chartHeight - padding.bottom);
|
||||||
|
points.forEach((p) => ctx.lineTo(p.x, p.y));
|
||||||
|
ctx.lineTo(points[points.length - 1].x, chartHeight - padding.bottom);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fillColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
points.forEach((p) => ctx.lineTo(p.x, p.y));
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw X-axis labels (dates)
|
||||||
|
ctx.fillStyle = '#9CA3AF';
|
||||||
|
ctx.font = '11px system-ui';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const labelCount = Math.min(6, data.length);
|
||||||
|
for (let i = 0; i < labelCount; i++) {
|
||||||
|
const idx = Math.floor((i / (labelCount - 1)) * (data.length - 1));
|
||||||
|
const point = points[idx];
|
||||||
|
const date = new Date(data[idx].date);
|
||||||
|
const label = `${date.getDate()}/${date.getMonth() + 1}`;
|
||||||
|
ctx.fillText(label, point.x, chartHeight - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw hovered point
|
||||||
|
if (hoveredPoint) {
|
||||||
|
const idx = data.findIndex((d) => d.date === hoveredPoint.date);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const point = points[idx];
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = lineColor;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(point.x, padding.top);
|
||||||
|
ctx.lineTo(point.x, chartHeight - padding.bottom);
|
||||||
|
ctx.strokeStyle = 'rgba(156, 163, 175, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || data.length === 0) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const padding = { left: 60, right: 20 };
|
||||||
|
const chartWidth = rect.width - padding.left - padding.right;
|
||||||
|
|
||||||
|
if (x < padding.left || x > rect.width - padding.right) {
|
||||||
|
setHoveredPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeX = (x - padding.left) / chartWidth;
|
||||||
|
const idx = Math.round(relativeX * (data.length - 1));
|
||||||
|
const point = data[Math.max(0, Math.min(idx, data.length - 1))];
|
||||||
|
setHoveredPoint(point);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setHoveredPoint(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalChange = data.length > 0 ? data[data.length - 1].value - data[0].value : 0;
|
||||||
|
const totalChangePercent = data.length > 0 && data[0].value > 0
|
||||||
|
? ((data[data.length - 1].value - data[0].value) / data[0].value) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Rendimiento</h3>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`text-lg font-bold ${
|
||||||
|
totalChange >= 0 ? 'text-green-500' : 'text-red-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{totalChange >= 0 ? '+' : ''}${totalChange.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
totalChangePercent >= 0 ? 'text-green-500' : 'text-red-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
({totalChangePercent >= 0 ? '+' : ''}{totalChangePercent.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{PERIOD_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setPeriod(option.value)}
|
||||||
|
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||||
|
period === option.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hovered point info */}
|
||||||
|
{hoveredPoint && (
|
||||||
|
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{hoveredPoint.date}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
${hoveredPoint.value.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs mt-1">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Cambio diario</span>
|
||||||
|
<span className={hoveredPoint.change >= 0 ? 'text-green-500' : 'text-red-500'}>
|
||||||
|
{hoveredPoint.change >= 0 ? '+' : ''}
|
||||||
|
{hoveredPoint.changePercent.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center" style={{ height }}>
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ width: '100%', height }}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className="cursor-crosshair"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceChart;
|
||||||
348
src/modules/portfolio/pages/EditAllocations.tsx
Normal file
348
src/modules/portfolio/pages/EditAllocations.tsx
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Edit Allocations Page
|
||||||
|
* Form to edit portfolio target allocations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import {
|
||||||
|
getPortfolio,
|
||||||
|
updateAllocations,
|
||||||
|
type Portfolio,
|
||||||
|
} from '../../../services/portfolio.service';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Available Assets
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const AVAILABLE_ASSETS = [
|
||||||
|
{ symbol: 'BTC', name: 'Bitcoin', icon: 'https://cryptologos.cc/logos/bitcoin-btc-logo.svg' },
|
||||||
|
{ symbol: 'ETH', name: 'Ethereum', icon: 'https://cryptologos.cc/logos/ethereum-eth-logo.svg' },
|
||||||
|
{ symbol: 'USDT', name: 'Tether', icon: 'https://cryptologos.cc/logos/tether-usdt-logo.svg' },
|
||||||
|
{ symbol: 'SOL', name: 'Solana', icon: 'https://cryptologos.cc/logos/solana-sol-logo.svg' },
|
||||||
|
{ symbol: 'LINK', name: 'Chainlink', icon: 'https://cryptologos.cc/logos/chainlink-link-logo.svg' },
|
||||||
|
{ symbol: 'AVAX', name: 'Avalanche', icon: 'https://cryptologos.cc/logos/avalanche-avax-logo.svg' },
|
||||||
|
{ symbol: 'ADA', name: 'Cardano', icon: 'https://cryptologos.cc/logos/cardano-ada-logo.svg' },
|
||||||
|
{ symbol: 'DOT', name: 'Polkadot', icon: 'https://cryptologos.cc/logos/polkadot-new-dot-logo.svg' },
|
||||||
|
{ symbol: 'MATIC', name: 'Polygon', icon: 'https://cryptologos.cc/logos/polygon-matic-logo.svg' },
|
||||||
|
{ symbol: 'UNI', name: 'Uniswap', icon: 'https://cryptologos.cc/logos/uniswap-uni-logo.svg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AllocationInput {
|
||||||
|
asset: string;
|
||||||
|
targetPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default function EditAllocations() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { portfolioId } = useParams<{ portfolioId: string }>();
|
||||||
|
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
|
||||||
|
const [allocations, setAllocations] = useState<AllocationInput[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (portfolioId) {
|
||||||
|
loadPortfolio();
|
||||||
|
}
|
||||||
|
}, [portfolioId]);
|
||||||
|
|
||||||
|
const loadPortfolio = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getPortfolio(portfolioId!);
|
||||||
|
setPortfolio(data);
|
||||||
|
setAllocations(
|
||||||
|
data.allocations.map((a) => ({
|
||||||
|
asset: a.asset,
|
||||||
|
targetPercent: a.targetPercent,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error loading portfolio');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPercent = allocations.reduce((sum, a) => sum + a.targetPercent, 0);
|
||||||
|
const isValid = Math.abs(totalPercent - 100) < 0.01;
|
||||||
|
|
||||||
|
const handlePercentChange = (index: number, value: string) => {
|
||||||
|
const newAllocations = [...allocations];
|
||||||
|
newAllocations[index].targetPercent = parseFloat(value) || 0;
|
||||||
|
setAllocations(newAllocations);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const newAllocations = allocations.filter((_, i) => i !== index);
|
||||||
|
setAllocations(newAllocations);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAsset = (asset: string) => {
|
||||||
|
if (allocations.some((a) => a.asset === asset)) {
|
||||||
|
setShowAddModal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.max(0, 100 - totalPercent);
|
||||||
|
setAllocations([...allocations, { asset, targetPercent: remaining }]);
|
||||||
|
setShowAddModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoBalance = () => {
|
||||||
|
if (allocations.length === 0) return;
|
||||||
|
const equalPercent = 100 / allocations.length;
|
||||||
|
setAllocations(allocations.map((a) => ({ ...a, targetPercent: equalPercent })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!isValid || !portfolioId) {
|
||||||
|
setError('Las allocaciones deben sumar 100%');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
await updateAllocations(portfolioId, allocations);
|
||||||
|
navigate('/portfolio');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error saving allocations');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableToAdd = AVAILABLE_ASSETS.filter(
|
||||||
|
(asset) => !allocations.some((a) => a.asset === asset.symbol)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-96">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/portfolio')}
|
||||||
|
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-5 h-5" />
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Editar Allocaciones
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{portfolio?.name} - Perfil{' '}
|
||||||
|
{portfolio?.riskProfile === 'conservative'
|
||||||
|
? 'Conservador'
|
||||||
|
: portfolio?.riskProfile === 'moderate'
|
||||||
|
? 'Moderado'
|
||||||
|
: 'Agresivo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total indicator */}
|
||||||
|
<div
|
||||||
|
className={`mb-6 p-4 rounded-xl flex items-center justify-between ${
|
||||||
|
isValid
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isValid && <ExclamationTriangleIcon className="w-5 h-5 text-red-500" />}
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
Total: {totalPercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isValid && (
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{totalPercent < 100
|
||||||
|
? `Faltan ${(100 - totalPercent).toFixed(1)}%`
|
||||||
|
: `Sobran ${(totalPercent - 100).toFixed(1)}%`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allocations list */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-bold text-gray-900 dark:text-white">Distribución</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoBalance}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Distribuir equitativamente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{allocations.map((alloc, index) => {
|
||||||
|
const assetInfo = AVAILABLE_ASSETS.find((a) => a.symbol === alloc.asset);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.asset}
|
||||||
|
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
{assetInfo?.icon ? (
|
||||||
|
<img src={assetInfo.icon} alt={alloc.asset} className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center font-bold text-sm">
|
||||||
|
{alloc.asset.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{alloc.asset}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{assetInfo?.name || alloc.asset}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={alloc.targetPercent}
|
||||||
|
onChange={(e) => handlePercentChange(index, e.target.value)}
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
className="w-20 px-3 py-2 bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg text-right text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Visual bar */}
|
||||||
|
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${Math.min(100, alloc.targetPercent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add asset button */}
|
||||||
|
{availableToAdd.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="mt-4 w-full py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-5 h-5" />
|
||||||
|
Agregar Activo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/portfolio')}
|
||||||
|
className="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || saving}
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Guardar Cambios'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Asset Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[80vh] overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Agregar Activo</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 overflow-y-auto max-h-96">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableToAdd.map((asset) => (
|
||||||
|
<button
|
||||||
|
key={asset.symbol}
|
||||||
|
onClick={() => handleAddAsset(asset.symbol)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{asset.icon ? (
|
||||||
|
<img src={asset.icon} alt={asset.symbol} className="w-8 h-8" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center font-bold text-xs">
|
||||||
|
{asset.symbol.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{asset.symbol}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{asset.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="w-full py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ import { AllocationChart } from '../components/AllocationChart';
|
|||||||
import { AllocationTable } from '../components/AllocationTable';
|
import { AllocationTable } from '../components/AllocationTable';
|
||||||
import { RebalanceCard } from '../components/RebalanceCard';
|
import { RebalanceCard } from '../components/RebalanceCard';
|
||||||
import { GoalCard } from '../components/GoalCard';
|
import { GoalCard } from '../components/GoalCard';
|
||||||
|
import { PerformanceChart } from '../components/PerformanceChart';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Subcomponents
|
// Subcomponents
|
||||||
@ -289,6 +290,11 @@ export default function PortfolioDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Chart */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<PerformanceChart portfolioId={selectedPortfolio.id} height={280} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
{/* Main Content Grid */}
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
{/* Left: Allocation Chart & Table */}
|
{/* Left: Allocation Chart & Table */}
|
||||||
|
|||||||
@ -193,6 +193,62 @@ export async function getPortfolioStats(portfolioId: string): Promise<PortfolioS
|
|||||||
return data.data || data;
|
return data.data || data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Performance API Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PerformanceDataPoint {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
pnl: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceStats {
|
||||||
|
dayChange: number;
|
||||||
|
dayChangePercent: number;
|
||||||
|
weekChange: number;
|
||||||
|
weekChangePercent: number;
|
||||||
|
monthChange: number;
|
||||||
|
monthChangePercent: number;
|
||||||
|
yearChange: number;
|
||||||
|
yearChangePercent: number;
|
||||||
|
allTimeChange: number;
|
||||||
|
allTimeChangePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PerformancePeriod = 'week' | 'month' | '3months' | 'year' | 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get portfolio performance history for charts
|
||||||
|
*/
|
||||||
|
export async function getPortfolioPerformance(
|
||||||
|
portfolioId: string,
|
||||||
|
period: PerformancePeriod = 'month'
|
||||||
|
): Promise<PerformanceDataPoint[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/api/v1/portfolio/${portfolioId}/performance?period=${period}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch performance');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed performance statistics
|
||||||
|
*/
|
||||||
|
export async function getPerformanceStats(portfolioId: string): Promise<PerformanceStats> {
|
||||||
|
const response = await fetch(`${API_URL}/api/v1/portfolio/${portfolioId}/performance/stats`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch performance stats');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Goals API Functions
|
// Goals API Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user