feat(frontend): improve portfolio pages
- TradingView chart component with lightweight-charts v5 API - PortfolioCard component with mini pie chart and return display - Updated portfolio list with cards and empty state - Portfolio detail with charts, tabs (holdings/transactions/analysis) - Improved holdings table with progress bars for weight - Added tabs component from shadcn Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4f432fb85c
commit
c3d43c97d0
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -1838,6 +1839,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@ -6,7 +6,11 @@ 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 { api } from '@/lib/api';
|
||||
import { AreaData, Time } from 'lightweight-charts';
|
||||
|
||||
interface HoldingWithValue {
|
||||
ticker: string;
|
||||
@ -24,6 +28,15 @@ interface Target {
|
||||
target_ratio: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: number;
|
||||
ticker: string;
|
||||
transaction_type: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PortfolioDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -37,6 +50,42 @@ interface PortfolioDetail {
|
||||
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();
|
||||
@ -44,13 +93,14 @@ export default function PortfolioDetailPage() {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await api.getCurrentUser();
|
||||
await fetchPortfolio();
|
||||
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
@ -71,6 +121,16 @@ export default function PortfolioDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
|
||||
setTransactions(data);
|
||||
} catch {
|
||||
// Transactions may not exist yet
|
||||
setTransactions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return '-';
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
@ -85,10 +145,36 @@ export default function PortfolioDetailPage() {
|
||||
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 null;
|
||||
}
|
||||
|
||||
const chartData = portfolio ? generateChartData(portfolio.total_value) : [];
|
||||
const returnPercent = calculateReturnPercent();
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{error && (
|
||||
@ -102,11 +188,13 @@ export default function PortfolioDetailPage() {
|
||||
<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'
|
||||
}`}>
|
||||
<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>
|
||||
@ -116,7 +204,7 @@ export default function PortfolioDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<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>
|
||||
@ -136,62 +224,327 @@ export default function PortfolioDetailPage() {
|
||||
<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'
|
||||
}`}>
|
||||
<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>
|
||||
|
||||
{/* Holdings Table */}
|
||||
<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-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">
|
||||
{portfolio.holdings.map((holding) => (
|
||||
<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 text-right">{holding.current_ratio?.toFixed(2)}%</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>
|
||||
{/* 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
|
||||
@ -4,16 +4,25 @@ import { useEffect, useState } from 'react';
|
||||
import { 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 { PortfolioCard } from '@/components/portfolio/portfolio-card';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface HoldingWithValue {
|
||||
ticker: string;
|
||||
current_ratio: number | null;
|
||||
}
|
||||
|
||||
interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
portfolio_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
holdings?: HoldingWithValue[];
|
||||
total_value?: number | null;
|
||||
total_profit_loss?: number | null;
|
||||
total_invested?: number | null;
|
||||
}
|
||||
|
||||
export default function PortfolioListPage() {
|
||||
@ -40,15 +49,41 @@ export default function PortfolioListPage() {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await api.get<Portfolio[]>('/api/portfolios');
|
||||
setPortfolios(data);
|
||||
// Fetch details for each portfolio to get holdings and values
|
||||
const portfoliosWithDetails = await Promise.all(
|
||||
data.map(async (portfolio) => {
|
||||
try {
|
||||
const detail = await api.get<Portfolio>(`/api/portfolios/${portfolio.id}/detail`);
|
||||
return {
|
||||
...portfolio,
|
||||
holdings: detail.holdings ?? [],
|
||||
total_value: detail.total_value ?? null,
|
||||
total_profit_loss: detail.total_profit_loss ?? null,
|
||||
total_invested: detail.total_invested ?? null,
|
||||
};
|
||||
} catch {
|
||||
return portfolio;
|
||||
}
|
||||
})
|
||||
);
|
||||
setPortfolios(portfoliosWithDetails);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch portfolios';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
return type === 'pension' ? '퇴직연금' : '일반';
|
||||
const calculateReturnPercent = (portfolio: Portfolio): number | null => {
|
||||
if (
|
||||
portfolio.total_profit_loss === null ||
|
||||
portfolio.total_profit_loss === undefined ||
|
||||
portfolio.total_invested === null ||
|
||||
portfolio.total_invested === undefined ||
|
||||
portfolio.total_invested === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (portfolio.total_profit_loss / portfolio.total_invested) * 100;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@ -72,35 +107,46 @@ export default function PortfolioListPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{portfolios.map((portfolio) => (
|
||||
<Link key={portfolio.id} href={`/portfolio/${portfolio.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{portfolio.name}</CardTitle>
|
||||
<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'
|
||||
}`}>
|
||||
{getTypeLabel(portfolio.portfolio_type)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생성일: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<PortfolioCard
|
||||
key={portfolio.id}
|
||||
id={portfolio.id}
|
||||
name={portfolio.name}
|
||||
portfolioType={portfolio.portfolio_type}
|
||||
totalValue={portfolio.total_value ?? null}
|
||||
returnPercent={calculateReturnPercent(portfolio)}
|
||||
holdings={portfolio.holdings ?? []}
|
||||
/>
|
||||
))}
|
||||
|
||||
{portfolios.length === 0 && !error && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{portfolios.length === 0 && !error && (
|
||||
<div className="text-center py-16 px-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
아직 포트폴리오가 없습니다
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
새 포트폴리오를 생성하여 자산을 관리해보세요.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/portfolio/new">첫 포트폴리오 만들기</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
62
frontend/src/components/charts/trading-view-chart.tsx
Normal file
62
frontend/src/components/charts/trading-view-chart.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createChart, IChartApi, AreaData, Time, AreaSeries } from "lightweight-charts";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface TradingViewChartProps {
|
||||
data: AreaData<Time>[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function TradingViewChart({ data, height = 300 }: TradingViewChartProps) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return;
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
height,
|
||||
layout: {
|
||||
background: { color: isDark ? "hsl(222.2, 84%, 4.9%)" : "hsl(0, 0%, 100%)" },
|
||||
textColor: isDark ? "hsl(210, 40%, 98%)" : "hsl(222.2, 84%, 4.9%)",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: isDark ? "hsl(217.2, 32.6%, 17.5%)" : "hsl(214.3, 31.8%, 91.4%)" },
|
||||
horzLines: { color: isDark ? "hsl(217.2, 32.6%, 17.5%)" : "hsl(214.3, 31.8%, 91.4%)" },
|
||||
},
|
||||
rightPriceScale: { borderColor: isDark ? "hsl(217.2, 32.6%, 17.5%)" : "hsl(214.3, 31.8%, 91.4%)" },
|
||||
timeScale: { borderColor: isDark ? "hsl(217.2, 32.6%, 17.5%)" : "hsl(214.3, 31.8%, 91.4%)" },
|
||||
});
|
||||
|
||||
const areaSeries = chart.addSeries(AreaSeries, {
|
||||
lineColor: "hsl(221.2, 83.2%, 53.3%)",
|
||||
topColor: "hsla(221.2, 83.2%, 53.3%, 0.4)",
|
||||
bottomColor: "hsla(221.2, 83.2%, 53.3%, 0.0)",
|
||||
});
|
||||
|
||||
areaSeries.setData(data);
|
||||
chart.timeScale().fitContent();
|
||||
chartRef.current = chart;
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current) {
|
||||
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
chart.remove();
|
||||
};
|
||||
}, [data, height, resolvedTheme]);
|
||||
|
||||
return <div ref={chartContainerRef} />;
|
||||
}
|
||||
162
frontend/src/components/portfolio/portfolio-card.tsx
Normal file
162
frontend/src/components/portfolio/portfolio-card.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface Holding {
|
||||
ticker: string;
|
||||
current_ratio: number | null;
|
||||
}
|
||||
|
||||
interface PortfolioCardProps {
|
||||
id: number;
|
||||
name: string;
|
||||
portfolioType: string;
|
||||
totalValue: number | null;
|
||||
returnPercent: number | null;
|
||||
strategy?: string;
|
||||
needsRebalancing?: boolean;
|
||||
holdings: Holding[];
|
||||
}
|
||||
|
||||
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%)',
|
||||
];
|
||||
|
||||
export function PortfolioCard({
|
||||
id,
|
||||
name,
|
||||
portfolioType,
|
||||
totalValue,
|
||||
returnPercent,
|
||||
strategy,
|
||||
needsRebalancing,
|
||||
holdings,
|
||||
}: PortfolioCardProps) {
|
||||
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 getTypeLabel = (type: string) => {
|
||||
return type === 'pension' ? '퇴직연금' : '일반';
|
||||
};
|
||||
|
||||
const pieData = holdings
|
||||
.filter((h) => h.current_ratio !== null && h.current_ratio > 0)
|
||||
.slice(0, 6)
|
||||
.map((h, index) => ({
|
||||
name: h.ticker,
|
||||
value: h.current_ratio ?? 0,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Link href={`/portfolio/${id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{name}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{needsRebalancing && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
리밸런싱 필요
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
portfolioType === 'pension'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(portfolioType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{strategy && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 w-fit">
|
||||
{strategy}
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Mini Pie Chart */}
|
||||
{pieData.length > 0 && (
|
||||
<div className="w-16 h-16 flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={15}
|
||||
outerRadius={30}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value and Return */}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground">총 평가금액</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{formatCurrency(totalValue)}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
(returnPercent ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(returnPercent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holdings Preview */}
|
||||
{pieData.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{pieData.slice(0, 4).map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
))}
|
||||
{pieData.length > 4 && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
+{pieData.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ui/tabs.tsx
Normal file
55
frontend/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Loading…
x
Reference in New Issue
Block a user