kcg-ai-monitoring/frontend/src/app/layout/MainLayout.tsx
htlee e6319a571c refactor: 모노레포 구조로 전환 (frontend/ + backend/ + database/)
Phase 1: 모노레포 디렉토리 구조 구축

- 기존 React 프로젝트를 frontend/ 디렉토리로 이동 (git mv)
- backend/ 디렉토리 생성 (Phase 2에서 Spring Boot 초기화)
- database/migration/ 디렉토리 생성 (Phase 2에서 Flyway 마이그레이션)
- 루트 .gitignore에 frontend/, backend/ 경로 반영
- 루트 CLAUDE.md를 모노레포 가이드로 갱신
- Makefile 추가 (dev/build/lint 통합 명령)
- frontend/vite.config.ts에 /api → :8080 백엔드 proxy 설정
- .githooks/pre-commit을 모노레포 구조에 맞게 갱신
  (frontend/ 변경 시 frontend/ 내부에서 검증)

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

569 lines
26 KiB
TypeScript

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<UserRole, string> = {
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<string, string> = {
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 (
<div className="flex items-center justify-center gap-1">
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
{range.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
}`}
>{p + 1}</button>
))}
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
</div>
);
}
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);
// 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(`<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 ? 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 (
<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 ${roleColor}`}>{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>
)}
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{NAV_ENTRIES.map((entry) => {
if (isGroup(entry)) {
// 그룹 내 RBAC 필터링
const groupItems = entry.items.filter((item) => hasAccess(item.to));
if (groupItems.length === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
return (
<div key={entry.groupKey}>
{/* 그룹 헤더 */}
<button
onClick={() => toggleGroup(entry.groupKey)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isAnyActive || openGroups.has(entry.groupKey)
? 'text-foreground bg-surface-overlay'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<GroupIcon className="w-4 h-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 그룹 하위 메뉴 */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{groupItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
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'
}`
}
>
<item.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
)}
</div>
);
}
// 일반 메뉴 아이템
if (!hasAccess(entry.to)) return null;
return (
<NavLink
key={entry.to}
to={entry.to}
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'
}`
}
>
<entry.icon className="w-4 h-4 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</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
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 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 ${roleColor} bg-white/[0.04]`}>
{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
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-600 hover:bg-blue-500 text-heading 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"
onScroll={handleScroll}
>
<Outlet />
</main>
{/* SFR-02: 공통 페이지네이션 (하단) */}
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
<PagePagination
page={scrollPage}
totalPages={getTotalScrollPages()}
onPageChange={handleScrollPageChange}
/>
</div>
</div>
{/* SFR-02: 공통알림 팝업 */}
<NotificationPopup notices={systemNotices} userRole={user?.role} />
</div>
);
}