import { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { LayoutDashboard, Map, List, Ship, Anchor, Radar, FileText, Settings, LogOut, ChevronLeft, ChevronRight, Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, Download, FileSpreadsheet, Printer, Wifi, Brain, Activity, ChevronsLeft, ChevronsRight, Navigation, Users, EyeOff, BarChart3, Globe, Smartphone, Monitor, Send, Cpu, MessageSquare, } from 'lucide-react'; import { useAuth, type UserRole } from '@/app/auth/AuthContext'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { useSettingsStore } from '@stores/settingsStore'; /* * SFR-01 반영 사항: * - RBAC 기반 메뉴 접근 제어 (역할별 허용 메뉴만 표시) * - 세션 타임아웃 잔여 시간 표시 * - 로그인 사용자 정보·역할·인증방식 표시 * - 로그아웃 시 감사 로그 기록 * * SFR-02 공통기능: * - 모든 페이지 오른쪽 상단: 페이지 검색, 파일다운로드, 엑셀 내보내기, 인쇄 * - 모든 페이지 하단: 페이지네이션 */ const ROLE_COLORS: Record = { ADMIN: 'text-red-400', OPERATOR: 'text-blue-400', ANALYST: 'text-purple-400', FIELD: 'text-green-400', VIEWER: 'text-yellow-400', }; const AUTH_METHOD_LABELS: Record = { password: 'ID/PW', gpki: 'GPKI', sso: 'SSO', }; interface NavItem { to: string; icon: React.ElementType; labelKey: string; } interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; } type NavEntry = NavItem | NavGroup; const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry; const NAV_ENTRIES: NavEntry[] = [ // ── 상황판·감시 ── { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, { to: '/events', icon: Radar, labelKey: 'nav.eventList' }, { to: '/map-control', icon: Map, labelKey: 'nav.riskMap' }, // ── 위험도·단속 ── { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, { to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' }, // ── 탐지 ── { to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' }, { to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' }, { to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' }, // ── 이력·통계 ── { to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' }, { to: '/event-list', icon: List, labelKey: 'nav.eventList' }, { to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' }, { to: '/reports', icon: FileText, labelKey: 'nav.reports' }, // ── 함정용 (그룹) ── { groupKey: 'group.fieldOps', icon: Ship, items: [ { to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' }, { to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' }, { to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' }, { to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' }, { to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' }, ], }, // ── 관리자 (그룹) ── { groupKey: 'group.admin', icon: Settings, items: [ { to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' }, { to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' }, { to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' }, { to: '/external-service', icon: Globe, labelKey: 'nav.externalService' }, { to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' }, { to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' }, { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, { to: '/admin', icon: Settings, labelKey: 'nav.admin' }, { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, ], }, ]; // getPageLabel용 flat 목록 const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]); function formatRemaining(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; } // ─── 공통 페이지네이션 (간소형) ───────────── function PagePagination({ page, totalPages, onPageChange }: { page: number; totalPages: number; onPageChange: (p: number) => void; }) { if (totalPages <= 1) return null; const range: number[] = []; const maxVis = 5; let s = Math.max(0, page - Math.floor(maxVis / 2)); const e = Math.min(totalPages - 1, s + maxVis - 1); if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1); for (let i = s; i <= e; i++) range.push(i); const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors"; return (
{range.map((p) => ( ))} {page + 1} / {totalPages}
); } 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); // getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n) const getPageLabel = (pathname: string): string => { const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to)); return item ? t(item.labelKey) : ''; }; // 공통 검색 const [pageSearch, setPageSearch] = useState(''); // 공통 스크롤 페이징 (페이지 단위 스크롤) const [scrollPage, setScrollPage] = useState(0); const scrollPageSize = 800; // px per page const handleScrollPageChange = (p: number) => { setScrollPage(p); if (contentRef.current) { contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' }); } }; // 스크롤 이벤트로 현재 페이지 추적 const handleScroll = () => { if (contentRef.current) { const { scrollTop, scrollHeight, clientHeight } = contentRef.current; const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1); setScrollPage(currentPage); } }; const getTotalScrollPages = () => { if (!contentRef.current) return 1; const { scrollHeight, clientHeight } = contentRef.current; return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); }; // 인쇄 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 ? ROLE_COLORS[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: 공통 페이지네이션 (하단) */}
{/* SFR-02: 공통알림 팝업 */}
); }