generated from gc/template-java-maven
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info) - PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 - SERVICE_BADGE_VARIANTS 공통 상수 추출 - 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 - 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs) - 타이틀 아이콘 전체 페이지 통일 - 카드 테두리 디자인 통일 (border + rounded-xl) - FHD 1920x1080 최적화
385 lines
15 KiB
TypeScript
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 h-screen overflow-hidden">
|
|
{/* 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 flex flex-col h-screen overflow-hidden">
|
|
{/* 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="flex-1 p-6 bg-[var(--color-bg-base)] overflow-y-auto">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MainLayout;
|