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 = { 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(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(`${getPageLabel(location.pathname)} - ${t('layout.print')} ${el.innerHTML}`); 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 = `${getPageLabel(location.pathname)}${el.innerHTML}`; 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>(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 (
{/* 사이드바 */} {/* 메인 영역 */}
{/* 헤더 */}
{/* 경보 */}
{t('layout.alertCount', { count: 3 })}
{/* 언어 토글 */} {/* 테마 토글 */}
{user && (
{user.name.charAt(0)}
{user.name} ({user.rank})
{user.org}
{roleColor && ( {user.role} )}
)}
{/* SFR-02: 공통알림 배너 */} {/* SFR-02: 공통 페이지 액션바 (검색, 다운로드, 엑셀, 인쇄) */}
{/* 왼쪽: 페이지명 */}
{getPageLabel(location.pathname)}
{/* 검색 입력 + 검색 버튼 통합 */}
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" />
{/* 콘텐츠 */}
{/* SFR-02: 공통알림 팝업 */}
); } /* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */ function GroupMenu({ group, children, collapsed, hasAccess, openGroups, toggleGroup, location, language, }: { group: MenuConfigItem; children: MenuConfigItem[]; collapsed: boolean; hasAccess: (path: string) => boolean; openGroups: Set; 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 (
{isOpen && (
{children.map((child) => { if (child.menuType === 'DIVIDER') { if (collapsed) return null; return (
{child.dividerLabel}
); } if (!child.urlPath || !hasAccess(child.urlPath)) return null; const ChildIcon = resolveIcon(child.icon); return ( `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 && } {!collapsed && ( {getMenuLabel(child, language)} )} ); })}
)}
); }