From eb3ce0e6e7fdc15f0310338ff56962454e52c9f6 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Thu, 5 Feb 2026 22:54:22 +0900 Subject: [PATCH] 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 --- frontend/package-lock.json | 47 ++ frontend/package.json | 1 + frontend/src/app/admin/data/page.tsx | 205 +++--- frontend/src/app/backtest/[id]/page.tsx | 490 +++++++------- frontend/src/app/backtest/page.tsx | 586 ++++++++-------- frontend/src/app/login/page.tsx | 96 ++- .../src/app/portfolio/[id]/history/page.tsx | 631 +++++++++--------- frontend/src/app/portfolio/[id]/page.tsx | 212 +++--- .../src/app/portfolio/[id]/rebalance/page.tsx | 243 ++++--- frontend/src/app/portfolio/new/page.tsx | 72 +- frontend/src/app/portfolio/page.tsx | 96 ++- .../src/app/strategy/multi-factor/page.tsx | 270 ++++---- frontend/src/app/strategy/page.tsx | 57 +- frontend/src/app/strategy/quality/page.tsx | 226 +++---- .../src/app/strategy/value-momentum/page.tsx | 239 +++---- frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 26 + 17 files changed, 1718 insertions(+), 1801 deletions(-) create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b262a15..0dc8fbd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 5ba6529..573317d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/admin/data/page.tsx b/frontend/src/app/admin/data/page.tsx index af68ef1..339bf54 100644 --- a/frontend/src/app/admin/data/page.tsx +++ b/frontend/src/app/admin/data/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [jobs, setJobs] = useState([]); const [collecting, setCollecting] = useState(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 = { - 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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

데이터 수집 관리

+ +

데이터 수집 관리

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} -
-
-

수집 작업

-
-
-
- {collectors.map((col) => ( -
-

{col.label}

-

{col.description}

- -
- ))} -
-
-
- -
-
-

최근 작업 이력

- -
-
- - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - - ))} - {jobs.length === 0 && ( - - - - )} - -
작업명상태시작 시간건수에러
{job.job_name} - - {job.status} - - - {new Date(job.started_at).toLocaleString('ko-KR')} - {job.records_count ?? '-'} - {job.error_msg || '-'} -
- 아직 수집 이력이 없습니다. -
-
+

{col.label}

+

{col.description}

+ +
+ ))}
- -
- + + + + + + 최근 작업 이력 + + + +
+ + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + ))} + {jobs.length === 0 && ( + + + + )} + +
작업명상태시작 시간건수에러
{job.job_name} + + {job.status} + + + {new Date(job.started_at).toLocaleString('ko-KR')} + {job.records_count ?? '-'} + {job.error_msg || '-'} +
+ 아직 수집 이력이 없습니다. +
+
+
+
+ ); } diff --git a/frontend/src/app/backtest/[id]/page.tsx b/frontend/src/app/backtest/[id]/page.tsx index e38b5b7..2fc3b26 100644 --- a/frontend/src/app/backtest/[id]/page.tsx +++ b/frontend/src/app/backtest/[id]/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [backtest, setBacktest] = useState(null); const [equityCurve, setEquityCurve] = useState([]); @@ -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 = { - 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 = { pending: '대기중', @@ -168,261 +162,269 @@ export default function BacktestDetailPage() { failed: '실패', }; return ( - + {labels[status] || status} ); }; if (loading) { - return ( -
-
Loading...
-
- ); + return null; } if (!backtest) { return ( -
-
Backtest not found
-
+ +
Backtest not found
+
); } const selectedHoldings = holdings.find((h) => h.rebalance_date === selectedRebalance); return ( -
- -
-
-
- {/* Header */} -
-
-

- {strategyLabels[backtest.strategy_type] || backtest.strategy_type} 백테스트 -

-

- {backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]} -

-
-
- {getStatusBadge(backtest.status)} -
+ + {/* Header */} +
+
+

+ {strategyLabels[backtest.strategy_type] || backtest.strategy_type} 백테스트 +

+

+ {backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]} +

+
+
+ {getStatusBadge(backtest.status)} +
+
+ + {/* Status Messages */} + {backtest.status === 'pending' && ( +
+ 백테스트가 대기 중입니다... +
+ )} + + {backtest.status === 'running' && ( +
+ 백테스트가 실행 중입니다... 잠시만 기다려주세요. +
+ )} + + {backtest.status === 'failed' && ( +
+ 백테스트 실패: {backtest.error_message} +
+ )} + + {/* Results (only show when completed) */} + {backtest.status === 'completed' && backtest.result && ( + <> + {/* Metrics Cards */} +
+ + +
총 수익률
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.total_return)}% +
+
+
+ + +
CAGR
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.cagr)}% +
+
+
+ + +
MDD
+
+ {formatNumber(backtest.result.mdd)}% +
+
+
+ + +
샤프 비율
+
+ {formatNumber(backtest.result.sharpe_ratio)} +
+
+
+ + +
변동성
+
+ {formatNumber(backtest.result.volatility)}% +
+
+
+ + +
벤치마크
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.benchmark_return)}% +
+
+
+ + +
초과 수익
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.excess_return)}% +
+
+
- {/* Status Messages */} - {backtest.status === 'pending' && ( -
- 백테스트가 대기 중입니다... -
- )} - - {backtest.status === 'running' && ( -
- 백테스트가 실행 중입니다... 잠시만 기다려주세요. -
- )} - - {backtest.status === 'failed' && ( -
- 백테스트 실패: {backtest.error_message} -
- )} - - {/* Results (only show when completed) */} - {backtest.status === 'completed' && backtest.result && ( - <> - {/* Metrics Cards */} -
-
-
총 수익률
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatNumber(backtest.result.total_return)}% -
-
-
-
CAGR
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatNumber(backtest.result.cagr)}% -
-
-
-
MDD
-
- {formatNumber(backtest.result.mdd)}% -
-
-
-
샤프 비율
-
- {formatNumber(backtest.result.sharpe_ratio)} -
-
-
-
변동성
-
- {formatNumber(backtest.result.volatility)}% -
-
-
-
벤치마크
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatNumber(backtest.result.benchmark_return)}% -
-
-
-
초과 수익
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatNumber(backtest.result.excess_return)}% -
-
-
- - {/* Equity Curve */} -
-

자산 추이

-
- {equityCurve.length > 0 ? ( -
-
- 시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원 - 종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원 -
-
- (차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트) -
+ {/* Equity Curve */} + + + 자산 추이 + + +
+ {equityCurve.length > 0 ? ( +
+
+ 시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원 + 종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원
- ) : ( - '데이터 없음' - )} -
-
- - {/* Tabs */} -
-
- -
- - {/* Holdings Tab */} - {activeTab === 'holdings' && ( -
-
- - +
+ (차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트)
- {selectedHoldings && ( - - - - - - - - - - - {selectedHoldings.holdings.map((h) => ( - - - - - - - ))} - -
종목비중수량가격
-
{h.ticker}
-
{h.name}
-
{formatNumber(h.weight)}%{formatCurrency(h.shares)}{formatCurrency(h.price)}
- )}
+ ) : ( + '데이터 없음' )} +
+ + - {/* Transactions Tab */} - {activeTab === 'transactions' && ( -
- - - - - - - - - + {/* Tabs */} + +
+ +
+ + {/* Holdings Tab */} + {activeTab === 'holdings' && ( + +
+ + +
+ {selectedHoldings && ( +
날짜종목구분수량가격수수료
+ + + + + + + + + + {selectedHoldings.holdings.map((h) => ( + + + + + - - - {transactions.map((t) => ( - - - - - - - - - ))} - {transactions.length === 0 && ( - - - - )} - -
종목비중수량가격
+
{h.ticker}
+
{h.name}
+
{formatNumber(h.weight)}%{formatCurrency(h.shares)}{formatCurrency(h.price)}
{t.date}{t.ticker} - - {t.action === 'buy' ? '매수' : '매도'} - - {formatCurrency(t.shares)}{formatCurrency(t.price)}{formatCurrency(t.commission)}
- 거래 내역이 없습니다. -
-
+ ))} + + )} + + )} + + {/* Transactions Tab */} + {activeTab === 'transactions' && ( +
+ + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + ))} + {transactions.length === 0 && ( + + + + )} + +
날짜종목구분수량가격수수료
{t.date}{t.ticker} + + {t.action === 'buy' ? '매수' : '매도'} + + {formatCurrency(t.shares)}{formatCurrency(t.price)}{formatCurrency(t.commission)}
+ 거래 내역이 없습니다. +
- - )} -
-
-
+ )} + + + )} + ); } diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx index e667959..a680881 100644 --- a/frontend/src/app/backtest/page.tsx +++ b/frontend/src/app/backtest/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [backtests, setBacktests] = useState([]); @@ -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 = { - 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 = { pending: '대기중', @@ -148,7 +144,7 @@ export default function BacktestPage() { failed: '실패', }; return ( - + {labels[status] || status} ); @@ -160,339 +156,293 @@ export default function BacktestPage() { }; if (loading) { - return ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

백테스트

+ +

백테스트

- {error && ( -
- {error} + {error && ( +
+ {error} +
+ )} + + {/* New Backtest Form */} + + + 새 백테스트 + + +
+
+
+ + +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
- )} - {/* New Backtest Form */} -
-

새 백테스트

- +
+
+ + +
+
+ + setInitialCapital(parseInt(e.target.value))} + /> +
+
+ + setTopN(parseInt(e.target.value))} + /> +
+
+ + {/* Strategy-specific params */} + {strategyType === 'multi_factor' && (
-
- - -
-
- - setStartDate(e.target.value)} - className="w-full px-3 py-2 border rounded" +
+ + setValueWeight(parseFloat(e.target.value))} />
-
- - setEndDate(e.target.value)} - className="w-full px-3 py-2 border rounded" +
+ + setQualityWeight(parseFloat(e.target.value))} + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} />
+ )} -
-
- - -
-
- - setInitialCapital(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setTopN(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - {/* Strategy-specific params */} - {strategyType === 'multi_factor' && ( -
-
- - setValueWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setQualityWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setMomentumWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- )} - - {strategyType === 'quality' && ( -
- - +
+ + setMinFscore(parseInt(e.target.value))} - className="w-32 px-3 py-2 border rounded" />
- )} - - {strategyType === 'value_momentum' && ( -
-
- - setVmValueWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setVmMomentumWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- )} - - {/* Advanced options */} -
-
+ )} - {showAdvanced && ( -
-
- - setCommissionRate(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setSlippageRate(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
+ {strategyType === 'value_momentum' && ( +
+
+ + setVmValueWeight(parseFloat(e.target.value))} + />
- )} +
+ + setVmMomentumWeight(parseFloat(e.target.value))} + /> +
+
+ )} + {/* Advanced options */} +
- -
+
- {/* Backtest List */} -
-
-

백테스트 목록

-
-
- - - - - - - - - - - + {showAdvanced && ( +
+
+ + setCommissionRate(parseFloat(e.target.value))} + /> +
+
+ + setSlippageRate(parseFloat(e.target.value))} + /> +
+
+ )} + + + + + + + {/* Backtest List */} + + + 백테스트 목록 + + +
+
전략기간주기수익률CAGRMDD상태생성일
+ + + + + + + + + + + + + + {backtests.map((bt) => ( + + + + + + + + + - - - {backtests.map((bt) => ( - - - - - - - - - - - ))} - {backtests.length === 0 && ( - - - - )} - -
전략기간주기수익률CAGRMDD상태생성일
+ + {strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type} + + + {bt.start_date} ~ {bt.end_date} + + {periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'} + + {bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'} + + {getStatusBadge(bt.status)} + + {new Date(bt.created_at).toLocaleDateString('ko-KR')} +
- - {strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type} - - - {bt.start_date} ~ {bt.end_date} - - {periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period} - = 0 ? 'text-green-600' : 'text-red-600'}`}> - {bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'} - = 0 ? 'text-green-600' : 'text-red-600'}`}> - {bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'} - - {bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'} - - {getStatusBadge(bt.status)} - - {new Date(bt.created_at).toLocaleDateString('ko-KR')} -
- 아직 백테스트가 없습니다. -
-
+ ))} + {backtests.length === 0 && ( + + + 아직 백테스트가 없습니다. + + + )} + +
-
-
-
+ + + ); } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 2825a2a..4fc87d0 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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 ( -
-
-

- Galaxy-PO -

+
+ + + Galaxy-PO + 포트폴리오 최적화 플랫폼에 오신 것을 환영합니다 + + +
+ {error && ( +
+ {error} +
+ )} - - {error && ( -
- {error} +
+ + setUsername(e.target.value)} + placeholder="사용자명을 입력하세요" + required + />
- )} -
- - 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 - /> -
+
+ + setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + required + /> +
-
- - 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 - /> -
- - - -
+ + +
+
); } diff --git a/frontend/src/app/portfolio/[id]/history/page.tsx b/frontend/src/app/portfolio/[id]/history/page.tsx index fd8db2d..aec5424 100644 --- a/frontend/src/app/portfolio/[id]/history/page.tsx +++ b/frontend/src/app/portfolio/[id]/history/page.tsx @@ -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(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 ( -
-
로딩 중...
-
- ); + return null; } return ( -
-
- {/* Header */} -
- - ← 포트폴리오로 돌아가기 - -

- 포트폴리오 히스토리 -

+ + {/* Header */} +
+ + ← 포트폴리오로 돌아가기 + +

+ 포트폴리오 히스토리 +

+
+ + {error && ( +
+ {error}
+ )} - {error && ( -
- {error} -
- )} - - {/* Summary Cards */} - {returns && returns.total_return !== null && ( -
-
-
총 수익률
+ {/* Summary Cards */} + {returns && returns.total_return !== null && ( +
+ + +
총 수익률
= 0 - ? "text-green-600" - : "text-red-600" + parseFloat(returns.total_return || '0') >= 0 + ? 'text-green-600' + : 'text-red-600' }`} > {formatPercent(returns.total_return)}
-
-
-
CAGR
+ + + + +
CAGR
= 0 - ? "text-green-600" - : "text-red-600" + parseFloat(returns.cagr || '0') >= 0 + ? 'text-green-600' + : 'text-red-600' }`} > {formatPercent(returns.cagr)}
-
-
-
시작일
-
- {returns.start_date ? formatDate(returns.start_date) : "-"} + + + + +
시작일
+
+ {returns.start_date ? formatDate(returns.start_date) : '-'}
-
-
-
종료일
-
- {returns.end_date ? formatDate(returns.end_date) : "-"} + + + + +
종료일
+
+ {returns.end_date ? formatDate(returns.end_date) : '-'}
-
-
- )} + + +
+ )} - {/* Tabs */} -
-
-
- - -
-
- -
- {activeTab === "snapshots" && ( -
- {/* Create Snapshot Button */} -
- -
- - {/* Snapshots Table */} - {snapshots.length === 0 ? ( -
- 스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요. -
- ) : ( -
- - - - - - - - - - {snapshots.map((snapshot) => ( - - - - - - ))} - -
- 날짜 - - 총 평가금액 - - 작업 -
- {formatDate(snapshot.snapshot_date)} - - {formatCurrency(snapshot.total_value)} - - - -
-
- )} -
- )} - - {activeTab === "returns" && ( -
- {returns && returns.data.length > 0 ? ( -
- - - - - - - - - - - {returns.data.map((point, index) => ( - - - - - - - ))} - -
- 날짜 - - 평가금액 - - 일간 수익률 - - 누적 수익률 -
- {formatDate(point.date)} - - {formatCurrency(point.total_value)} - = 0 - ? "text-green-600" - : "text-red-600" - }`} - > - {formatPercent(point.daily_return)} - = 0 - ? "text-green-600" - : "text-red-600" - }`} - > - {formatPercent(point.cumulative_return)} -
-
- ) : ( -
- 수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요. -
- )} -
- )} + {/* Tabs */} + +
+
+ +
- {/* Snapshot Detail Modal */} - {selectedSnapshot && ( -
-
-
-
-
-

- 스냅샷 상세 -

-

- {formatDate(selectedSnapshot.snapshot_date)} -

-
- -
- -
-
총 평가금액
-
- {formatCurrency(selectedSnapshot.total_value)} -
-
- -

보유 종목

- - - - - - - - - - - - {selectedSnapshot.holdings.map((holding) => ( - - - - - - - - ))} - -
- 종목 - - 수량 - - 가격 - - 평가금액 - - 비중 -
- {holding.ticker} - - {holding.quantity.toLocaleString()} - - {formatCurrency(holding.price)} - - {formatCurrency(holding.value)} - - {parseFloat(holding.current_ratio).toFixed(2)}% -
+ + {activeTab === 'snapshots' && ( +
+ {/* Create Snapshot Button */} +
+
+ + {/* Snapshots Table */} + {snapshots.length === 0 ? ( +
+ 스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요. +
+ ) : ( +
+ + + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + ))} + +
+ 날짜 + + 총 평가금액 + + 작업 +
+ {formatDate(snapshot.snapshot_date)} + + {formatCurrency(snapshot.total_value)} + + + +
+
+ )}
-
- )} -
-
+ )} + + {activeTab === 'returns' && ( +
+ {returns && returns.data.length > 0 ? ( +
+ + + + + + + + + + + {returns.data.map((point, index) => ( + + + + + + + ))} + +
+ 날짜 + + 평가금액 + + 일간 수익률 + + 누적 수익률 +
+ {formatDate(point.date)} + + {formatCurrency(point.total_value)} + = 0 + ? 'text-green-600' + : 'text-red-600' + }`} + > + {formatPercent(point.daily_return)} + = 0 + ? 'text-green-600' + : 'text-red-600' + }`} + > + {formatPercent(point.cumulative_return)} +
+
+ ) : ( +
+ 수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요. +
+ )} +
+ )} + +
+ + {/* Snapshot Detail Modal */} + {selectedSnapshot && ( +
+ + +
+
+ 스냅샷 상세 +

+ {formatDate(selectedSnapshot.snapshot_date)} +

+
+ +
+
+ +
+
총 평가금액
+
+ {formatCurrency(selectedSnapshot.total_value)} +
+
+ +

보유 종목

+ + + + + + + + + + + + {selectedSnapshot.holdings.map((holding) => ( + + + + + + + + ))} + +
+ 종목 + + 수량 + + 가격 + + 평가금액 + + 비중 +
+ {holding.ticker} + + {holding.quantity.toLocaleString()} + + {formatCurrency(holding.price)} + + {formatCurrency(holding.value)} + + {parseFloat(holding.current_ratio).toFixed(2)}% +
+
+
+
+ )} + ); } diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index 94f2425..c860bdf 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
- {error && ( -
- {error} + + {error && ( +
+ {error} +
+ )} + + {portfolio && ( + <> +
+
+

{portfolio.name}

+ + {portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'} +
- )} + +
- {portfolio && ( - <> -
-
-

{portfolio.name}

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

총 평가금액

+

+ {formatCurrency(portfolio.total_value)} +

+
+
+ + +

총 투자금액

+

+ {formatCurrency(portfolio.total_invested)} +

+
+
+ + +

총 손익

+

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

+
+
+
- {/* Summary Cards */} -
-
-

총 평가금액

-

- {formatCurrency(portfolio.total_value)} -

-
-
-

총 투자금액

-

- {formatCurrency(portfolio.total_invested)} -

-
-
-

총 손익

-

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

-
-
- - {/* Holdings Table */} -
-
-

보유 자산

-
-
- - - - - - - - - - + {/* Holdings Table */} + + + 보유 자산 + + +
+
종목수량평균단가현재가평가금액비중손익률
+ + + + + + + + + + + + + {portfolio.holdings.map((holding) => ( + + + + + + + + - - - {portfolio.holdings.map((holding) => ( - - - - - - - - - - ))} - {portfolio.holdings.length === 0 && ( - - - - )} - -
종목수량평균단가현재가평가금액비중손익률
{holding.ticker}{holding.quantity.toLocaleString()}{formatCurrency(holding.avg_price)}{formatCurrency(holding.current_price)}{formatCurrency(holding.value)}{holding.current_ratio?.toFixed(2)}%= 0 ? 'text-green-600' : 'text-red-600' + }`}> + {formatPercent(holding.profit_loss_ratio)} +
{holding.ticker}{holding.quantity.toLocaleString()}{formatCurrency(holding.avg_price)}{formatCurrency(holding.current_price)}{formatCurrency(holding.value)}{holding.current_ratio?.toFixed(2)}%= 0 ? 'text-green-600' : 'text-red-600' - }`}> - {formatPercent(holding.profit_loss_ratio)} -
- 보유 자산이 없습니다. -
-
+ ))} + {portfolio.holdings.length === 0 && ( + + + 보유 자산이 없습니다. + + + )} + +
- - )} -
-
-
+ + + + )} + ); } diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx index 1e50b72..5649c08 100644 --- a/frontend/src/app/portfolio/[id]/rebalance/page.tsx +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -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 = { - 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 = { buy: '매수', @@ -112,138 +115,128 @@ export default function RebalancePage() { }; if (loading) { - return ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

리밸런싱 계산

+ +

리밸런싱 계산

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {/* Simulation Input */} -
-
-
- - 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" - /> -
- - + {/* Simulation Input */} + + +
+
+ + setAdditionalAmount(e.target.value)} + placeholder="예: 1000000" + className="mt-2" + />
+ +
+
+
- {rebalance && ( - <> - {/* Summary */} -
-
-
-

현재 총액

-

- {formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)} -

-
- {'additional_amount' in rebalance && ( - <> -
-

추가 입금

-

- +{formatCurrency(rebalance.additional_amount)} -

-
-
-

새 총액

-

- {formatCurrency(rebalance.new_total)} -

-
- - )} + {rebalance && ( + <> + {/* Summary */} + + +
+
+

현재 총액

+

+ {formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)} +

+ {'additional_amount' in rebalance && ( + <> +
+

추가 입금

+

+ +{formatCurrency(rebalance.additional_amount)} +

+
+
+

새 총액

+

+ {formatCurrency(rebalance.new_total)} +

+
+ + )}
+
+
- {/* Rebalance Table */} -
-
-

리밸런싱 내역

-
-
- - - - - - - - - - + {/* Rebalance Table */} + + + 리밸런싱 내역 + + +
+
종목목표 비중현재 비중현재 수량조정 금액조정 수량액션
+ + + + + + + + + + + + + {rebalance.items.map((item) => ( + + + + + + + + - - - {rebalance.items.map((item) => ( - - - - - - - - - - ))} - -
종목목표 비중현재 비중현재 수량조정 금액조정 수량액션
+
{item.ticker}
+ {item.name &&
{item.name}
} +
{item.target_ratio.toFixed(2)}%{item.current_ratio.toFixed(2)}%{item.current_quantity.toLocaleString()} 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : '' + }`}> + {item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)} + 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' + }`}> + {item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity} + {getActionBadge(item.action)}
-
{item.ticker}
- {item.name &&
{item.name}
} -
{item.target_ratio.toFixed(2)}%{item.current_ratio.toFixed(2)}%{item.current_quantity.toLocaleString()} 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : '' - }`}> - {item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)} - 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' - }`}> - {item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity} - {getActionBadge(item.action)}
-
+ ))} + +
- - )} -
-
-
+ + + + )} + ); } diff --git a/frontend/src/app/portfolio/new/page.tsx b/frontend/src/app/portfolio/new/page.tsx index cf0eb48..1924a21 100644 --- a/frontend/src/app/portfolio/new/page.tsx +++ b/frontend/src/app/portfolio/new/page.tsx @@ -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 ( -
- -
-
-
-

새 포트폴리오

+ +

새 포트폴리오

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} -
-
-
- - + + + 포트폴리오 정보 + + + +
+ + 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 />
-
- +
+
-
- - +
-
-
+ +
-
+ ); } diff --git a/frontend/src/app/portfolio/page.tsx b/frontend/src/app/portfolio/page.tsx index bcad07c..d70da2e 100644 --- a/frontend/src/app/portfolio/page.tsx +++ b/frontend/src/app/portfolio/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [portfolios, setPortfolios] = useState([]); const [error, setError] = useState(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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-
-

포트폴리오

- - 새 포트폴리오 - -
+ +
+

포트폴리오

+ +
- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} -
- {portfolios.map((portfolio) => ( - -
-

- {portfolio.name} -

+
+ {portfolios.map((portfolio) => ( + + + +
+ {portfolio.name} {getTypeLabel(portfolio.portfolio_type)}
-

+ + +

생성일: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')}

- - ))} + +
+ + ))} - {portfolios.length === 0 && !error && ( -
- 아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요. -
- )} + {portfolios.length === 0 && !error && ( +
+ 아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
-
+ )}
-
+ ); } diff --git a/frontend/src/app/strategy/multi-factor/page.tsx b/frontend/src/app/strategy/multi-factor/page.tsx index 0a831c4..df34f69 100644 --- a/frontend/src/app/strategy/multi-factor/page.tsx +++ b/frontend/src/app/strategy/multi-factor/page.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [result, setResult] = useState(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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

멀티 팩터 전략

+ +

멀티 팩터 전략

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {/* Settings */} -
-

전략 설정

-
-
- - setValueWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setQualityWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setMomentumWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setTopN(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
+ {/* Settings */} + + + 전략 설정 + + +
+
+ + setValueWeight(parseFloat(e.target.value))} + /> +
+
+ + setQualityWeight(parseFloat(e.target.value))} + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + /> +
+
+ + setTopN(parseInt(e.target.value))} + />
-
+ +
+
- {/* Results */} - {result && ( -
-
-

- 결과 ({result.result_count}/{result.universe_count} 종목) -

-

기준일: {result.base_date}

-
-
- - - - - - - - - - - - - - + {/* Results */} + {result && ( + + + + 결과 ({result.result_count}/{result.universe_count} 종목) + +

기준일: {result.base_date}

+
+ +
+
순위종목섹터시가총액(억)현재가PERPBR밸류퀄리티모멘텀종합
+ + + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + + - - - {result.stocks.map((stock) => ( - - - - - - - - - - - - - - ))} - -
순위종목섹터시가총액(억)현재가PERPBR밸류퀄리티모멘텀종합
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.quality_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
{stock.rank} -
{stock.ticker}
-
{stock.name}
-
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.quality_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
-
+ ))} + +
- )} -
-
-
+ + + )} + ); } diff --git a/frontend/src/app/strategy/page.tsx b/frontend/src/app/strategy/page.tsx index ce4abe9..5712de2 100644 --- a/frontend/src/app/strategy/page.tsx +++ b/frontend/src/app/strategy/page.tsx @@ -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(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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

퀀트 전략

+ +

퀀트 전략

-
- {strategies.map((strategy) => ( - +
+ {strategies.map((strategy) => ( + + +
{strategy.icon}
-

- {strategy.name} -

-

{strategy.description}

- - ))} -
-
+ {strategy.name} + + +

{strategy.description}

+
+ + + ))}
-
+ ); } diff --git a/frontend/src/app/strategy/quality/page.tsx b/frontend/src/app/strategy/quality/page.tsx index fed5e9e..c49ee33 100644 --- a/frontend/src/app/strategy/quality/page.tsx +++ b/frontend/src/app/strategy/quality/page.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [result, setResult] = useState(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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

슈퍼 퀄리티 전략

+ +

슈퍼 퀄리티 전략

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {/* Settings */} -
-

전략 설정

-
-
- - setMinFscore(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setTopN(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
+ {/* Settings */} + + + 전략 설정 + + +
+
+ + setMinFscore(parseInt(e.target.value))} + /> +
+
+ + setTopN(parseInt(e.target.value))} + />
-
+ +
+
- {/* Results */} - {result && ( -
-
-

- 결과 ({result.result_count}/{result.universe_count} 종목) -

-

기준일: {result.base_date}

-
-
- - - - - - - - - - - - - + {/* Results */} + {result && ( + + + + 결과 ({result.result_count}/{result.universe_count} 종목) + +

기준일: {result.base_date}

+
+ +
+
순위종목섹터시가총액(억)현재가PERPBR배당률F-Score퀄리티
+ + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + - - - {result.stocks.map((stock) => ( - - - - - - - - - - - - - ))} - -
순위종목섹터시가총액(억)현재가PERPBR배당률F-Score퀄리티
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.dividend_yield)}% + = 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 + + {formatNumber(stock.quality_score)}
{stock.rank} -
{stock.ticker}
-
{stock.name}
-
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.dividend_yield)}% - = 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 - - {formatNumber(stock.quality_score)}
-
+ ))} + +
- )} -
-
-
+ + + )} + ); } diff --git a/frontend/src/app/strategy/value-momentum/page.tsx b/frontend/src/app/strategy/value-momentum/page.tsx index 0569450..5fcf8c0 100644 --- a/frontend/src/app/strategy/value-momentum/page.tsx +++ b/frontend/src/app/strategy/value-momentum/page.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [result, setResult] = useState(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 ( -
-
Loading...
-
- ); + return null; } return ( -
- -
-
-
-

밸류 모멘텀 전략

+ +

밸류 모멘텀 전략

- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - {/* Settings */} -
-

전략 설정

-
-
- - setValueWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setMomentumWeight(parseFloat(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
-
- - setTopN(parseInt(e.target.value))} - className="w-full px-3 py-2 border rounded" - /> -
+ {/* Settings */} + + + 전략 설정 + + +
+
+ + setValueWeight(parseFloat(e.target.value))} + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + /> +
+
+ + setTopN(parseInt(e.target.value))} + />
-
+ +
+
- {/* Results */} - {result && ( -
-
-

- 결과 ({result.result_count}/{result.universe_count} 종목) -

-

기준일: {result.base_date}

-
-
- - - - - - - - - - - - - + {/* Results */} + {result && ( + + + + 결과 ({result.result_count}/{result.universe_count} 종목) + +

기준일: {result.base_date}

+
+ +
+
순위종목섹터시가총액(억)현재가PERPBR밸류모멘텀종합
+ + + + + + + + + + + + + + + + {result.stocks.map((stock) => ( + + + + + + + + + + + - - - {result.stocks.map((stock) => ( - - - - - - - - - - - - - ))} - -
순위종목섹터시가총액(억)현재가PERPBR밸류모멘텀종합
{stock.rank} +
{stock.ticker}
+
{stock.name}
+
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
{stock.rank} -
{stock.ticker}
-
{stock.name}
-
{stock.sector_name || '-'}{formatCurrency(stock.market_cap)}{formatCurrency(stock.close_price)}{formatNumber(stock.per)}{formatNumber(stock.pbr)}{formatNumber(stock.value_score)}{formatNumber(stock.momentum_score)}{formatNumber(stock.total_score)}
-
+ ))} + +
- )} -
-
-
+ + + )} + ); } diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..68551b9 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label }