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:
zephyrdark 2026-02-05 23:11:41 +09:00
parent 3b741e1cfd
commit 4498ff9df1
12 changed files with 314 additions and 134 deletions

View File

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

View File

@ -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": {

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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