204 lines
7.6 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface HoldingWithValue {
ticker: string;
quantity: number;
avg_price: number;
current_price: number | null;
value: number | null;
current_ratio: number | null;
profit_loss: number | null;
profit_loss_ratio: number | null;
}
interface Target {
ticker: string;
target_ratio: number;
}
interface PortfolioDetail {
id: number;
name: string;
portfolio_type: string;
created_at: string;
updated_at: string;
targets: Target[];
holdings: HoldingWithValue[];
total_value: number | null;
total_invested: number | null;
total_profit_loss: number | null;
}
export default function PortfolioDetailPage() {
const router = useRouter();
const params = useParams();
const portfolioId = params.id as string;
const [loading, setLoading] = useState(true);
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
await fetchPortfolio();
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router, portfolioId]);
const fetchPortfolio = async () => {
try {
setError(null);
const data = await api.get<PortfolioDetail>(`/api/portfolios/${portfolioId}/detail`);
setPortfolio(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch portfolio';
setError(message);
}
};
const formatCurrency = (value: number | null) => {
if (value === null) return '-';
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(value);
};
const formatPercent = (value: number | null) => {
if (value === null) return '-';
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{portfolio && (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800">{portfolio.name}</h1>
<span className={`text-xs px-2 py-1 rounded ${
portfolio.portfolio_type === 'pension'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
<Link
href={`/portfolio/${portfolioId}/rebalance`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_value)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_invested)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className={`text-2xl font-bold ${
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(portfolio.total_profit_loss)}
</p>
</div>
</div>
{/* Holdings Table */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{portfolio.holdings.map((holding) => (
<tr key={holding.ticker}>
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td>
<td className="px-4 py-3 text-sm text-right">{holding.quantity.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.avg_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.current_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.value)}</td>
<td className="px-4 py-3 text-sm text-right">{holding.current_ratio?.toFixed(2)}%</td>
<td className={`px-4 py-3 text-sm text-right ${
(holding.profit_loss_ratio ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatPercent(holding.profit_loss_ratio)}
</td>
</tr>
))}
{portfolio.holdings.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
)}
</main>
</div>
</div>
);
}