feat(frontend): apply DashboardLayout to all pages

- Portfolio pages updated with DashboardLayout and shadcn/ui Card components
- Strategy pages updated (multi-factor, quality, value-momentum)
- Backtest pages updated with consistent styling
- Admin data management page updated
- Login page improved with shadcn/ui Card, Input, Button, Label
- All pages now support dark mode via CSS variables
- Removed old Sidebar/Header imports, using unified DashboardLayout
- Added shadcn/ui input and label components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-05 22:54:22 +09:00
parent 3e733ec1b8
commit eb3ce0e6e7
17 changed files with 1718 additions and 1801 deletions

View File

@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@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-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
@ -1563,6 +1564,52 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"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-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"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-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",

View File

@ -11,6 +11,7 @@
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@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-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",

View File

@ -2,8 +2,9 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
interface JobLog {
@ -16,12 +17,6 @@ interface JobLog {
error_msg: string | null;
}
interface User {
id: number;
username: string;
email: string;
}
const collectors = [
{ key: 'stocks', label: '종목 마스터', description: 'KRX에서 종목 정보 수집' },
{ key: 'sectors', label: '섹터 정보', description: 'WISEindex에서 섹터 분류 수집' },
@ -31,7 +26,6 @@ const collectors = [
export default function DataManagementPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [jobs, setJobs] = useState<JobLog[]>([]);
const [collecting, setCollecting] = useState<string | null>(null);
@ -41,8 +35,7 @@ export default function DataManagementPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
await fetchJobs();
} catch {
router.push('/login');
@ -91,115 +84,107 @@ export default function DataManagementPage() {
const getStatusBadge = (status: string) => {
const colors: Record<string, string> = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
running: 'bg-yellow-100 text-yellow-800',
success: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
running: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
};
return colors[status] || 'bg-gray-100 text-gray-800';
return colors[status] || 'bg-muted text-muted-foreground';
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="bg-white rounded-lg shadow mb-6">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
</div>
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{collectors.map((col) => (
<div
key={col.key}
className="border rounded-lg p-4 flex flex-col"
>
<h3 className="font-medium text-gray-800">{col.label}</h3>
<p className="text-sm text-gray-500 mb-4">{col.description}</p>
<button
onClick={() => runCollector(col.key)}
disabled={collecting !== null}
aria-label={`${col.label} 수집 실행`}
className="mt-auto px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
>
{collecting === col.key ? '수집 중...' : '실행'}
</button>
</div>
))}
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b flex justify-between items-center">
<h2 className="text-lg font-semibold"> </h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400"
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{collectors.map((col) => (
<div
key={col.key}
className="border border-border rounded-lg p-4 flex flex-col"
>
{refreshing ? '새로고침 중...' : '새로고침'}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{jobs.map((job) => (
<tr key={job.id}>
<td className="px-4 py-3 text-sm">{job.job_name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${getStatusBadge(job.status)}`}>
{job.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{new Date(job.started_at).toLocaleString('ko-KR')}
</td>
<td className="px-4 py-3 text-sm">{job.records_count ?? '-'}</td>
<td className="px-4 py-3 text-sm text-red-600 truncate max-w-xs">
{job.error_msg || '-'}
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
<h3 className="font-medium text-foreground">{col.label}</h3>
<p className="text-sm text-muted-foreground mb-4">{col.description}</p>
<Button
onClick={() => runCollector(col.key)}
disabled={collecting !== null}
aria-label={`${col.label} 수집 실행`}
className="mt-auto"
>
{collecting === col.key ? '수집 중...' : '실행'}
</Button>
</div>
))}
</div>
</main>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle> </CardTitle>
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-sm text-primary hover:text-primary/80 disabled:text-muted-foreground"
>
{refreshing ? '새로고침 중...' : '새로고침'}
</button>
</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-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-left text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{jobs.map((job) => (
<tr key={job.id}>
<td className="px-4 py-3 text-sm">{job.job_name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${getStatusBadge(job.status)}`}>
{job.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(job.started_at).toLocaleString('ko-KR')}
</td>
<td className="px-4 py-3 text-sm">{job.records_count ?? '-'}</td>
<td className="px-4 py-3 text-sm text-destructive truncate max-w-xs">
{job.error_msg || '-'}
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</DashboardLayout>
);
}

View File

@ -2,15 +2,11 @@
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
interface BacktestMetrics {
total_return: number;
cagr: number;
@ -87,7 +83,6 @@ export default function BacktestDetailPage() {
const params = useParams();
const backtestId = params.id as string;
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [backtest, setBacktest] = useState<BacktestDetail | null>(null);
const [equityCurve, setEquityCurve] = useState<EquityCurvePoint[]>([]);
@ -122,8 +117,7 @@ export default function BacktestDetailPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
await fetchBacktest();
} catch {
router.push('/login');
@ -156,10 +150,10 @@ export default function BacktestDetailPage() {
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const labels: Record<string, string> = {
pending: '대기중',
@ -168,261 +162,269 @@ export default function BacktestDetailPage() {
failed: '실패',
};
return (
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-gray-100'}`}>
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-muted'}`}>
{labels[status] || status}
</span>
);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
if (!backtest) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Backtest not found</div>
</div>
<DashboardLayout>
<div className="text-center text-muted-foreground">Backtest not found</div>
</DashboardLayout>
);
}
const selectedHoldings = holdings.find((h) => h.rebalance_date === selectedRebalance);
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800">
{strategyLabels[backtest.strategy_type] || backtest.strategy_type}
</h1>
<p className="text-sm text-gray-500">
{backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]}
</p>
</div>
<div className="flex items-center gap-4">
{getStatusBadge(backtest.status)}
</div>
<DashboardLayout>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-foreground">
{strategyLabels[backtest.strategy_type] || backtest.strategy_type}
</h1>
<p className="text-sm text-muted-foreground">
{backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]}
</p>
</div>
<div className="flex items-center gap-4">
{getStatusBadge(backtest.status)}
</div>
</div>
{/* Status Messages */}
{backtest.status === 'pending' && (
<div className="bg-yellow-100 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200 px-4 py-3 rounded mb-6">
...
</div>
)}
{backtest.status === 'running' && (
<div className="bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 px-4 py-3 rounded mb-6">
... .
</div>
)}
{backtest.status === 'failed' && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-6">
: {backtest.error_message}
</div>
)}
{/* Results (only show when completed) */}
{backtest.status === 'completed' && backtest.result && (
<>
{/* Metrics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> </div>
<div className={`text-xl font-bold ${backtest.result.total_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.total_return)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">CAGR</div>
<div className={`text-xl font-bold ${backtest.result.cagr >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.cagr)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">MDD</div>
<div className="text-xl font-bold text-red-600">
{formatNumber(backtest.result.mdd)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-xl font-bold text-foreground">
{formatNumber(backtest.result.sharpe_ratio)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"></div>
<div className="text-xl font-bold text-foreground">
{formatNumber(backtest.result.volatility)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"></div>
<div className={`text-xl font-bold ${backtest.result.benchmark_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.benchmark_return)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> </div>
<div className={`text-xl font-bold ${backtest.result.excess_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.excess_return)}%
</div>
</CardContent>
</Card>
</div>
{/* Status Messages */}
{backtest.status === 'pending' && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded mb-6">
...
</div>
)}
{backtest.status === 'running' && (
<div className="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded mb-6">
... .
</div>
)}
{backtest.status === 'failed' && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-6">
: {backtest.error_message}
</div>
)}
{/* Results (only show when completed) */}
{backtest.status === 'completed' && backtest.result && (
<>
{/* Metrics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"> </div>
<div className={`text-xl font-bold ${backtest.result.total_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.total_return)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">CAGR</div>
<div className={`text-xl font-bold ${backtest.result.cagr >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.cagr)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">MDD</div>
<div className="text-xl font-bold text-red-600">
{formatNumber(backtest.result.mdd)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"> </div>
<div className="text-xl font-bold text-gray-800">
{formatNumber(backtest.result.sharpe_ratio)}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-xl font-bold text-gray-800">
{formatNumber(backtest.result.volatility)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"></div>
<div className={`text-xl font-bold ${backtest.result.benchmark_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.benchmark_return)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"> </div>
<div className={`text-xl font-bold ${backtest.result.excess_return >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatNumber(backtest.result.excess_return)}%
</div>
</div>
</div>
{/* Equity Curve */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="h-64 flex items-center justify-center text-gray-400">
{equityCurve.length > 0 ? (
<div className="w-full">
<div className="flex justify-between text-sm text-gray-500 mb-2">
<span>: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}</span>
<span>: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}</span>
</div>
<div className="text-center text-gray-400">
( - {equityCurve.length} )
</div>
{/* Equity Curve */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center text-muted-foreground">
{equityCurve.length > 0 ? (
<div className="w-full">
<div className="flex justify-between text-sm text-muted-foreground mb-2">
<span>: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}</span>
<span>: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}</span>
</div>
) : (
'데이터 없음'
)}
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b">
<nav className="flex">
<button
onClick={() => setActiveTab('holdings')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'holdings'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setActiveTab('transactions')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'transactions'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</nav>
</div>
{/* Holdings Tab */}
{activeTab === 'holdings' && (
<div className="p-4">
<div className="mb-4">
<label htmlFor="rebalance-date" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="rebalance-date"
value={selectedRebalance || ''}
onChange={(e) => setSelectedRebalance(e.target.value)}
className="px-3 py-2 border rounded"
>
{holdings.map((h) => (
<option key={h.rebalance_date} value={h.rebalance_date}>
{h.rebalance_date}
</option>
))}
</select>
<div className="text-center text-muted-foreground">
( - {equityCurve.length} )
</div>
{selectedHoldings && (
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y">
{selectedHoldings.holdings.map((h) => (
<tr key={h.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium">{h.ticker}</div>
<div className="text-xs text-gray-500">{h.name}</div>
</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(h.weight)}%</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.shares)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.price)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
'데이터 없음'
)}
</div>
</CardContent>
</Card>
{/* Transactions Tab */}
{activeTab === 'transactions' && (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
{/* Tabs */}
<Card>
<div className="border-b border-border">
<nav className="flex">
<button
onClick={() => setActiveTab('holdings')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'holdings'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
<button
onClick={() => setActiveTab('transactions')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'transactions'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
</nav>
</div>
{/* Holdings Tab */}
{activeTab === 'holdings' && (
<CardContent className="p-4">
<div className="mb-4 space-y-2">
<Label htmlFor="rebalance-date"> </Label>
<select
id="rebalance-date"
value={selectedRebalance || ''}
onChange={(e) => setSelectedRebalance(e.target.value)}
className="flex h-10 w-auto rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{holdings.map((h) => (
<option key={h.rebalance_date} value={h.rebalance_date}>
{h.rebalance_date}
</option>
))}
</select>
</div>
{selectedHoldings && (
<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>
</tr>
</thead>
<tbody className="divide-y divide-border">
{selectedHoldings.holdings.map((h) => (
<tr key={h.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{h.ticker}</div>
<div className="text-xs text-muted-foreground">{h.name}</div>
</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(h.weight)}%</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.shares)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(h.price)}</td>
</tr>
</thead>
<tbody className="divide-y">
{transactions.map((t) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm">{t.date}</td>
<td className="px-4 py-3 text-sm font-medium">{t.ticker}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${
t.action === 'buy' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{t.action === 'buy' ? '매수' : '매도'}
</span>
</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.shares)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.commission)}</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
</tbody>
</table>
)}
</CardContent>
)}
{/* Transactions Tab */}
{activeTab === 'transactions' && (
<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((t) => (
<tr key={t.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm">{t.date}</td>
<td className="px-4 py-3 text-sm font-medium">{t.ticker}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${
t.action === 'buy'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{t.action === 'buy' ? '매수' : '매도'}
</span>
</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.shares)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(t.commission)}</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
</main>
</div>
</div>
)}
</Card>
</>
)}
</DashboardLayout>
);
}

View File

@ -3,15 +3,13 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
interface BacktestListItem {
id: number;
strategy_type: string;
@ -40,7 +38,6 @@ const periodOptions = [
export default function BacktestPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [backtests, setBacktests] = useState<BacktestListItem[]>([]);
@ -68,8 +65,7 @@ export default function BacktestPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
await fetchBacktests();
} catch {
router.push('/login');
@ -136,10 +132,10 @@ export default function BacktestPage() {
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const labels: Record<string, string> = {
pending: '대기중',
@ -148,7 +144,7 @@ export default function BacktestPage() {
failed: '실패',
};
return (
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-gray-100'}`}>
<span className={`px-2 py-1 rounded text-xs ${styles[status] || 'bg-muted'}`}>
{labels[status] || status}
</span>
);
@ -160,339 +156,293 @@ export default function BacktestPage() {
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"></h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"></h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* New Backtest Form */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="strategy"></Label>
<select
id="strategy"
value={strategyType}
onChange={(e) => setStrategyType(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{strategyOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="start-date"></Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date"></Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
)}
{/* New Backtest Form */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="period"> </Label>
<select
id="period"
value={rebalancePeriod}
onChange={(e) => setRebalancePeriod(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{periodOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="capital"> </Label>
<Input
id="capital"
type="number"
value={initialCapital}
onChange={(e) => setInitialCapital(parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="top-n"> </Label>
<Input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
/>
</div>
</div>
{/* Strategy-specific params */}
{strategyType === 'multi_factor' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label htmlFor="strategy" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="strategy"
value={strategyType}
onChange={(e) => setStrategyType(e.target.value)}
className="w-full px-3 py-2 border rounded"
>
{strategyOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="start-date" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border rounded"
<div className="space-y-2">
<Label htmlFor="value-weight"> </Label>
<Input
id="value-weight"
type="number"
step="0.01"
min="0"
max="1"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
/>
</div>
<div>
<label htmlFor="end-date" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border rounded"
<div className="space-y-2">
<Label htmlFor="quality-weight"> </Label>
<Input
id="quality-weight"
type="number"
step="0.01"
min="0"
max="1"
value={qualityWeight}
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="momentum-weight"> </Label>
<Input
id="momentum-weight"
type="number"
step="0.01"
min="0"
max="1"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label htmlFor="period" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="period"
value={rebalancePeriod}
onChange={(e) => setRebalancePeriod(e.target.value)}
className="w-full px-3 py-2 border rounded"
>
{periodOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="capital" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="capital"
type="number"
value={initialCapital}
onChange={(e) => setInitialCapital(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
{/* Strategy-specific params */}
{strategyType === 'multi_factor' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="value-weight"
type="number"
step="0.01"
min="0"
max="1"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="quality-weight"
type="number"
step="0.01"
min="0"
max="1"
value={qualityWeight}
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="momentum-weight"
type="number"
step="0.01"
min="0"
max="1"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
)}
{strategyType === 'quality' && (
<div className="mb-4">
<label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
F-Score
</label>
<input
{strategyType === 'quality' && (
<div className="mb-4">
<div className="space-y-2 max-w-xs">
<Label htmlFor="min-fscore"> F-Score</Label>
<Input
id="min-fscore"
type="number"
min="0"
max="9"
value={minFscore}
onChange={(e) => setMinFscore(parseInt(e.target.value))}
className="w-32 px-3 py-2 border rounded"
/>
</div>
)}
{strategyType === 'value_momentum' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="vm-value" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="vm-value"
type="number"
step="0.1"
min="0"
max="1"
value={vmValueWeight}
onChange={(e) => setVmValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="vm-momentum" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="vm-momentum"
type="number"
step="0.1"
min="0"
max="1"
value={vmMomentumWeight}
onChange={(e) => setVmMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
)}
{/* Advanced options */}
<div className="mb-4">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-blue-600 hover:underline"
>
{showAdvanced ? '고급 옵션 숨기기' : '고급 옵션 보기'}
</button>
</div>
)}
{showAdvanced && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="commission" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="commission"
type="number"
step="0.00001"
value={commissionRate}
onChange={(e) => setCommissionRate(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="slippage" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="slippage"
type="number"
step="0.0001"
value={slippageRate}
onChange={(e) => setSlippageRate(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
{strategyType === 'value_momentum' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="vm-value"> </Label>
<Input
id="vm-value"
type="number"
step="0.1"
min="0"
max="1"
value={vmValueWeight}
onChange={(e) => setVmValueWeight(parseFloat(e.target.value))}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="vm-momentum"> </Label>
<Input
id="vm-momentum"
type="number"
step="0.1"
min="0"
max="1"
value={vmMomentumWeight}
onChange={(e) => setVmMomentumWeight(parseFloat(e.target.value))}
/>
</div>
</div>
)}
{/* Advanced options */}
<div className="mb-4">
<button
type="submit"
disabled={submitting}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-primary hover:underline"
>
{submitting ? '실행 중...' : '백테스트 실행'}
{showAdvanced ? '고급 옵션 숨기기' : '고급 옵션 보기'}
</button>
</form>
</div>
</div>
{/* Backtest List */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">CAGR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">MDD</th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
{showAdvanced && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="commission"></Label>
<Input
id="commission"
type="number"
step="0.00001"
value={commissionRate}
onChange={(e) => setCommissionRate(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slippage"></Label>
<Input
id="slippage"
type="number"
step="0.0001"
value={slippageRate}
onChange={(e) => setSlippageRate(parseFloat(e.target.value))}
/>
</div>
</div>
)}
<Button type="submit" disabled={submitting}>
{submitting ? '실행 중...' : '백테스트 실행'}
</Button>
</form>
</CardContent>
</Card>
{/* Backtest List */}
<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-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">CAGR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">MDD</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-left text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{backtests.map((bt) => (
<tr key={bt.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<Link href={`/backtest/${bt.id}`} className="text-primary hover:underline">
{strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type}
</Link>
</td>
<td className="px-4 py-3 text-sm">
{bt.start_date} ~ {bt.end_date}
</td>
<td className="px-4 py-3 text-sm">
{periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period}
</td>
<td className={`px-4 py-3 text-sm text-right ${(bt.total_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'}
</td>
<td className={`px-4 py-3 text-sm text-right ${(bt.cagr ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'}
</td>
<td className="px-4 py-3 text-sm text-right text-red-600">
{bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'}
</td>
<td className="px-4 py-3 text-center">
{getStatusBadge(bt.status)}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(bt.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
</thead>
<tbody className="divide-y">
{backtests.map((bt) => (
<tr key={bt.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/backtest/${bt.id}`} className="text-blue-600 hover:underline">
{strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type}
</Link>
</td>
<td className="px-4 py-3 text-sm">
{bt.start_date} ~ {bt.end_date}
</td>
<td className="px-4 py-3 text-sm">
{periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period}
</td>
<td className={`px-4 py-3 text-sm text-right ${(bt.total_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'}
</td>
<td className={`px-4 py-3 text-sm text-right ${(bt.cagr ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'}
</td>
<td className="px-4 py-3 text-sm text-right text-red-600">
{bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'}
</td>
<td className="px-4 py-3 text-center">
{getStatusBadge(bt.status)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(bt.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
))}
{backtests.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
{backtests.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</div>
</div>
</CardContent>
</Card>
</DashboardLayout>
);
}

View File

@ -2,6 +2,10 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
export default function LoginPage() {
@ -27,62 +31,50 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold text-center text-gray-800 mb-8">
Galaxy-PO
</h1>
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-1">
<CardTitle className="text-2xl font-bold">Galaxy-PO</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="사용자명을 입력하세요"
required
/>
</div>
)}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,8 +1,11 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
interface SnapshotItem {
id: number;
@ -52,13 +55,13 @@ export default function PortfolioHistoryPage() {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"snapshots" | "returns">("snapshots");
const [activeTab, setActiveTab] = useState<'snapshots' | 'returns'>('snapshots');
const fetchData = async () => {
try {
const token = localStorage.getItem("token");
const token = localStorage.getItem('token');
if (!token) {
router.push("/login");
router.push('/login');
return;
}
@ -70,7 +73,7 @@ export default function PortfolioHistoryPage() {
]);
if (!snapshotsRes.ok || !returnsRes.ok) {
throw new Error("Failed to fetch data");
throw new Error('Failed to fetch data');
}
const [snapshotsData, returnsData] = await Promise.all([
@ -81,7 +84,7 @@ export default function PortfolioHistoryPage() {
setSnapshots(snapshotsData);
setReturns(returnsData);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
@ -97,26 +100,26 @@ export default function PortfolioHistoryPage() {
setError(null);
try {
const token = localStorage.getItem("token");
const token = localStorage.getItem('token');
const res = await fetch(
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots`,
{
method: "POST",
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Failed to create snapshot");
throw new Error(data.detail || 'Failed to create snapshot');
}
await fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setCreating(false);
}
@ -124,7 +127,7 @@ export default function PortfolioHistoryPage() {
const handleViewSnapshot = async (snapshotId: number) => {
try {
const token = localStorage.getItem("token");
const token = localStorage.getItem('token');
const res = await fetch(
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`,
{
@ -133,369 +136,369 @@ export default function PortfolioHistoryPage() {
);
if (!res.ok) {
throw new Error("Failed to fetch snapshot");
throw new Error('Failed to fetch snapshot');
}
const data = await res.json();
setSelectedSnapshot(data);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
const handleDeleteSnapshot = async (snapshotId: number) => {
if (!confirm("이 스냅샷을 삭제하시겠습니까?")) return;
if (!confirm('이 스냅샷을 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem("token");
const token = localStorage.getItem('token');
const res = await fetch(
`http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`,
{
method: "DELETE",
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
}
);
if (!res.ok) {
throw new Error("Failed to delete snapshot");
throw new Error('Failed to delete snapshot');
}
setSelectedSnapshot(null);
await fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
const formatCurrency = (value: string | number) => {
const num = typeof value === "string" ? parseFloat(value) : value;
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(num);
};
const formatPercent = (value: string | number | null) => {
if (value === null) return "-";
const num = typeof value === "string" ? parseFloat(value) : value;
return `${num >= 0 ? "+" : ""}${num.toFixed(2)}%`;
if (value === null) return '-';
const num = typeof value === 'string' ? parseFloat(value) : value;
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
return new Date(dateStr).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500"> ...</div>
</div>
);
return null;
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-6">
<Link
href={`/portfolio/${portfolioId}`}
className="text-blue-600 hover:underline text-sm"
>
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-2">
</h1>
<DashboardLayout>
{/* Header */}
<div className="mb-6">
<Link
href={`/portfolio/${portfolioId}`}
className="text-primary hover:underline text-sm"
>
</Link>
<h1 className="text-2xl font-bold text-foreground mt-2">
</h1>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-6">
{error}
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
{error}
</div>
)}
{/* Summary Cards */}
{returns && returns.total_return !== null && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"> </div>
{/* Summary Cards */}
{returns && returns.total_return !== null && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> </div>
<div
className={`text-2xl font-bold ${
parseFloat(returns.total_return || "0") >= 0
? "text-green-600"
: "text-red-600"
parseFloat(returns.total_return || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{formatPercent(returns.total_return)}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">CAGR</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">CAGR</div>
<div
className={`text-2xl font-bold ${
parseFloat(returns.cagr || "0") >= 0
? "text-green-600"
: "text-red-600"
parseFloat(returns.cagr || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{formatPercent(returns.cagr)}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-gray-900">
{returns.start_date ? formatDate(returns.start_date) : "-"}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"></div>
<div className="text-lg font-semibold text-foreground">
{returns.start_date ? formatDate(returns.start_date) : '-'}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-gray-900">
{returns.end_date ? formatDate(returns.end_date) : "-"}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"></div>
<div className="text-lg font-semibold text-foreground">
{returns.end_date ? formatDate(returns.end_date) : '-'}
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<div className="flex">
<button
onClick={() => setActiveTab("snapshots")}
className={`px-6 py-3 text-sm font-medium ${
activeTab === "snapshots"
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
<button
onClick={() => setActiveTab("returns")}
className={`px-6 py-3 text-sm font-medium ${
activeTab === "returns"
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
</div>
</div>
<div className="p-6">
{activeTab === "snapshots" && (
<div>
{/* Create Snapshot Button */}
<div className="mb-4">
<button
onClick={handleCreateSnapshot}
disabled={creating}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{creating ? "생성 중..." : "스냅샷 생성"}
</button>
</div>
{/* Snapshots Table */}
{snapshots.length === 0 ? (
<div className="text-center py-8 text-gray-500">
. .
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{snapshots.map((snapshot) => (
<tr key={snapshot.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(snapshot.snapshot_date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
{formatCurrency(snapshot.total_value)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<button
onClick={() => handleViewSnapshot(snapshot.id)}
className="text-blue-600 hover:underline mr-4"
>
</button>
<button
onClick={() => handleDeleteSnapshot(snapshot.id)}
className="text-red-600 hover:underline"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === "returns" && (
<div>
{returns && returns.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{returns.data.map((point, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(point.date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
{formatCurrency(point.total_value)}
</td>
<td
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
parseFloat(point.daily_return || "0") >= 0
? "text-green-600"
: "text-red-600"
}`}
>
{formatPercent(point.daily_return)}
</td>
<td
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
parseFloat(point.cumulative_return || "0") >= 0
? "text-green-600"
: "text-red-600"
}`}
>
{formatPercent(point.cumulative_return)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
. .
</div>
)}
</div>
)}
{/* Tabs */}
<Card>
<div className="border-b border-border">
<div className="flex">
<button
onClick={() => setActiveTab('snapshots')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'snapshots'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
<button
onClick={() => setActiveTab('returns')}
className={`px-6 py-3 text-sm font-medium ${
activeTab === 'returns'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
</div>
</div>
{/* Snapshot Detail Modal */}
{selectedSnapshot && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-xl font-bold text-gray-900">
</h2>
<p className="text-sm text-gray-500">
{formatDate(selectedSnapshot.snapshot_date)}
</p>
</div>
<button
onClick={() => setSelectedSnapshot(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="mb-4">
<div className="text-sm text-gray-500"> </div>
<div className="text-2xl font-bold text-gray-900">
{formatCurrency(selectedSnapshot.total_value)}
</div>
</div>
<h3 className="font-medium text-gray-900 mb-2"> </h3>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{selectedSnapshot.holdings.map((holding) => (
<tr key={holding.ticker}>
<td className="px-4 py-2 text-sm text-gray-900">
{holding.ticker}
</td>
<td className="px-4 py-2 text-sm text-right text-gray-900">
{holding.quantity.toLocaleString()}
</td>
<td className="px-4 py-2 text-sm text-right text-gray-900">
{formatCurrency(holding.price)}
</td>
<td className="px-4 py-2 text-sm text-right font-medium text-gray-900">
{formatCurrency(holding.value)}
</td>
<td className="px-4 py-2 text-sm text-right text-gray-900">
{parseFloat(holding.current_ratio).toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
<CardContent className="p-6">
{activeTab === 'snapshots' && (
<div>
{/* Create Snapshot Button */}
<div className="mb-4">
<Button
onClick={handleCreateSnapshot}
disabled={creating}
>
{creating ? '생성 중...' : '스냅샷 생성'}
</Button>
</div>
{/* Snapshots Table */}
{snapshots.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
. .
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
</th>
</tr>
</thead>
<tbody className="bg-background divide-y divide-border">
{snapshots.map((snapshot) => (
<tr key={snapshot.id} className="hover:bg-muted/50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
{formatDate(snapshot.snapshot_date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
{formatCurrency(snapshot.total_value)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<button
onClick={() => handleViewSnapshot(snapshot.id)}
className="text-primary hover:underline mr-4"
>
</button>
<button
onClick={() => handleDeleteSnapshot(snapshot.id)}
className="text-destructive hover:underline"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'returns' && (
<div>
{returns && returns.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
</tr>
</thead>
<tbody className="bg-background divide-y divide-border">
{returns.data.map((point, index) => (
<tr key={index} className="hover:bg-muted/50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
{formatDate(point.date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-foreground">
{formatCurrency(point.total_value)}
</td>
<td
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
parseFloat(point.daily_return || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{formatPercent(point.daily_return)}
</td>
<td
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
parseFloat(point.cumulative_return || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{formatPercent(point.cumulative_return)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
. .
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Snapshot Detail Modal */}
{selectedSnapshot && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground">
{formatDate(selectedSnapshot.snapshot_date)}
</p>
</div>
<button
onClick={() => setSelectedSnapshot(null)}
className="text-muted-foreground hover:text-foreground"
>
X
</button>
</div>
</CardHeader>
<CardContent>
<div className="mb-4">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-2xl font-bold text-foreground">
{formatCurrency(selectedSnapshot.total_value)}
</div>
</div>
<h3 className="font-medium text-foreground mb-2"> </h3>
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{selectedSnapshot.holdings.map((holding) => (
<tr key={holding.ticker}>
<td className="px-4 py-2 text-sm text-foreground">
{holding.ticker}
</td>
<td className="px-4 py-2 text-sm text-right text-foreground">
{holding.quantity.toLocaleString()}
</td>
<td className="px-4 py-2 text-sm text-right text-foreground">
{formatCurrency(holding.price)}
</td>
<td className="px-4 py-2 text-sm text-right font-medium text-foreground">
{formatCurrency(holding.value)}
</td>
<td className="px-4 py-2 text-sm text-right text-foreground">
{parseFloat(holding.current_ratio).toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
)}
</DashboardLayout>
);
}

View File

@ -3,8 +3,9 @@
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
interface HoldingWithValue {
@ -85,119 +86,114 @@ export default function PortfolioDetailPage() {
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
<DashboardLayout>
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{portfolio && (
<>
<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'
}`}>
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
)}
<Button asChild>
<Link href={`/portfolio/${portfolioId}/rebalance`}></Link>
</Button>
</div>
{portfolio && (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800">{portfolio.name}</h1>
<span className={`text-xs px-2 py-1 rounded ${
portfolio.portfolio_type === 'pension'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
<Link
href={`/portfolio/${portfolioId}/rebalance`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(portfolio.total_value)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(portfolio.total_invested)}
</p>
</CardContent>
</Card>
<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'
}`}>
{formatCurrency(portfolio.total_profit_loss)}
</p>
</CardContent>
</Card>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_value)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_invested)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className={`text-2xl font-bold ${
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(portfolio.total_profit_loss)}
</p>
</div>
</div>
{/* Holdings Table */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
{/* 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>
</thead>
<tbody className="divide-y">
{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-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
{portfolio.holdings.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
</main>
</div>
</div>
</CardContent>
</Card>
</>
)}
</DashboardLayout>
);
}

View File

@ -2,8 +2,11 @@
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface RebalanceItem {
@ -95,9 +98,9 @@ export default function RebalancePage() {
const getActionBadge = (action: string) => {
const styles: Record<string, string> = {
buy: 'bg-green-100 text-green-800',
sell: 'bg-red-100 text-red-800',
hold: 'bg-gray-100 text-gray-800',
buy: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hold: 'bg-muted text-muted-foreground',
};
const labels: Record<string, string> = {
buy: '매수',
@ -112,138 +115,128 @@ export default function RebalancePage() {
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Simulation Input */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label htmlFor="additional-amount" className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<input
id="additional-amount"
type="number"
value={additionalAmount}
onChange={(e) => setAdditionalAmount(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="예: 1000000"
/>
</div>
<button
onClick={simulate}
disabled={!additionalAmount || simulating}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
>
{simulating ? '계산 중...' : '시뮬레이션'}
</button>
<button
onClick={fetchRebalance}
className="px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
>
</button>
{/* Simulation Input */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex gap-4 items-end">
<div className="flex-1">
<Label htmlFor="additional-amount"> ()</Label>
<Input
id="additional-amount"
type="number"
value={additionalAmount}
onChange={(e) => setAdditionalAmount(e.target.value)}
placeholder="예: 1000000"
className="mt-2"
/>
</div>
<Button
onClick={simulate}
disabled={!additionalAmount || simulating}
>
{simulating ? '계산 중...' : '시뮬레이션'}
</Button>
<Button variant="outline" onClick={fetchRebalance}>
</Button>
</div>
</CardContent>
</Card>
{rebalance && (
<>
{/* Summary */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-500"> </p>
<p className="text-xl font-bold">
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
</p>
</div>
{'additional_amount' in rebalance && (
<>
<div>
<p className="text-sm text-gray-500"> </p>
<p className="text-xl font-bold text-blue-600">
+{formatCurrency(rebalance.additional_amount)}
</p>
</div>
<div>
<p className="text-sm text-gray-500"> </p>
<p className="text-xl font-bold">
{formatCurrency(rebalance.new_total)}
</p>
</div>
</>
)}
{rebalance && (
<>
{/* Summary */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold">
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
</p>
</div>
{'additional_amount' in rebalance && (
<>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold text-blue-600">
+{formatCurrency(rebalance.additional_amount)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold">
{formatCurrency(rebalance.new_total)}
</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Rebalance Table */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"> </th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600"></th>
{/* Rebalance 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-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rebalance.items.map((item) => (
<tr key={item.ticker}>
<td className="px-4 py-3">
<div className="font-medium">{item.ticker}</div>
{item.name && <div className="text-xs text-muted-foreground">{item.name}</div>}
</td>
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
<td className={`px-4 py-3 text-sm text-right ${
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
}`}>
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
}`}>
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
</td>
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
</tr>
</thead>
<tbody className="divide-y">
{rebalance.items.map((item) => (
<tr key={item.ticker}>
<td className="px-4 py-3">
<div className="font-medium">{item.ticker}</div>
{item.name && <div className="text-xs text-gray-500">{item.name}</div>}
</td>
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
<td className={`px-4 py-3 text-sm text-right ${
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
}`}>
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
}`}>
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
</td>
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
</>
)}
</main>
</div>
</div>
</CardContent>
</Card>
</>
)}
</DashboardLayout>
);
}

View File

@ -2,8 +2,11 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
export default function NewPortfolioPage() {
@ -33,71 +36,68 @@ export default function NewPortfolioPage() {
};
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="max-w-md">
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label htmlFor="portfolio-name" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
<div className="max-w-md">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="portfolio-name"> </Label>
<Input
id="portfolio-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="예: 퇴직연금 포트폴리오"
required
/>
</div>
<div className="mb-6">
<label htmlFor="portfolio-type" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="space-y-2">
<Label htmlFor="portfolio-type"></Label>
<select
id="portfolio-type"
value={portfolioType}
onChange={(e) => setPortfolioType(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="general"></option>
<option value="pension"></option>
</select>
</div>
<div className="flex gap-2">
<button
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => router.back()}
className="flex-1 px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
>
</button>
<button
</Button>
<Button
type="submit"
disabled={loading || !name}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
className="flex-1"
>
{loading ? '생성 중...' : '생성'}
</button>
</Button>
</div>
</form>
</div>
</main>
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
);
}

View File

@ -3,8 +3,9 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
interface Portfolio {
@ -15,15 +16,8 @@ interface Portfolio {
updated_at: string;
}
interface User {
id: number;
username: string;
email: string;
}
export default function PortfolioListPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
const [error, setError] = useState<string | null>(null);
@ -31,8 +25,7 @@ export default function PortfolioListPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
await fetchPortfolios();
} catch {
router.push('/login');
@ -59,68 +52,55 @@ export default function PortfolioListPage() {
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Link
href="/portfolio/new"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
</Link>
</div>
<DashboardLayout>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<Button asChild>
<Link href="/portfolio/new"> </Link>
</Button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
<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}`}
className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
>
<div className="flex justify-between items-start mb-2">
<h2 className="text-lg font-semibold text-gray-800">
{portfolio.name}
</h2>
<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'
: 'bg-gray-100 text-gray-800'
? '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>
<p className="text-sm text-gray-500">
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')}
</p>
</Link>
))}
</CardContent>
</Card>
</Link>
))}
{portfolios.length === 0 && !error && (
<div className="col-span-full text-center py-12 text-gray-500">
. .
</div>
)}
{portfolios.length === 0 && !error && (
<div className="col-span-full text-center py-12 text-muted-foreground">
. .
</div>
</main>
)}
</div>
</div>
</DashboardLayout>
);
}

View File

@ -2,15 +2,13 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
interface StockFactor {
ticker: string;
name: string;
@ -37,7 +35,6 @@ interface StrategyResult {
export default function MultiFactorPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [result, setResult] = useState<StrategyResult | null>(null);
@ -52,8 +49,7 @@ export default function MultiFactorPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
} catch {
router.push('/login');
} finally {
@ -99,152 +95,132 @@ export default function MultiFactorPage() {
};
if (initialLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div>
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="value-weight"
type="number"
min="0"
max="1"
step="0.05"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="quality-weight"
type="number"
min="0"
max="1"
step="0.05"
value={qualityWeight}
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.05"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
{/* Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="value-weight"> </Label>
<Input
id="value-weight"
type="number"
min="0"
max="1"
step="0.05"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quality-weight"> </Label>
<Input
id="quality-weight"
type="number"
min="0"
max="1"
step="0.05"
value={qualityWeight}
onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="momentum-weight"> </Label>
<Input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.05"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="top-n"> </Label>
<Input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
/>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
<Button onClick={runStrategy} disabled={loading}>
{loading ? '실행 중...' : '전략 실행'}
</Button>
</CardContent>
</Card>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
{/* Results */}
{result && (
<Card>
<CardHeader>
<CardTitle>
({result.result_count}/{result.universe_count} )
</CardTitle>
<p className="text-sm text-muted-foreground">: {result.base_date}</p>
</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-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">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</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">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-muted-foreground">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
);
}

View File

@ -3,15 +3,10 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
const strategies = [
{
id: 'multi-factor',
@ -35,14 +30,12 @@ const strategies = [
export default function StrategyListPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
} catch {
router.push('/login');
} finally {
@ -53,38 +46,28 @@ export default function StrategyListPage() {
}, [router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">퀀 </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6">퀀 </h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{strategies.map((strategy) => (
<Link
key={strategy.id}
href={`/strategy/${strategy.id}`}
className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{strategies.map((strategy) => (
<Link key={strategy.id} href={`/strategy/${strategy.id}`}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="text-4xl mb-4">{strategy.icon}</div>
<h2 className="text-lg font-semibold text-gray-800 mb-2">
{strategy.name}
</h2>
<p className="text-sm text-gray-500">{strategy.description}</p>
</Link>
))}
</div>
</main>
<CardTitle>{strategy.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{strategy.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</DashboardLayout>
);
}

View File

@ -2,15 +2,13 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
interface StockFactor {
ticker: string;
name: string;
@ -36,7 +34,6 @@ interface StrategyResult {
export default function QualityStrategyPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [result, setResult] = useState<StrategyResult | null>(null);
@ -48,8 +45,7 @@ export default function QualityStrategyPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
} catch {
router.push('/login');
} finally {
@ -90,127 +86,113 @@ export default function QualityStrategyPage() {
};
if (initialLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
F-Score
</label>
<input
id="min-fscore"
type="number"
min="0"
max="9"
value={minFscore}
onChange={(e) => setMinFscore(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
{/* Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="min-fscore"> F-Score</Label>
<Input
id="min-fscore"
type="number"
min="0"
max="9"
value={minFscore}
onChange={(e) => setMinFscore(parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="top-n"> </Label>
<Input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
/>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
<Button onClick={runStrategy} disabled={loading}>
{loading ? '실행 중...' : '전략 실행'}
</Button>
</CardContent>
</Card>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">F-Score</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
{/* Results */}
{result && (
<Card>
<CardHeader>
<CardTitle>
({result.result_count}/{result.universe_count} )
</CardTitle>
<p className="text-sm text-muted-foreground">: {result.base_date}</p>
</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-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">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</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-center text-sm font-medium text-muted-foreground">F-Score</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">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-muted-foreground">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${
(stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
(stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
'bg-muted text-muted-foreground'
}`}>
{stock.fscore}/9
</span>
</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs ${
(stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800' :
(stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{stock.fscore}/9
</span>
</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
);
}

View File

@ -2,15 +2,13 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface User {
id: number;
username: string;
}
interface StockFactor {
ticker: string;
name: string;
@ -37,7 +35,6 @@ interface StrategyResult {
export default function ValueMomentumPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [result, setResult] = useState<StrategyResult | null>(null);
@ -50,8 +47,7 @@ export default function ValueMomentumPage() {
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await api.getCurrentUser();
} catch {
router.push('/login');
} finally {
@ -93,135 +89,118 @@ export default function ValueMomentumPage() {
};
if (initialLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
return null;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6"> </h1>
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="value-weight"
type="number"
min="0"
max="1"
step="0.1"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.1"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
className="w-full px-3 py-2 border rounded"
/>
</div>
{/* Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="space-y-2">
<Label htmlFor="value-weight"> </Label>
<Input
id="value-weight"
type="number"
min="0"
max="1"
step="0.1"
value={valueWeight}
onChange={(e) => setValueWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="momentum-weight"> </Label>
<Input
id="momentum-weight"
type="number"
min="0"
max="1"
step="0.1"
value={momentumWeight}
onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="top-n"> </Label>
<Input
id="top-n"
type="number"
min="1"
max="100"
value={topN}
onChange={(e) => setTopN(parseInt(e.target.value))}
/>
</div>
<button
onClick={runStrategy}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '실행 중...' : '전략 실행'}
</button>
</div>
<Button onClick={runStrategy} disabled={loading}>
{loading ? '실행 중...' : '전략 실행'}
</Button>
</CardContent>
</Card>
{/* Results */}
{result && (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">
({result.result_count}/{result.universe_count} )
</h2>
<p className="text-sm text-gray-500">: {result.base_date}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">()</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600"></th>
{/* Results */}
{result && (
<Card>
<CardHeader>
<CardTitle>
({result.result_count}/{result.universe_count} )
</CardTitle>
<p className="text-sm text-muted-foreground">: {result.base_date}</p>
</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-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">PER</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</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">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-muted-foreground">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
</tr>
</thead>
<tbody className="divide-y">
{result.stocks.map((stock) => (
<tr key={stock.ticker} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
<td className="px-4 py-3">
<div className="font-medium">{stock.ticker}</div>
<div className="text-xs text-gray-500">{stock.name}</div>
</td>
<td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
<td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
<td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
);
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }