feat: 프론트엔드 UI 개편 - 메인 화면 및 섹션별 네비게이션 (#115) #116

병합
HYOJIN feature/ISSUE-115-frontend-ui-redesign 에서 develop 로 2 commits 를 머지했습니다 2026-03-31 11:04:46 +09:00
7개의 변경된 파일257개의 추가작업 그리고 58개의 파일을 삭제

파일 보기

@ -5,6 +5,10 @@
## [Unreleased]
### 추가
- 프론트엔드 UI 개편 (#115)
- 메인 화면 3개 섹션 카드 (Collector/Bypass/Risk&Compliance)
- 섹션별 Navbar 분리
- 플랫폼명 S&P Data Platform 변경
- Risk&Compliance 값 변경 이력 확인 페이지 개발 (#111)
- 선박 위험지표/선박 제재/회사 제재 변경 이력 조회
- 선박/회사 기본정보 및 현재 Risk&Compliance 상태 조회

파일 보기

@ -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&amp;P 배치 관리</title>
<title>S&amp;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>
);
}

파일 보기

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