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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import Link from 'next/link';
|
|||||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { api } from '@/lib/api';
|
||||||
|
import { AreaData, Time } from 'lightweight-charts';
|
||||||
|
|
||||||
interface HoldingWithValue {
|
interface HoldingWithValue {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@ -24,6 +28,15 @@ interface Target {
|
|||||||
target_ratio: number;
|
target_ratio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: number;
|
||||||
|
ticker: string;
|
||||||
|
transaction_type: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PortfolioDetail {
|
interface PortfolioDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -37,6 +50,42 @@ interface PortfolioDetail {
|
|||||||
total_profit_loss: number | null;
|
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() {
|
export default function PortfolioDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -44,13 +93,14 @@ export default function PortfolioDetailPage() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
|
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
await api.getCurrentUser();
|
await api.getCurrentUser();
|
||||||
await fetchPortfolio();
|
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
||||||
} catch {
|
} catch {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
} finally {
|
} 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) => {
|
const formatCurrency = (value: number | null) => {
|
||||||
if (value === null) return '-';
|
if (value === null) return '-';
|
||||||
return new Intl.NumberFormat('ko-KR', {
|
return new Intl.NumberFormat('ko-KR', {
|
||||||
@ -85,10 +145,36 @@ export default function PortfolioDetailPage() {
|
|||||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
|
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) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartData = portfolio ? generateChartData(portfolio.total_value) : [];
|
||||||
|
const returnPercent = calculateReturnPercent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
{error && (
|
{error && (
|
||||||
@ -102,11 +188,13 @@ export default function PortfolioDetailPage() {
|
|||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{portfolio.name}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{portfolio.name}</h1>
|
||||||
<span className={`text-xs px-2 py-1 rounded ${
|
<span
|
||||||
portfolio.portfolio_type === 'pension'
|
className={`text-xs px-2 py-1 rounded ${
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
portfolio.portfolio_type === 'pension'
|
||||||
: 'bg-muted text-muted-foreground'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||||
}`}>
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
|
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +204,7 @@ export default function PortfolioDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* 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>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
<p className="text-sm text-muted-foreground mb-1">총 평가금액</p>
|
||||||
@ -136,62 +224,327 @@ export default function PortfolioDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
<p className="text-sm text-muted-foreground mb-1">총 손익</p>
|
||||||
<p className={`text-2xl font-bold ${
|
<p
|
||||||
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
className={`text-2xl font-bold ${
|
||||||
}`}>
|
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formatCurrency(portfolio.total_profit_loss)}
|
{formatCurrency(portfolio.total_profit_loss)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Holdings Table */}
|
{/* Chart Section */}
|
||||||
<Card>
|
{chartData.length > 0 && (
|
||||||
<CardHeader>
|
<Card className="mb-6">
|
||||||
<CardTitle>보유 자산</CardTitle>
|
<CardHeader>
|
||||||
</CardHeader>
|
<CardTitle>포트폴리오 가치 추이</CardTitle>
|
||||||
<CardContent className="p-0">
|
</CardHeader>
|
||||||
<div className="overflow-x-auto">
|
<CardContent>
|
||||||
<table className="w-full">
|
<TradingViewChart data={chartData} height={300} />
|
||||||
<thead className="bg-muted">
|
</CardContent>
|
||||||
<tr>
|
</Card>
|
||||||
<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>
|
{/* Tabs Section */}
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
|
<Tabs defaultValue="holdings" className="space-y-4">
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">평가금액</th>
|
<TabsList>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">비중</th>
|
<TabsTrigger value="holdings">보유종목</TabsTrigger>
|
||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">손익률</th>
|
<TabsTrigger value="transactions">거래내역</TabsTrigger>
|
||||||
</tr>
|
<TabsTrigger value="analysis">분석</TabsTrigger>
|
||||||
</thead>
|
</TabsList>
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{portfolio.holdings.map((holding) => (
|
{/* Holdings Tab */}
|
||||||
<tr key={holding.ticker}>
|
<TabsContent value="holdings">
|
||||||
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td>
|
<Card>
|
||||||
<td className="px-4 py-3 text-sm text-right">{holding.quantity.toLocaleString()}</td>
|
<CardHeader>
|
||||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.avg_price)}</td>
|
<CardTitle>보유 자산</CardTitle>
|
||||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.current_price)}</td>
|
</CardHeader>
|
||||||
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.value)}</td>
|
<CardContent className="p-0">
|
||||||
<td className="px-4 py-3 text-sm text-right">{holding.current_ratio?.toFixed(2)}%</td>
|
<div className="overflow-x-auto">
|
||||||
<td className={`px-4 py-3 text-sm text-right ${
|
<table className="w-full">
|
||||||
(holding.profit_loss_ratio ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
|
<thead className="bg-muted">
|
||||||
}`}>
|
<tr>
|
||||||
{formatPercent(holding.profit_loss_ratio)}
|
<th
|
||||||
</td>
|
scope="col"
|
||||||
</tr>
|
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
|
||||||
))}
|
>
|
||||||
{portfolio.holdings.length === 0 && (
|
종목
|
||||||
<tr>
|
</th>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
<th
|
||||||
보유 자산이 없습니다.
|
scope="col"
|
||||||
</td>
|
className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"
|
||||||
</tr>
|
>
|
||||||
)}
|
수량
|
||||||
</tbody>
|
</th>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@ -4,16 +4,25 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PortfolioCard } from '@/components/portfolio/portfolio-card';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface HoldingWithValue {
|
||||||
|
ticker: string;
|
||||||
|
current_ratio: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Portfolio {
|
interface Portfolio {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
portfolio_type: string;
|
portfolio_type: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
holdings?: HoldingWithValue[];
|
||||||
|
total_value?: number | null;
|
||||||
|
total_profit_loss?: number | null;
|
||||||
|
total_invested?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PortfolioListPage() {
|
export default function PortfolioListPage() {
|
||||||
@ -40,15 +49,41 @@ export default function PortfolioListPage() {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await api.get<Portfolio[]>('/api/portfolios');
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to fetch portfolios';
|
const message = err instanceof Error ? err.message : 'Failed to fetch portfolios';
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const calculateReturnPercent = (portfolio: Portfolio): number | null => {
|
||||||
return type === 'pension' ? '퇴직연금' : '일반';
|
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) {
|
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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{portfolios.map((portfolio) => (
|
{portfolios.map((portfolio) => (
|
||||||
<Link key={portfolio.id} href={`/portfolio/${portfolio.id}`}>
|
<PortfolioCard
|
||||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
key={portfolio.id}
|
||||||
<CardHeader className="pb-2">
|
id={portfolio.id}
|
||||||
<div className="flex justify-between items-start">
|
name={portfolio.name}
|
||||||
<CardTitle className="text-lg">{portfolio.name}</CardTitle>
|
portfolioType={portfolio.portfolio_type}
|
||||||
<span className={`text-xs px-2 py-1 rounded ${
|
totalValue={portfolio.total_value ?? null}
|
||||||
portfolio.portfolio_type === 'pension'
|
returnPercent={calculateReturnPercent(portfolio)}
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
holdings={portfolio.holdings ?? []}
|
||||||
: '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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{portfolios.length === 0 && !error && (
|
|
||||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
|
||||||
아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
</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