kcg-ai-monitoring/frontend/src/app/layout/MainLayout.tsx
htlee 6fe7a7daf4 feat: 메뉴 DB SSOT 구조화 — auth_perm_tree 기반 메뉴·권한·i18n 통합
## 핵심 변경
- auth_perm_tree를 메뉴 SSOT로 확장 (V020~V024)
  - url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort 컬럼
  - labels JSONB (다국어: {"ko":"...", "en":"..."})
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection, risk-assessment 등)
  - 권한 트리 = 메뉴 트리 완전 동기화
  - 그룹 레벨 권한 → 개별 자식 권한으로 확장 후 그룹 삭제
- 패널 노드 parent_cd를 실제 소속 페이지로 수정
  (어구식별→어구탐지, 전역제외→후보제외, 역할관리→권한관리)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)

## 백엔드
- MenuConfigService: auth_perm_tree에서 menuConfig DTO 생성
- /api/auth/me 응답에 menuConfig 포함 (로그인 시 프리로드)
- @RequirePermission 12곳 수정 (삭제된 그룹명 → 구체적 자식 리소스)
- Caffeine 캐시 menuConfig 추가

## 프론트엔드
- NAV_ENTRIES 하드코딩 제거 → menuStore(Zustand) 동적 렌더링
- PATH_TO_RESOURCE 하드코딩 제거 → DB 기반 longest-match
- App.tsx 36개 정적 import/33개 Route → DynamicRoutes + componentRegistry
- PermissionsPanel: DB labels JSONB 기반 표시명 + 페이지/패널 아이콘 구분
- DB migration README.md 전면 재작성 (V001~V024, 49테이블, 149인덱스)

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

490 lines
22 KiB
TypeScript

import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Clock, Lock,
Download, FileSpreadsheet, Printer,
} from 'lucide-react';
import { useAuth } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore';
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
import { resolveIcon } from '@/app/iconRegistry';
/*
* SFR-01 반영 사항:
* - RBAC 기반 메뉴 접근 제어 (역할별 허용 메뉴만 표시)
* - 세션 타임아웃 잔여 시간 표시
* - 로그인 사용자 정보·역할·인증방식 표시
* - 로그아웃 시 감사 로그 기록
*
* SFR-02 공통기능:
* - 모든 페이지 오른쪽 상단: 페이지 검색, 파일다운로드, 엑셀 내보내기, 인쇄
* - 모든 페이지 하단: 페이지네이션
*/
const AUTH_METHOD_LABELS: Record<string, string> = {
password: 'ID/PW',
gpki: 'GPKI',
sso: 'SSO',
};
function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function MainLayout() {
const { t } = useTranslation('common');
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, hasAccess, sessionRemaining } = useAuth();
const contentRef = useRef<HTMLDivElement>(null);
const { getTopLevelEntries, getChildren } = useMenuStore();
// getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
const getPageLabel = (pathname: string): string => {
const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
return item ? getMenuLabel(item, language) : '';
};
// 공통 검색
const [pageSearch, setPageSearch] = useState('');
// 인쇄
const handlePrint = () => {
const el = contentRef.current;
if (!el) { window.print(); return; }
const win = window.open('', '_blank');
if (!win) return;
win.document.write(`<html><head><title>${getPageLabel(location.pathname)} - ${t('layout.print')}</title>
<style>body{font-family:'Pretendard',sans-serif;color:#1e293b;padding:20px}
table{width:100%;border-collapse:collapse;font-size:11px}
th,td{border:1px solid #cbd5e1;padding:6px 8px;text-align:left}
th{background:#f1f5f9;font-weight:600}@media print{body{padding:0}}</style>
</head><body>${el.innerHTML}</body></html>`);
win.document.close();
win.print();
};
// 엑셀(CSV) 내보내기 — 현재 화면 테이블 자동 추출
const handleExcelExport = () => {
const el = contentRef.current;
if (!el) return;
const tables = el.querySelectorAll('table');
if (tables.length === 0) { alert(t('layout.noExportTable')); return; }
const table = tables[0];
const rows: string[] = [];
table.querySelectorAll('tr').forEach((tr) => {
const cells: string[] = [];
tr.querySelectorAll('th, td').forEach((td) => {
cells.push(`"${(td.textContent || '').replace(/"/g, '""').trim()}"`);
});
rows.push(cells.join(','));
});
const csv = '\uFEFF' + rows.join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${getPageLabel(location.pathname) || 'export'}_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// 파일 다운로드 (현재 페이지 HTML)
const handleDownload = () => {
const el = contentRef.current;
if (!el) return;
const html = `<html><head><meta charset="utf-8"><title>${getPageLabel(location.pathname)}</title></head><body>${el.innerHTML}</body></html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${getPageLabel(location.pathname) || 'page'}_${new Date().toISOString().slice(0, 10)}.html`;
a.click();
URL.revokeObjectURL(url);
};
// 그룹 메뉴 접기/펼치기 (다중 그룹 지원)
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
const toggleGroup = (name: string) => setOpenGroups(prev => {
const next = new Set(prev);
next.has(name) ? next.delete(name) : next.add(name);
return next;
});
// RBAC
const roleColor = user ? getRoleColorHex(user.role) : null;
const isSessionWarning = sessionRemaining <= 5 * 60;
// SFR-02: 공통알림 데이터
const systemNotices: SystemNotice[] = [
{
id: 'N-001', type: 'urgent', display: 'banner', title: '서해 NLL 인근 경보 강화',
message: '2026-04-03부터 서해 NLL 인근 해역에 대한 경계 경보가 강화되었습니다.',
startDate: '2026-04-03', endDate: '2026-04-10', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: true, pinned: true,
},
{
id: 'N-002', type: 'maintenance', display: 'popup', title: '정기 시스템 점검 안내',
message: '2026-04-05(토) 02:00~06:00 시스템 정기점검이 예정되어 있습니다. 점검 중 서비스 이용이 제한될 수 있습니다.',
startDate: '2026-04-03', endDate: '2026-04-05', targetRoles: [], dismissible: true, pinned: false,
},
{
id: 'N-003', type: 'info', display: 'banner', title: 'AI 탐지 모델 v2.3 업데이트',
message: '다크베셀 탐지 정확도 89%→93% 개선. 환적 탐지 알고리즘 업데이트.',
startDate: '2026-04-01', endDate: '2026-04-15', targetRoles: ['ADMIN', 'ANALYST'], dismissible: true, pinned: false,
},
];
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="flex h-screen bg-background text-heading overflow-hidden">
{/* 사이드바 */}
<aside
className={`flex flex-col border-r border-border bg-background transition-all duration-300 ${
collapsed ? 'w-16' : 'w-56'
}`}
>
{/* 로고 */}
<div className="flex items-center gap-2.5 px-4 h-14 border-b border-border shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600/20 border border-blue-500/30 shrink-0">
<Shield className="w-4 h-4 text-blue-400" />
</div>
{!collapsed && (
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate">{t('layout.brandTitle')}</div>
<div className="text-[8px] text-hint truncate">{t('layout.brandSub')}</div>
</div>
)}
</div>
{/* RBAC 역할 표시 */}
{!collapsed && user && roleColor && (
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-1.5">
<Lock className="w-3 h-3 text-hint" />
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
</div>
<div className="text-[8px] text-hint mt-0.5">
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
</div>
</div>
)}
{/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{getTopLevelEntries().map((entry) => {
if (entry.menuType === 'GROUP') {
return (
<GroupMenu
key={entry.menuCd}
group={entry}
children={getChildren(entry.menuCd)}
collapsed={collapsed}
hasAccess={hasAccess}
openGroups={openGroups}
toggleGroup={toggleGroup}
location={location}
language={language}
/>
);
}
// 일반 ITEM
if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
const Icon = resolveIcon(entry.icon);
return (
<NavLink
key={entry.menuCd}
to={entry.urlPath}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{Icon && <Icon className="w-4 h-4 shrink-0" />}
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
</NavLink>
);
})}
</nav>
{/* 세션 타임아웃 표시 */}
{!collapsed && user && (
<div className={`mx-2 mb-1 px-3 py-1.5 rounded-lg text-[9px] flex items-center gap-1.5 ${
isSessionWarning
? 'bg-red-500/10 border border-red-500/20 text-red-400'
: 'bg-surface-overlay border border-border text-hint'
}`}>
<Clock className="w-3 h-3" />
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{t('layout.sessionExpiring')} {formatRemaining(sessionRemaining)}</span>
{isSessionWarning && <span className="ml-auto text-[8px] whitespace-nowrap animate-pulse">{t('layout.extendNeeded')}</span>}
</div>
)}
{/* 사이드바 하단 */}
<div className="border-t border-border p-2 space-y-1 shrink-0">
<button
onClick={handleLogout}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] text-hint hover:text-red-400 hover:bg-red-500/10 w-full transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t('layout.logout')}</span>}
</button>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-center w-full py-1.5 rounded-lg text-hint hover:text-muted-foreground hover:bg-surface-overlay transition-colors"
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</aside>
{/* 메인 영역 */}
<div className="flex-1 flex flex-col min-w-0">
{/* 헤더 */}
<header className="flex items-center justify-between h-12 px-4 border-b border-border bg-background/80 backdrop-blur-sm shrink-0">
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input aria-label={t('layout.searchPlaceholder')}
type="text"
placeholder={t('layout.searchPlaceholder')}
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
/>
</div>
</div>
<div className="flex items-center gap-2">
{/* 경보 */}
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
</div>
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<Bell className="w-4 h-4" />
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
</button>
<div className="w-px h-5 bg-white/[0.06]" />
{/* 언어 토글 */}
<button
onClick={toggleLanguage}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
{/* 테마 토글 */}
<button
onClick={toggleTheme}
className="p-1.5 rounded-lg bg-surface-overlay border border-border text-label hover:text-heading transition-colors"
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
)}
</button>
<div className="w-px h-5 bg-white/[0.06]" />
{user && (
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center">
<span className="text-[9px] font-bold text-blue-400">{user.name.charAt(0)}</span>
</div>
<div className="text-right">
<div className="text-[10px] font-medium text-heading">
{user.name}
<span className="text-hint ml-1">({user.rank})</span>
</div>
<div className="text-[8px] text-hint">{user.org}</div>
</div>
{roleColor && (
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
{user.role}
</span>
)}
</div>
)}
</div>
</header>
{/* SFR-02: 공통알림 배너 */}
<NotificationBanner notices={systemNotices} userRole={user?.role} />
{/* SFR-02: 공통 페이지 액션바 (검색, 다운로드, 엑셀, 인쇄) */}
<div className="flex items-center justify-end gap-1.5 px-4 py-1.5 border-b border-border bg-background/60 shrink-0">
{/* 왼쪽: 페이지명 */}
<div className="mr-auto text-[9px] text-hint">{getPageLabel(location.pathname)}</div>
{/* 검색 입력 + 검색 버튼 통합 */}
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label="페이지 내 검색"
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && pageSearch) {
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
}
}}
placeholder={t('layout.pageSearch')}
className="w-48 bg-surface-overlay border border-slate-700/50 rounded-l-md pl-7 pr-2 py-1 text-[10px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
/>
<button
onClick={() => {
if (pageSearch) {
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
}
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors"
>
<Search className="w-3 h-3" />
{t('action.search')}
</button>
</div>
<button
onClick={handleDownload}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
title={t('layout.fileDownload')}
>
<Download className="w-3 h-3" />
{t('layout.download')}
</button>
<button
onClick={handleExcelExport}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
title={t('layout.excelExport')}
>
<FileSpreadsheet className="w-3 h-3" />
{t('layout.excel')}
</button>
<button
onClick={handlePrint}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
title={t('layout.print')}
>
<Printer className="w-3 h-3" />
{t('layout.print')}
</button>
</div>
{/* 콘텐츠 */}
<main
ref={contentRef}
className="flex-1 overflow-auto"
>
<Outlet />
</main>
</div>
{/* SFR-02: 공통알림 팝업 */}
<NotificationPopup notices={systemNotices} userRole={user?.role} />
</div>
);
}
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
function GroupMenu({
group,
children,
collapsed,
hasAccess,
openGroups,
toggleGroup,
location,
language,
}: {
group: MenuConfigItem;
children: MenuConfigItem[];
collapsed: boolean;
hasAccess: (path: string) => boolean;
openGroups: Set<string>;
toggleGroup: (name: string) => void;
location: { pathname: string };
language: string;
}) {
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
if (accessibleItems.length === 0) return null;
const GroupIcon = resolveIcon(group.icon);
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
return (
<div>
<button
type="button"
onClick={() => toggleGroup(group.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
{getMenuLabel(group, language)}
</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</>
)}
</button>
{isOpen && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{children.map((child) => {
if (child.menuType === 'DIVIDER') {
if (collapsed) return null;
return (
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
</div>
);
}
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
const ChildIcon = resolveIcon(child.icon);
return (
<NavLink
key={child.menuCd}
to={child.urlPath}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
{!collapsed && (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
}