snp-connection-monitoring/frontend/src/layouts/MainLayout.tsx
HYOJIN 2a8723419d feat(ui): KCG 브랜딩 + 레이아웃 디자인 + 메뉴 한글화 (#48)
- S&P/SNP → KCG 텍스트 변경 (타이틀, 사이드바, 대시보드)
- 사이드 메뉴 한글화 (모니터링, 통계, API 키, 관리자, 부서)
- MainLayout/ApiHubLayout 헤더/사이드바 레퍼런스 디자인 적용
- 서비스 상태 카드 서비스 코드 제거
- 대시보드 배너 브랜드 컬러 그라디언트 적용
- 다크/라이트 테마 전환 .light 클래스 대응

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:37:31 +09:00

385 lines
15 KiB
TypeScript

import { useState } from 'react';
import { Outlet, NavLink, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
interface NavItem {
label: string;
path: string;
icon: React.ReactNode;
adminManagerOnly?: boolean;
}
interface NavGroup {
label: string;
items: NavItem[];
adminOnly?: boolean;
}
const iconProps = {
fill: 'none' as const,
viewBox: '0 0 24 24',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
width: 18,
height: 18,
};
const IconDashboard = () => (
<svg {...iconProps}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
);
const IconRequestLog = () => (
<svg {...iconProps}>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
const IconServiceStatus = () => (
<svg {...iconProps}>
<polyline points="22,12 18,12 15,21 9,3 6,12 2,12" />
</svg>
);
const IconServiceStats = () => (
<svg {...iconProps}>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
);
const IconUserStats = () => (
<svg {...iconProps}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
</svg>
);
const IconApiStats = () => (
<svg {...iconProps}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
<path d="M21 3v6h-6" />
</svg>
);
const IconTenantStats = () => (
<svg {...iconProps}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
);
const IconUsageTrend = () => (
<svg {...iconProps}>
<polyline points="23,6 13.5,15.5 8.5,10.5 1,18" />
<polyline points="17,6 23,6 23,12" />
</svg>
);
const IconMyKey = () => (
<svg {...iconProps}>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
);
const IconKeyRequest = () => (
<svg {...iconProps}>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
);
const IconKeyManage = () => (
<svg {...iconProps}>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
);
const IconService = () => (
<svg {...iconProps}>
<polygon points="12,2 2,7 12,12 22,7" />
<polyline points="2,17 12,22 22,17" />
<polyline points="2,12 12,17 22,12" />
</svg>
);
const IconDomain = () => (
<svg {...iconProps}>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20" />
<path d="M2 12h20" />
</svg>
);
const IconApiManage = () => (
<svg {...iconProps}>
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
);
const IconSampleCode = () => (
<svg {...iconProps}>
<polyline points="16,18 22,12 16,6" />
<polyline points="8,6 2,12 8,18" />
</svg>
);
const navGroups: NavGroup[] = [
{
label: '모니터링',
items: [
{ label: '요청 로그', path: '/monitoring/request-logs', icon: <IconRequestLog /> },
{ label: '서비스 상태', path: '/monitoring/service-status', icon: <IconServiceStatus /> },
],
},
{
label: '통계',
items: [
{ label: '서비스 통계', path: '/statistics/services', icon: <IconServiceStats /> },
{ label: '사용자 통계', path: '/statistics/users', icon: <IconUserStats /> },
{ label: 'API 통계', path: '/statistics/apis', icon: <IconApiStats /> },
{ label: '부서 통계', path: '/statistics/tenants', icon: <IconTenantStats /> },
{ label: '사용량 추이', path: '/statistics/usage-trend', icon: <IconUsageTrend /> },
],
},
{
label: 'API 키',
items: [
{ label: '내 키', path: '/apikeys/my-keys', icon: <IconMyKey /> },
{ label: '키 신청', path: '/apikeys/request', icon: <IconKeyRequest /> },
{ label: '키 관리', path: '/apikeys/admin', icon: <IconKeyManage />, adminManagerOnly: true },
],
},
{
label: '관리자',
adminOnly: true,
items: [
{ label: '서비스', path: '/admin/services', icon: <IconService /> },
{ label: '도메인', path: '/admin/domains', icon: <IconDomain /> },
{ label: 'API 관리', path: '/admin/apis', icon: <IconApiManage /> },
{ label: '공통 샘플 코드', path: '/admin/sample-code', icon: <IconSampleCode /> },
{ label: '사용자', path: '/admin/users', icon: <IconUserStats /> },
{ label: '부서', path: '/admin/tenants', icon: <IconTenantStats /> },
],
},
];
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const MainLayout = () => {
const { user, setRole } = useAuth();
const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
'모니터링': true,
'통계': true,
'API 키': true,
'관리자': true,
});
const toggleGroup = (label: string) => {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
};
const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER';
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-60 bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] flex flex-col z-40 border-r border-[var(--color-border)]">
{/* 로고 영역 */}
<div className="flex items-center gap-3 px-4 h-14 border-b border-[var(--color-border)] flex-shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--color-primary-600)] flex-shrink-0">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={16} height={16} className="text-white">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
</svg>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)] leading-tight truncate">KCG Connection</div>
<div className="text-[10px] text-[var(--color-text-tertiary)] leading-tight truncate">Monitoring Service</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
{/* API Hub 바로가기 카드 */}
<Link
to="/api-hub"
className="flex items-center gap-3 mx-1 mb-3 px-3 py-2.5 rounded-lg border border-[var(--color-primary-active)] bg-[var(--color-primary-subtle)] hover:bg-[var(--color-primary-subtle)] transition-colors group"
>
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--color-primary-subtle)] flex-shrink-0">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={15} height={15} className="text-[var(--color-primary)]">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M9 9h6M9 13h4" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-[var(--color-primary)] leading-tight">API Hub</div>
<div className="text-[10px] text-[var(--color-text-tertiary)] leading-tight truncate">API </div>
</div>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" width={14} height={14} className="text-[var(--color-text-tertiary)] flex-shrink-0 group-hover:text-[var(--color-primary)] transition-colors">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{/* 대시보드 */}
<NavLink
to="/dashboard"
className={({ isActive }) =>
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? 'bg-[var(--color-primary)] text-white dark:bg-[var(--color-primary-subtle)] dark:text-[var(--color-primary-text)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-primary-subtle)] hover:text-[var(--color-primary-text)]'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-[var(--color-primary)] rounded-r-full dark:hidden" />
)}
<span className="flex-shrink-0"><IconDashboard /></span>
<span className="font-medium"></span>
</>
)}
</NavLink>
{/* Nav Groups */}
{navGroups.map((group) => {
if (group.adminOnly && user?.role !== 'ADMIN') return null;
const isOpen = openGroups[group.label] ?? false;
return (
<div key={group.label}>
{/* 구분선 */}
<div className="my-2 mx-1 border-t border-[var(--color-border)]" />
{/* 섹션 타이틀 */}
<button
onClick={() => toggleGroup(group.label)}
className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<span>{group.label}</span>
<svg
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
width={12}
height={12}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="mt-0.5 space-y-0.5">
{group.items.map((item) => {
if (item.adminManagerOnly && !isAdminOrManager) return null;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? 'bg-[var(--color-primary)] text-white dark:bg-[var(--color-primary-subtle)] dark:text-[var(--color-primary-text)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-primary-subtle)] hover:text-[var(--color-primary-text)]'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-[var(--color-primary)] rounded-r-full dark:hidden" />
)}
<span className="flex-shrink-0">{item.icon}</span>
<span>{item.label}</span>
</>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
})}
{/* 마지막 구분선 */}
<div className="my-2 mx-1 border-t border-[var(--color-border)]" />
</nav>
</aside>
{/* Main Content */}
<div className="flex-1 ml-60">
{/* Header */}
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border)] flex items-center justify-between px-6 sticky top-0 z-30">
<div />
<div className="flex items-center gap-3">
{/* 테마 토글 */}
<button
onClick={toggleTheme}
className="flex items-center justify-center w-8 h-8 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] transition-colors"
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
>
{theme === 'light' ? (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" width={18} height={18}>
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</button>
{/* 역할 스위처 */}
<div className="flex items-center bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-full p-0.5 gap-0.5">
{ROLES.map((role) => (
<button
key={role}
onClick={() => setRole(role)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
user?.role === role
? 'bg-[var(--color-primary)] text-white shadow-sm dark:bg-[var(--color-primary-600)] dark:hover:bg-[var(--color-primary-500)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
{role}
</button>
))}
</div>
</div>
</header>
{/* Content */}
<main className="p-6 bg-[var(--color-bg-base)] min-h-[calc(100vh-3.5rem)]">
<Outlet />
</main>
</div>
</div>
);
};
export default MainLayout;