feat: 공통 UI 피드백 반영 - 2단 탭 네비게이션 및 카드 높이 통일 (#121)
- 2단 탭 레이아웃 (섹션 탭 slate-900 + 서브 탭 언더라인) - 섹션 탭에서 다른 섹션으로 직접 이동 가능 - 메인화면 카드 CSS Grid 전환 (높이 자동 동기화) - h-screen 고정 + 탭/콘텐츠 영역 분리 - 중앙 정렬 (메인 섹션 + 서브 섹션) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
c104eb7457
커밋
388d99d05f
@ -25,12 +25,24 @@ function AppLayout() {
|
|||||||
const isMainMenu = location.pathname === '/';
|
const isMainMenu = location.pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-wing-bg text-wing-text">
|
<div className="h-screen bg-wing-bg text-wing-text flex flex-col overflow-hidden">
|
||||||
<div className={isMainMenu ? 'px-4' : 'max-w-7xl mx-auto px-4 py-6'}>
|
{/* 메인 화면: 전체화면, 섹션 페이지: 탭 + 스크롤 콘텐츠 */}
|
||||||
<Navbar />
|
{isMainMenu ? (
|
||||||
|
<div className="flex-1 overflow-auto px-4">
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainMenu />} />
|
<Route path="/" element={<MainMenu />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-shrink-0 px-4 pt-4 max-w-7xl mx-auto w-full">
|
||||||
|
<Navbar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-4 pt-6 max-w-7xl mx-auto w-full">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/executions" element={<Executions />} />
|
<Route path="/executions" element={<Executions />} />
|
||||||
@ -45,6 +57,8 @@ function AppLayout() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,49 +1,75 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useThemeContext } from '../contexts/ThemeContext';
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
interface NavSection {
|
interface MenuItem {
|
||||||
key: string;
|
id: string;
|
||||||
title: string;
|
label: string;
|
||||||
paths: string[];
|
path: string;
|
||||||
items: { path: string; label: string; icon: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections: NavSection[] = [
|
interface MenuSection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
defaultPath: string;
|
||||||
|
children: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_STRUCTURE: MenuSection[] = [
|
||||||
{
|
{
|
||||||
key: 'collector',
|
id: 'collector',
|
||||||
title: 'S&P Collector',
|
label: 'S&P Collector',
|
||||||
paths: ['/dashboard', '/jobs', '/executions', '/recollects', '/schedules', '/schedule-timeline'],
|
shortLabel: 'Collector',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
</svg>
|
||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
),
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
defaultPath: '/dashboard',
|
||||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
children: [
|
||||||
|
{ id: 'dashboard', label: '대시보드', path: '/dashboard' },
|
||||||
|
{ id: 'executions', label: '실행 이력', path: '/executions' },
|
||||||
|
{ id: 'recollects', label: '재수집 이력', path: '/recollects' },
|
||||||
|
{ id: 'jobs', label: '작업 관리', path: '/jobs' },
|
||||||
|
{ id: 'schedules', label: '스케줄', path: '/schedules' },
|
||||||
|
{ id: 'timeline', label: '타임라인', path: '/schedule-timeline' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bypass',
|
id: 'bypass',
|
||||||
title: 'S&P Bypass',
|
label: 'S&P Bypass',
|
||||||
paths: ['/bypass-config'],
|
shortLabel: 'Bypass',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
defaultPath: '/bypass-config',
|
||||||
|
children: [
|
||||||
|
{ id: 'bypass-config', label: 'Bypass API', path: '/bypass-config' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'risk',
|
id: 'risk',
|
||||||
title: 'S&P Risk & Compliance',
|
label: 'S&P Risk & Compliance',
|
||||||
paths: ['/screening-guide', '/risk-compliance-history'],
|
shortLabel: 'Risk & Compliance',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
defaultPath: '/screening-guide',
|
||||||
|
children: [
|
||||||
|
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
|
||||||
|
{ id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getCurrentSection(pathname: string): NavSection | null {
|
function getCurrentSection(pathname: string): MenuSection | null {
|
||||||
for (const section of sections) {
|
for (const section of MENU_STRUCTURE) {
|
||||||
if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
|
if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) {
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,56 +78,75 @@ function getCurrentSection(pathname: string): NavSection | null {
|
|||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { theme, toggle } = useThemeContext();
|
const { theme, toggle } = useThemeContext();
|
||||||
const currentSection = getCurrentSection(location.pathname);
|
const currentSection = getCurrentSection(location.pathname);
|
||||||
|
|
||||||
// 메인 화면에서는 Navbar 숨김
|
// 메인 화면에서는 숨김
|
||||||
if (!currentSection) return null;
|
if (!currentSection) return null;
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActivePath = (path: string) => {
|
||||||
if (path === '/dashboard') return location.pathname === '/dashboard';
|
|
||||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="mb-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
{/* 1단: 섹션 탭 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-slate-900 px-6 pt-3 rounded-t-xl flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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"
|
className="flex items-center gap-1 px-3 py-2.5 text-sm text-slate-400 hover:text-white no-underline transition-colors"
|
||||||
title="메인 메뉴"
|
title="메인 메뉴"
|
||||||
>
|
>
|
||||||
← 메인
|
←
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-wing-border">|</span>
|
<div className="flex-1 flex items-center justify-center gap-1">
|
||||||
<span className="text-sm font-bold text-wing-accent">{currentSection.title}</span>
|
{MENU_STRUCTURE.map((section) => (
|
||||||
</div>
|
<button
|
||||||
<div className="flex gap-1 flex-wrap items-center">
|
key={section.id}
|
||||||
{currentSection.items.map((item) => (
|
onClick={() => {
|
||||||
<Link
|
if (currentSection?.id !== section.id) {
|
||||||
key={item.path}
|
navigate(section.defaultPath);
|
||||||
to={item.path}
|
}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
}}
|
||||||
${isActive(item.path)
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-t-lg text-sm font-medium transition-all ${
|
||||||
? 'bg-wing-accent text-white'
|
currentSection?.id === section.id
|
||||||
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
? 'bg-wing-bg text-wing-text shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-1">{item.icon}</span>
|
{section.icon}
|
||||||
{item.label}
|
<span>{section.shortLabel}</span>
|
||||||
</Link>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
className="px-2.5 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
hover:text-wing-text border border-wing-border transition-colors"
|
|
||||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2단: 서브 탭 */}
|
||||||
|
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md">
|
||||||
|
<div className="flex gap-1 -mb-px justify-center">
|
||||||
|
{currentSection?.children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
onClick={() => navigate(child.path)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||||
|
isActivePath(child.path)
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,16 +27,19 @@ body {
|
|||||||
/* Main Menu Cards */
|
/* Main Menu Cards */
|
||||||
.gc-cards {
|
.gc-cards {
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
align-items: stretch;
|
grid-auto-rows: 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-cards > * {
|
@media (max-width: 768px) {
|
||||||
flex: 1 1 0;
|
.gc-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-card {
|
.gc-card {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user