From bc356d9edf49db5837765b16c818b7d4c300a0f3 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Tue, 3 Feb 2026 08:27:29 +0900 Subject: [PATCH] feat: add portfolio detail page with holdings --- frontend/src/app/portfolio/[id]/page.tsx | 203 +++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 frontend/src/app/portfolio/[id]/page.tsx diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx new file mode 100644 index 0000000..94f2425 --- /dev/null +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -0,0 +1,203 @@ +'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(null); + const [error, setError] = useState(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(`/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+ {error && ( +
+ {error} +
+ )} + + {portfolio && ( + <> +
+
+

{portfolio.name}

+ + {portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'} + +
+ + 리밸런싱 + +
+ + {/* Summary Cards */} +
+
+

총 평가금액

+

+ {formatCurrency(portfolio.total_value)} +

+
+
+

총 투자금액

+

+ {formatCurrency(portfolio.total_invested)} +

+
+
+

총 손익

+

= 0 ? 'text-green-600' : 'text-red-600' + }`}> + {formatCurrency(portfolio.total_profit_loss)} +

+
+
+ + {/* Holdings Table */} +
+
+

보유 자산

+
+
+ + + + + + + + + + + + + + {portfolio.holdings.map((holding) => ( + + + + + + + + + + ))} + {portfolio.holdings.length === 0 && ( + + + + )} + +
종목수량평균단가현재가평가금액비중손익률
{holding.ticker}{holding.quantity.toLocaleString()}{formatCurrency(holding.avg_price)}{formatCurrency(holding.current_price)}{formatCurrency(holding.value)}{holding.current_ratio?.toFixed(2)}%= 0 ? 'text-green-600' : 'text-red-600' + }`}> + {formatPercent(holding.profit_loss_ratio)} +
+ 보유 자산이 없습니다. +
+
+
+ + )} +
+
+
+ ); +}