2026-02-03 08:27:29 +09:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
|
|
|
import Link from 'next/link';
|
2026-02-05 22:54:22 +09:00
|
|
|
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-02-05 23:03:45 +09:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
|
|
import { TradingViewChart } from '@/components/charts/trading-view-chart';
|
|
|
|
|
import { DonutChart } from '@/components/charts/donut-chart';
|
2026-02-03 08:27:29 +09:00
|
|
|
import { api } from '@/lib/api';
|
2026-02-05 23:03:45 +09:00
|
|
|
import { AreaData, Time } from 'lightweight-charts';
|
2026-02-03 08:27:29 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
interface Transaction {
|
|
|
|
|
id: number;
|
|
|
|
|
ticker: string;
|
|
|
|
|
transaction_type: string;
|
|
|
|
|
quantity: number;
|
|
|
|
|
price: number;
|
|
|
|
|
created_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 08:27:29 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
const CHART_COLORS = [
|
|
|
|
|
'hsl(221.2, 83.2%, 53.3%)',
|
|
|
|
|
'hsl(262.1, 83.3%, 57.8%)',
|
|
|
|
|
'hsl(142.1, 76.2%, 36.3%)',
|
|
|
|
|
'hsl(38.3, 95.7%, 53.1%)',
|
|
|
|
|
'hsl(346.8, 77.2%, 49.8%)',
|
|
|
|
|
'hsl(199.4, 95.5%, 53.8%)',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Generate sample chart data for portfolio value over time
|
|
|
|
|
function generateChartData(totalValue: number | null): AreaData<Time>[] {
|
|
|
|
|
if (totalValue === null || totalValue === 0) return [];
|
|
|
|
|
|
|
|
|
|
const data: AreaData<Time>[] = [];
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const baseValue = totalValue * 0.85;
|
|
|
|
|
|
|
|
|
|
for (let i = 90; i >= 0; i--) {
|
|
|
|
|
const date = new Date(now);
|
|
|
|
|
date.setDate(date.getDate() - i);
|
|
|
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
|
|
|
|
|
|
|
|
// Simulate value fluctuation
|
|
|
|
|
const progress = (90 - i) / 90;
|
|
|
|
|
const fluctuation = Math.sin(i * 0.1) * 0.05;
|
|
|
|
|
const value = baseValue + (totalValue - baseValue) * progress * (1 + fluctuation);
|
|
|
|
|
|
|
|
|
|
data.push({
|
|
|
|
|
time: dateStr as Time,
|
|
|
|
|
value: Math.round(value),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 08:27:29 +09:00
|
|
|
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);
|
2026-02-05 23:03:45 +09:00
|
|
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
2026-02-03 08:27:29 +09:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const init = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await api.getCurrentUser();
|
2026-02-05 23:03:45 +09:00
|
|
|
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
2026-02-03 08:27:29 +09:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
const fetchTransactions = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
|
|
|
|
|
setTransactions(data);
|
|
|
|
|
} catch {
|
|
|
|
|
// Transactions may not exist yet
|
|
|
|
|
setTransactions([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 08:27:29 +09:00
|
|
|
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)}%`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
const calculateReturnPercent = (): number | null => {
|
|
|
|
|
if (
|
|
|
|
|
!portfolio ||
|
|
|
|
|
portfolio.total_profit_loss === null ||
|
|
|
|
|
portfolio.total_invested === null ||
|
|
|
|
|
portfolio.total_invested === 0
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return (portfolio.total_profit_loss / portfolio.total_invested) * 100;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getDonutData = () => {
|
|
|
|
|
if (!portfolio) return [];
|
|
|
|
|
return portfolio.holdings
|
|
|
|
|
.filter((h) => h.current_ratio !== null && h.current_ratio > 0)
|
|
|
|
|
.map((h, index) => ({
|
|
|
|
|
name: h.ticker,
|
|
|
|
|
value: h.current_ratio ?? 0,
|
|
|
|
|
color: CHART_COLORS[index % CHART_COLORS.length],
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 08:27:29 +09:00
|
|
|
if (loading) {
|
2026-02-05 22:54:22 +09:00
|
|
|
return null;
|
2026-02-03 08:27:29 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
const chartData = portfolio ? generateChartData(portfolio.total_value) : [];
|
|
|
|
|
const returnPercent = calculateReturnPercent();
|
|
|
|
|
|
2026-02-03 08:27:29 +09:00
|
|
|
return (
|
2026-02-05 22:54:22 +09:00
|
|
|
<DashboardLayout>
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-destructive/10 border border-destructive text-destructive 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-foreground">{portfolio.name}</h1>
|
2026-02-05 23:03:45 +09:00
|
|
|
<span
|
|
|
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
|
|
|
portfolio.portfolio_type === 'pension'
|
|
|
|
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
|
|
|
|
: 'bg-muted text-muted-foreground'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-02-05 22:54:22 +09:00
|
|
|
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
|
|
|
|
|
</span>
|
2026-02-03 08:27:29 +09:00
|
|
|
</div>
|
2026-02-05 22:54:22 +09:00
|
|
|
<Button asChild>
|
|
|
|
|
<Link href={`/portfolio/${portfolioId}/rebalance`}>리밸런싱</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-02-03 08:27:29 +09:00
|
|
|
|
2026-02-05 22:54:22 +09:00
|
|
|
{/* Summary Cards */}
|
2026-02-05 23:03:45 +09:00
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
2026-02-05 22:54:22 +09:00
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
|
|
|
|
<p className="text-2xl font-bold text-foreground">
|
|
|
|
|
{formatCurrency(portfolio.total_value)}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">총 투자금액</p>
|
|
|
|
|
<p className="text-2xl font-bold text-foreground">
|
|
|
|
|
{formatCurrency(portfolio.total_invested)}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
2026-02-05 23:03:45 +09:00
|
|
|
<p
|
|
|
|
|
className={`text-2xl font-bold ${
|
|
|
|
|
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-02-05 22:54:22 +09:00
|
|
|
{formatCurrency(portfolio.total_profit_loss)}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2026-02-05 23:03:45 +09:00
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-1">수익률</p>
|
|
|
|
|
<p
|
|
|
|
|
className={`text-2xl font-bold ${
|
|
|
|
|
(returnPercent ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{formatPercent(returnPercent)}
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2026-02-05 22:54:22 +09:00
|
|
|
</div>
|
2026-02-03 08:27:29 +09:00
|
|
|
|
2026-02-05 23:03:45 +09:00
|
|
|
{/* Chart Section */}
|
|
|
|
|
{chartData.length > 0 && (
|
|
|
|
|
<Card className="mb-6">
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>포트폴리오 가치 추이</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<TradingViewChart data={chartData} height={300} />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Tabs Section */}
|
|
|
|
|
<Tabs defaultValue="holdings" className="space-y-4">
|
|
|
|
|
<TabsList>
|
|
|
|
|
<TabsTrigger value="holdings">보유종목</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="transactions">거래내역</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="analysis">분석</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
{/* Holdings Tab */}
|
|
|
|
|
<TabsContent value="holdings">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>보유 자산</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="p-0">
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full">
|
|
|
|
|
<thead className="bg-muted">
|
|
|
|
|
<tr>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
종목
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
수량
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
평균단가
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
현재가
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
평가금액
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground min-w-[150px]"
|
|
|
|
|
>
|
|
|
|
|
비중
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
손익률
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-border">
|
|
|
|
|
{portfolio.holdings.map((holding, index) => (
|
|
|
|
|
<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">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="flex-1 bg-muted rounded-full h-2 overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${Math.min(holding.current_ratio ?? 0, 100)}%`,
|
|
|
|
|
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-muted-foreground w-12 text-right">
|
|
|
|
|
{holding.current_ratio?.toFixed(1)}%
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</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-muted-foreground">
|
|
|
|
|
보유 자산이 없습니다.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* Transactions Tab */}
|
|
|
|
|
<TabsContent value="transactions">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>거래 내역</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="p-0">
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full">
|
|
|
|
|
<thead className="bg-muted">
|
|
|
|
|
<tr>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
일시
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
종목
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
유형
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
수량
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
가격
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
scope="col"
|
|
|
|
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
거래금액
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-border">
|
|
|
|
|
{transactions.map((tx) => (
|
|
|
|
|
<tr key={tx.id}>
|
|
|
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
|
|
|
{new Date(tx.created_at).toLocaleString('ko-KR')}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm font-medium">{tx.ticker}</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm text-center">
|
|
|
|
|
<span
|
|
|
|
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
|
|
|
tx.transaction_type === 'buy'
|
|
|
|
|
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
|
|
|
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{tx.transaction_type === 'buy' ? '매수' : '매도'}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm text-right">
|
|
|
|
|
{tx.quantity.toLocaleString()}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm text-right">
|
|
|
|
|
{formatCurrency(tx.price)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-3 text-sm text-right">
|
|
|
|
|
{formatCurrency(tx.quantity * tx.price)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
{transactions.length === 0 && (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
|
|
|
|
거래 내역이 없습니다.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* Analysis Tab */}
|
|
|
|
|
<TabsContent value="analysis">
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
{/* Allocation Chart */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>자산 배분</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<DonutChart data={getDonutData()} height={250} showLegend={true} />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Target vs Actual */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>목표 vs 실제 비중</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{portfolio.targets.map((target, index) => {
|
|
|
|
|
const holding = portfolio.holdings.find((h) => h.ticker === target.ticker);
|
|
|
|
|
const actualRatio = holding?.current_ratio ?? 0;
|
|
|
|
|
const diff = actualRatio - target.target_ratio;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={target.ticker} className="space-y-2">
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="font-medium">{target.ticker}</span>
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{actualRatio.toFixed(1)}% / {target.target_ratio.toFixed(1)}%
|
|
|
|
|
<span
|
|
|
|
|
className={`ml-2 ${
|
|
|
|
|
Math.abs(diff) > 5
|
|
|
|
|
? diff > 0
|
|
|
|
|
? 'text-orange-600'
|
|
|
|
|
: 'text-blue-600'
|
|
|
|
|
: 'text-green-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
({diff >= 0 ? '+' : ''}
|
|
|
|
|
{diff.toFixed(1)}%)
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative h-4 bg-muted rounded-full overflow-hidden">
|
|
|
|
|
{/* Target indicator */}
|
|
|
|
|
<div
|
|
|
|
|
className="absolute h-full w-0.5 bg-foreground/50 z-10"
|
|
|
|
|
style={{ left: `${Math.min(target.target_ratio, 100)}%` }}
|
|
|
|
|
/>
|
|
|
|
|
{/* Actual bar */}
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${Math.min(actualRatio, 100)}%`,
|
|
|
|
|
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{portfolio.targets.length === 0 && (
|
|
|
|
|
<p className="text-center text-muted-foreground py-8">
|
|
|
|
|
목표 비중이 설정되지 않았습니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2026-02-03 08:27:29 +09:00
|
|
|
</div>
|
2026-02-05 23:03:45 +09:00
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
2026-02-05 22:54:22 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</DashboardLayout>
|
2026-02-03 08:27:29 +09:00
|
|
|
);
|
|
|
|
|
}
|