Add paginated responses (items/total/skip/limit) to:
- GET /api/data/stocks/{ticker}/prices (default limit=365)
- GET /api/data/etfs/{ticker}/prices (default limit=365)
- GET /api/portfolios/{id}/snapshots (default limit=100)
- GET /api/portfolios/{id}/transactions (default limit=50)
Frontend: update snapshot/transaction consumers to handle new response
shape, add "Load more" button to transaction table.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter } 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 { api } from '@/lib/api';
|
|
|
|
interface SnapshotItem {
|
|
id: number;
|
|
portfolio_id: number;
|
|
total_value: string;
|
|
snapshot_date: string;
|
|
}
|
|
|
|
interface SnapshotDetail {
|
|
id: number;
|
|
portfolio_id: number;
|
|
total_value: string;
|
|
snapshot_date: string;
|
|
holdings: {
|
|
ticker: string;
|
|
name: string | null;
|
|
quantity: number;
|
|
price: string;
|
|
value: string;
|
|
current_ratio: string;
|
|
}[];
|
|
}
|
|
|
|
interface ReturnDataPoint {
|
|
date: string;
|
|
total_value: string;
|
|
daily_return: string | null;
|
|
cumulative_return: string | null;
|
|
benchmark_return: string | null;
|
|
}
|
|
|
|
interface ReturnsData {
|
|
portfolio_id: number;
|
|
start_date: string | null;
|
|
end_date: string | null;
|
|
total_return: string | null;
|
|
cagr: string | null;
|
|
benchmark_total_return: string | null;
|
|
data: ReturnDataPoint[];
|
|
}
|
|
|
|
export default function PortfolioHistoryPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const portfolioId = params.id as string;
|
|
|
|
const [snapshots, setSnapshots] = useState<SnapshotItem[]>([]);
|
|
const [returns, setReturns] = useState<ReturnsData | null>(null);
|
|
const [selectedSnapshot, setSelectedSnapshot] = useState<SnapshotDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creating, setCreating] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'snapshots' | 'returns'>('snapshots');
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const [snapshotsRes, returnsData] = await Promise.all([
|
|
api.get<{ items: SnapshotItem[]; total: number }>(`/api/portfolios/${portfolioId}/snapshots`),
|
|
api.get<ReturnsData>(`/api/portfolios/${portfolioId}/returns`),
|
|
]);
|
|
|
|
setSnapshots(snapshotsRes.items);
|
|
setReturns(returnsData);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === 'API request failed') {
|
|
router.push('/login');
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [portfolioId]);
|
|
|
|
const handleCreateSnapshot = async () => {
|
|
setCreating(true);
|
|
setError(null);
|
|
|
|
try {
|
|
await api.post(`/api/portfolios/${portfolioId}/snapshots`);
|
|
await fetchData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleViewSnapshot = async (snapshotId: number) => {
|
|
try {
|
|
const data = await api.get<SnapshotDetail>(`/api/portfolios/${portfolioId}/snapshots/${snapshotId}`);
|
|
setSelectedSnapshot(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
}
|
|
};
|
|
|
|
const handleDeleteSnapshot = async (snapshotId: number) => {
|
|
if (!confirm('이 스냅샷을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
await api.delete(`/api/portfolios/${portfolioId}/snapshots/${snapshotId}`);
|
|
setSelectedSnapshot(null);
|
|
await fetchData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
}
|
|
};
|
|
|
|
const formatCurrency = (value: string | number) => {
|
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
return new Intl.NumberFormat('ko-KR', {
|
|
style: 'currency',
|
|
currency: 'KRW',
|
|
maximumFractionDigits: 0,
|
|
}).format(num);
|
|
};
|
|
|
|
const formatPercent = (value: string | number | null) => {
|
|
if (value === null) return '-';
|
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`;
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
});
|
|
};
|
|
|
|
if (loading) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<Link
|
|
href={`/portfolio/${portfolioId}`}
|
|
className="text-primary hover:underline text-sm"
|
|
>
|
|
← 포트폴리오로 돌아가기
|
|
</Link>
|
|
<h1 className="text-2xl font-bold text-foreground mt-2">
|
|
포트폴리오 히스토리
|
|
</h1>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-6">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary Cards */}
|
|
{returns && returns.total_return !== null && (
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-sm text-muted-foreground">총 수익률</div>
|
|
<div
|
|
className={`text-2xl font-bold ${
|
|
parseFloat(returns.total_return || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{formatPercent(returns.total_return)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-sm text-muted-foreground">CAGR</div>
|
|
<div
|
|
className={`text-2xl font-bold ${
|
|
parseFloat(returns.cagr || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{formatPercent(returns.cagr)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-sm text-muted-foreground">벤치마크 (KOSPI)</div>
|
|
<div
|
|
className={`text-2xl font-bold ${
|
|
parseFloat(returns.benchmark_total_return || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{returns.benchmark_total_return !== null
|
|
? formatPercent(returns.benchmark_total_return)
|
|
: '-'}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-sm text-muted-foreground">시작일</div>
|
|
<div className="text-lg font-semibold text-foreground">
|
|
{returns.start_date ? formatDate(returns.start_date) : '-'}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-sm text-muted-foreground">종료일</div>
|
|
<div className="text-lg font-semibold text-foreground">
|
|
{returns.end_date ? formatDate(returns.end_date) : '-'}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<Card>
|
|
<div className="border-b border-border">
|
|
<div className="flex">
|
|
<button
|
|
onClick={() => setActiveTab('snapshots')}
|
|
className={`px-6 py-3 text-sm font-medium ${
|
|
activeTab === 'snapshots'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
스냅샷 목록
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('returns')}
|
|
className={`px-6 py-3 text-sm font-medium ${
|
|
activeTab === 'returns'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
수익률 추이
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<CardContent className="p-6">
|
|
{activeTab === 'snapshots' && (
|
|
<div>
|
|
{/* Create Snapshot Button */}
|
|
<div className="mb-4">
|
|
<Button
|
|
onClick={handleCreateSnapshot}
|
|
disabled={creating}
|
|
>
|
|
{creating ? '생성 중...' : '스냅샷 생성'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Snapshots Table */}
|
|
{snapshots.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-border">
|
|
<thead className="bg-muted">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
|
날짜
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
총 평가금액
|
|
</th>
|
|
<th className="px-6 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
|
|
작업
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-background divide-y divide-border">
|
|
{snapshots.map((snapshot) => (
|
|
<tr key={snapshot.id} className="hover:bg-muted/50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
|
{formatDate(snapshot.snapshot_date)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
|
|
{formatCurrency(snapshot.total_value)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
|
<button
|
|
onClick={() => handleViewSnapshot(snapshot.id)}
|
|
className="text-primary hover:underline mr-4"
|
|
>
|
|
상세
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteSnapshot(snapshot.id)}
|
|
className="text-destructive hover:underline"
|
|
>
|
|
삭제
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'returns' && (
|
|
<div>
|
|
{returns && returns.data.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-border">
|
|
<thead className="bg-muted">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
|
날짜
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
평가금액
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
일간 수익률
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
누적 수익률
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
벤치마크(KOSPI)
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-background divide-y divide-border">
|
|
{returns.data.map((point, index) => (
|
|
<tr key={index} className="hover:bg-muted/50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
|
{formatDate(point.date)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
|
|
{formatCurrency(point.total_value)}
|
|
</td>
|
|
<td
|
|
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
|
|
parseFloat(point.daily_return || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{formatPercent(point.daily_return)}
|
|
</td>
|
|
<td
|
|
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
|
|
parseFloat(point.cumulative_return || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{formatPercent(point.cumulative_return)}
|
|
</td>
|
|
<td
|
|
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
|
|
parseFloat(point.benchmark_return || '0') >= 0
|
|
? 'text-green-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{formatPercent(point.benchmark_return)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Snapshot Detail Modal */}
|
|
{selectedSnapshot && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<Card className="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
|
<CardHeader>
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<CardTitle>스냅샷 상세</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
{formatDate(selectedSnapshot.snapshot_date)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedSnapshot(null)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
X
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="mb-4">
|
|
<div className="text-sm text-muted-foreground">총 평가금액</div>
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{formatCurrency(selectedSnapshot.total_value)}
|
|
</div>
|
|
</div>
|
|
|
|
<h3 className="font-medium text-foreground mb-2">보유 종목</h3>
|
|
<table className="min-w-full divide-y divide-border">
|
|
<thead className="bg-muted">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
|
|
종목
|
|
</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
수량
|
|
</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
가격
|
|
</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
평가금액
|
|
</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
|
|
비중
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{selectedSnapshot.holdings.map((holding) => (
|
|
<tr key={holding.ticker}>
|
|
<td className="px-4 py-2 text-sm text-foreground" title={holding.ticker}>
|
|
{holding.name || holding.ticker}
|
|
</td>
|
|
<td className="px-4 py-2 text-sm text-right text-foreground">
|
|
{holding.quantity.toLocaleString()}
|
|
</td>
|
|
<td className="px-4 py-2 text-sm text-right text-foreground">
|
|
{formatCurrency(holding.price)}
|
|
</td>
|
|
<td className="px-4 py-2 text-sm text-right font-medium text-foreground">
|
|
{formatCurrency(holding.value)}
|
|
</td>
|
|
<td className="px-4 py-2 text-sm text-right text-foreground">
|
|
{parseFloat(holding.current_ratio).toFixed(2)}%
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</DashboardLayout>
|
|
);
|
|
}
|