feat: 프론트엔드 UI 개편 - 메인 화면 및 섹션별 네비게이션 (#115) #116
@ -8,7 +8,7 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/snp-api/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/snp-api/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>S&P 배치 관리</title>
|
||||
<title>S&P Data Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import ToastContainer from './components/Toast';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
|
||||
const MainMenu = lazy(() => import('./pages/MainMenu'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||
const Executions = lazy(() => import('./pages/Executions'));
|
||||
@ -20,14 +21,17 @@ const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory')
|
||||
|
||||
function AppLayout() {
|
||||
const { toasts, removeToast } = useToastContext();
|
||||
const location = useLocation();
|
||||
const isMainMenu = location.pathname === '/';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-wing-bg text-wing-text">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className={isMainMenu ? 'px-4' : 'max-w-7xl mx-auto px-4 py-6'}>
|
||||
<Navbar />
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/" element={<MainMenu />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/executions" element={<Executions />} />
|
||||
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||
|
||||
@ -1,58 +1,107 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '대시보드', icon: '📊' },
|
||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
|
||||
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
|
||||
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
|
||||
interface NavSection {
|
||||
key: string;
|
||||
title: string;
|
||||
paths: string[];
|
||||
items: { path: string; label: string; icon: string }[];
|
||||
}
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
key: 'collector',
|
||||
title: 'S&P Collector',
|
||||
paths: ['/dashboard', '/jobs', '/executions', '/recollects', '/schedules', '/schedule-timeline'],
|
||||
items: [
|
||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bypass',
|
||||
title: 'S&P Bypass',
|
||||
paths: ['/bypass-config'],
|
||||
items: [
|
||||
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
title: 'S&P Risk & Compliance',
|
||||
paths: ['/screening-guide', '/risk-compliance-history'],
|
||||
items: [
|
||||
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
|
||||
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const location = useLocation();
|
||||
const { theme, toggle } = useThemeContext();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
|
||||
S&P 배치 관리
|
||||
</Link>
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
||||
${isActive(item.path)
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||
hover:text-wing-text border border-wing-border transition-colors"
|
||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
function getCurrentSection(pathname: string): NavSection | null {
|
||||
for (const section of sections) {
|
||||
if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const location = useLocation();
|
||||
const { theme, toggle } = useThemeContext();
|
||||
const currentSection = getCurrentSection(location.pathname);
|
||||
|
||||
// 메인 화면에서는 Navbar 숨김
|
||||
if (!currentSection) return null;
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard') return location.pathname === '/dashboard';
|
||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium no-underline text-wing-muted hover:bg-wing-hover hover:text-wing-accent transition-colors"
|
||||
title="메인 메뉴"
|
||||
>
|
||||
← 메인
|
||||
</Link>
|
||||
<span className="text-wing-border">|</span>
|
||||
<span className="text-sm font-bold text-wing-accent">{currentSection.title}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{currentSection.items.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
||||
${isActive(item.path)
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||
hover:text-wing-text border border-wing-border transition-colors"
|
||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
69
frontend/src/pages/MainMenu.tsx
Normal file
69
frontend/src/pages/MainMenu.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'S&P Collector',
|
||||
description: 'S&P 배치 수집 관리',
|
||||
detail: '대시보드, 실행 이력, 재수집 이력, 작업 관리, 스케줄, 타임라인',
|
||||
path: '/dashboard',
|
||||
icon: '🔄',
|
||||
iconClass: 'gc-card-icon',
|
||||
menuCount: 6,
|
||||
},
|
||||
{
|
||||
title: 'S&P Bypass',
|
||||
description: 'S&P Bypass API 관리',
|
||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
||||
path: '/bypass-config',
|
||||
icon: '🔗',
|
||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||
menuCount: 1,
|
||||
},
|
||||
{
|
||||
title: 'S&P Risk & Compliance',
|
||||
description: 'S&P 위험 지표 및 규정 준수',
|
||||
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
|
||||
path: '/screening-guide',
|
||||
icon: '⚖️',
|
||||
iconClass: 'gc-card-icon gc-card-icon-nexus',
|
||||
menuCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export default function MainMenu() {
|
||||
const { theme, toggle } = useThemeContext();
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-bold text-wing-text mb-2">S&P Data Platform</h1>
|
||||
<p className="text-sm text-wing-muted">해양 데이터 통합 관리 플랫폼</p>
|
||||
</div>
|
||||
|
||||
{/* 섹션 카드 */}
|
||||
<div className="gc-cards">
|
||||
{sections.map((section) => (
|
||||
<Link key={section.path} to={section.path} className="gc-card">
|
||||
<div className={section.iconClass}>
|
||||
<span className="text-5xl">{section.icon}</span>
|
||||
</div>
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.description}<br />{section.detail}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테마 토글 */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="mt-8 px-3 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||
hover:text-wing-text border border-wing-border transition-colors"
|
||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||
>
|
||||
{theme === 'dark' ? '☀️ 라이트 모드' : '🌙 다크 모드'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -23,3 +23,76 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--wing-accent);
|
||||
}
|
||||
|
||||
/* Main Menu Cards */
|
||||
.gc-cards {
|
||||
padding: 2rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 2rem;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.gc-cards > * {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.gc-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2.5rem 2rem;
|
||||
border: 1px solid var(--wing-border);
|
||||
border-radius: 12px;
|
||||
background: var(--wing-surface);
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
transition: all 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gc-card:hover {
|
||||
border-color: #4183c4;
|
||||
box-shadow: 0 4px 16px rgba(65, 131, 196, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.gc-card-icon {
|
||||
color: #4183c4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gc-card-icon-guide {
|
||||
color: #21ba45;
|
||||
}
|
||||
|
||||
.gc-card-icon-nexus {
|
||||
color: #f2711c;
|
||||
}
|
||||
|
||||
.gc-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--wing-text);
|
||||
}
|
||||
|
||||
.gc-card p {
|
||||
font-size: 0.95rem;
|
||||
color: var(--wing-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gc-card-link {
|
||||
font-size: 0.9rem;
|
||||
color: #4183c4;
|
||||
font-weight: 600;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.gc-card:hover .gc-card-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@ -12,11 +12,11 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
@Controller
|
||||
public class WebViewController {
|
||||
|
||||
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||
"/recollects", "/recollects/{id:\\d+}",
|
||||
"/schedules", "/schedule-timeline", "/monitoring",
|
||||
"/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||
"/jobs/**", "/executions/**", "/recollects/**",
|
||||
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
|
||||
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
||||
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
||||
public String forward() {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user