567 lines
22 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TradingViewChart } from '@/components/charts/trading-view-chart';
import { DonutChart } from '@/components/charts/donut-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
import type { AreaData, Time } from 'lightweight-charts';
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 Transaction {
id: number;
ticker: string;
tx_type: string;
quantity: number;
price: number;
executed_at: string;
}
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;
}
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;
}
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 [transactions, setTransactions] = useState<Transaction[]>([]);
const [error, setError] = useState<string | null>(null);
const fetchPortfolio = useCallback(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);
}
}, [portfolioId]);
const fetchTransactions = useCallback(async () => {
try {
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
setTransactions(data);
} catch {
// Transactions may not exist yet
setTransactions([]);
}
}, [portfolioId]);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
await Promise.all([fetchPortfolio(), fetchTransactions()]);
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router, fetchPortfolio, fetchTransactions]);
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)}%`;
};
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],
}));
};
if (loading) {
return (
<DashboardLayout>
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-28" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-80 rounded-xl" />
</DashboardLayout>
);
}
const chartData = portfolio ? generateChartData(portfolio.total_value) : [];
const returnPercent = calculateReturnPercent();
return (
<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>
<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'
}`}
>
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
<Button asChild>
<Link href={`/portfolio/${portfolioId}/rebalance`}></Link>
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<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>
<p
className={`text-2xl font-bold ${
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatCurrency(portfolio.total_profit_loss)}
</p>
</CardContent>
</Card>
<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>
</div>
{/* 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.executed_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.tx_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.tx_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>
</div>
</TabsContent>
</Tabs>
</>
)}
</DashboardLayout>
);
}