머니페니 741b7fa7dd feat: add skip/limit pagination to prices, snapshots, and transactions APIs
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>
2026-03-18 22:32:34 +09:00

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>
);
}