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:
zephyrdark 2026-02-05 23:03:45 +09:00
parent 4f432fb85c
commit c3d43c97d0
7 changed files with 797 additions and 87 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

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

View 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} />;
}

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

View 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 }