feat(frontend): final polish and cleanup
- Toast notifications with sonner - Loading skeleton components - Improved loading states - 404 page - Cleanup old components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b741e1cfd
commit
4498ff9df1
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@ -24,6 +24,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -7292,6 +7293,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
@ -116,7 +116,7 @@ export default function BacktestPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [backtests, setBacktests] = useState<BacktestListItem[]>([]);
|
||||
const [currentResult, setCurrentResult] = useState<BacktestResult | null>(null);
|
||||
const [currentResult] = useState<BacktestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@ -25,6 +26,7 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
41
frontend/src/app/not-found.tsx
Normal file
41
frontend/src/app/not-found.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { FileQuestion, Home, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<FileQuestion className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-4xl font-bold">404</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
페이지를 찾을 수 없습니다
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
대시보드로 이동
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="javascript:history.back()">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
뒤로 가기
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
@ -96,6 +96,27 @@ export default function PortfolioDetailPage() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPortfolio = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await api.get<PortfolioDetail>(`/api/portfolios/${portfolioId}/detail`);
|
||||
setPortfolio(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch portfolio';
|
||||
setError(message);
|
||||
}
|
||||
}, [portfolioId]);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
|
||||
setTransactions(data);
|
||||
} catch {
|
||||
// Transactions may not exist yet
|
||||
setTransactions([]);
|
||||
}
|
||||
}, [portfolioId]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
@ -108,28 +129,7 @@ export default function PortfolioDetailPage() {
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [router, portfolioId]);
|
||||
|
||||
const fetchPortfolio = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await api.get<PortfolioDetail>(`/api/portfolios/${portfolioId}/detail`);
|
||||
setPortfolio(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch portfolio';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
const data = await api.get<Transaction[]>(`/api/portfolios/${portfolioId}/transactions`);
|
||||
setTransactions(data);
|
||||
} catch {
|
||||
// Transactions may not exist yet
|
||||
setTransactions([]);
|
||||
}
|
||||
};
|
||||
}, [router, fetchPortfolio, fetchTransactions]);
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return '-';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -45,6 +45,17 @@ export default function RebalancePage() {
|
||||
const [additionalAmount, setAdditionalAmount] = useState('');
|
||||
const [simulating, setSimulating] = useState(false);
|
||||
|
||||
const fetchRebalance = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await api.get<RebalanceResponse>(`/api/portfolios/${portfolioId}/rebalance`);
|
||||
setRebalance(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to calculate rebalance';
|
||||
setError(message);
|
||||
}
|
||||
}, [portfolioId]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
@ -57,18 +68,7 @@ export default function RebalancePage() {
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [router, portfolioId]);
|
||||
|
||||
const fetchRebalance = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await api.get<RebalanceResponse>(`/api/portfolios/${portfolioId}/rebalance`);
|
||||
setRebalance(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to calculate rebalance';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
}, [router, fetchRebalance]);
|
||||
|
||||
const simulate = async () => {
|
||||
if (!additionalAmount) return;
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface HeaderProps {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function Header({ username }: HeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
api.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
환영합니다{username ? `, ${username}` : ''}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const menuItems = [
|
||||
{ href: '/', label: '대시보드', icon: '📊' },
|
||||
{ href: '/portfolio', label: '포트폴리오', icon: '💼' },
|
||||
{ href: '/strategy', label: '퀀트 전략', icon: '📈' },
|
||||
{ href: '/backtest', label: '백테스트', icon: '🔬' },
|
||||
{ href: '/market', label: '시세 조회', icon: '💹' },
|
||||
{ href: '/admin/data', label: '데이터 관리', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-gray-900 text-white min-h-screen p-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-bold">Galaxy-PO</h1>
|
||||
<p className="text-gray-400 text-sm">Quant Portfolio Manager</p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<ul className="space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import {
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { DashboardSkeleton } from '@/components/ui/loading-skeleton';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
@ -42,14 +43,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
174
frontend/src/components/ui/loading-skeleton.tsx
Normal file
174
frontend/src/components/ui/loading-skeleton.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* Card skeleton for loading states
|
||||
*/
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats card skeleton with icon placeholder
|
||||
*/
|
||||
export function StatsCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-7 w-20 mb-1" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table skeleton for loading states
|
||||
*/
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Table header */}
|
||||
<div className="flex gap-4 px-4 py-3 border-b">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</div>
|
||||
{/* Table rows */}
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 px-4 py-3">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-defined heights for chart bars to avoid impure render
|
||||
const CHART_BAR_HEIGHTS = [45, 72, 38, 85, 62, 50, 78, 42, 68, 55, 80, 35];
|
||||
|
||||
/**
|
||||
* Chart skeleton for loading states
|
||||
*/
|
||||
export function ChartSkeleton({ height = 300 }: { height?: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="w-full flex items-end justify-between gap-2"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Simulated bar chart bars */}
|
||||
{CHART_BAR_HEIGHTS.map((barHeight, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1"
|
||||
style={{
|
||||
height: `${barHeight}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar skeleton for dashboard loading
|
||||
*/
|
||||
export function SidebarSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full w-64 flex-col border-r bg-background">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
{/* Navigation items */}
|
||||
<div className="flex-1 space-y-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* User area */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content area skeleton for dashboard loading
|
||||
*/
|
||||
export function ContentSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 space-y-6 p-6">
|
||||
{/* Page header */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
{/* Stats cards row */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<StatsCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart area */}
|
||||
<ChartSkeleton />
|
||||
{/* Table area */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TableSkeleton rows={5} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full page skeleton combining sidebar and content
|
||||
*/
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<SidebarSkeleton />
|
||||
<ContentSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/ui/sonner.tsx
Normal file
45
frontend/src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
OctagonX,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheck className="h-4 w-4" />,
|
||||
info: <Info className="h-4 w-4" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" />,
|
||||
error: <OctagonX className="h-4 w-4" />,
|
||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
Loading…
x
Reference in New Issue
Block a user