From 4498ff9df194c6c3a06e2dbde4f25a21c51c71e5 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Thu, 5 Feb 2026 23:11:41 +0900 Subject: [PATCH] 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 --- frontend/package-lock.json | 11 ++ frontend/package.json | 1 + frontend/src/app/backtest/page.tsx | 4 +- frontend/src/app/layout.tsx | 2 + frontend/src/app/not-found.tsx | 41 +++++ frontend/src/app/portfolio/[id]/page.tsx | 46 ++--- .../src/app/portfolio/[id]/rebalance/page.tsx | 26 +-- frontend/src/components/layout/Header.tsx | 37 ---- frontend/src/components/layout/Sidebar.tsx | 51 ----- .../components/layout/dashboard-layout.tsx | 10 +- .../src/components/ui/loading-skeleton.tsx | 174 ++++++++++++++++++ frontend/src/components/ui/sonner.tsx | 45 +++++ 12 files changed, 314 insertions(+), 134 deletions(-) create mode 100644 frontend/src/app/not-found.tsx delete mode 100644 frontend/src/components/layout/Header.tsx delete mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/ui/loading-skeleton.tsx create mode 100644 frontend/src/components/ui/sonner.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34a5280..28f434d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8146539..4897f07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx index a2af0f6..716f9c8 100644 --- a/frontend/src/app/backtest/page.tsx +++ b/frontend/src/app/backtest/page.tsx @@ -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([]); - const [currentResult, setCurrentResult] = useState(null); + const [currentResult] = useState(null); const [error, setError] = useState(null); const [showHistory, setShowHistory] = useState(false); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f0f6a36..e792ce2 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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} + diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..21cdd2b --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -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 ( +
+ + +
+ +
+ 404 + + 페이지를 찾을 수 없습니다 + +
+ +

+ 요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다. +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index 30353bf..c5140f6 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -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([]); const [error, setError] = useState(null); + const fetchPortfolio = useCallback(async () => { + try { + setError(null); + const data = await api.get(`/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(`/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(`/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(`/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 '-'; diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx index 5649c08..0039280 100644 --- a/frontend/src/app/portfolio/[id]/rebalance/page.tsx +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -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(`/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(`/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; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx deleted file mode 100644 index ac16ccf..0000000 --- a/frontend/src/components/layout/Header.tsx +++ /dev/null @@ -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 ( -
-
-
-

- 환영합니다{username ? `, ${username}` : ''} -

-
-
- -
-
-
- ); -} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx deleted file mode 100644 index c2b59eb..0000000 --- a/frontend/src/components/layout/Sidebar.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/frontend/src/components/layout/dashboard-layout.tsx b/frontend/src/components/layout/dashboard-layout.tsx index b50ef1a..b2d83f8 100644 --- a/frontend/src/components/layout/dashboard-layout.tsx +++ b/frontend/src/components/layout/dashboard-layout.tsx @@ -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 ( -
-
-
-

로딩 중...

-
-
- ); + return ; } return ( diff --git a/frontend/src/components/ui/loading-skeleton.tsx b/frontend/src/components/ui/loading-skeleton.tsx new file mode 100644 index 0000000..b69ecf2 --- /dev/null +++ b/frontend/src/components/ui/loading-skeleton.tsx @@ -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 ( + + + + + + + + + + ); +} + +/** + * Stats card skeleton with icon placeholder + */ +export function StatsCardSkeleton() { + return ( + + + + + + + + + + + ); +} + +/** + * Table skeleton for loading states + */ +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {/* Table header */} +
+ + + + +
+ {/* Table rows */} + {Array.from({ length: rows }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} + +// 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 ( + + + + + + +
+ {/* Simulated bar chart bars */} + {CHART_BAR_HEIGHTS.map((barHeight, i) => ( + + ))} +
+
+
+ ); +} + +/** + * Sidebar skeleton for dashboard loading + */ +export function SidebarSkeleton() { + return ( +
+ {/* Logo area */} +
+ +
+ {/* Navigation items */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+ {/* User area */} +
+
+ +
+ + +
+
+
+
+ ); +} + +/** + * Content area skeleton for dashboard loading + */ +export function ContentSkeleton() { + return ( +
+ {/* Page header */} +
+ + +
+ {/* Stats cards row */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ {/* Chart area */} + + {/* Table area */} + + + + + + + + +
+ ); +} + +/** + * Full page skeleton combining sidebar and content + */ +export function DashboardSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..6e6fd25 --- /dev/null +++ b/frontend/src/components/ui/sonner.tsx @@ -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 + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + , + info: , + warning: , + error: , + loading: , + }} + 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 }