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>
569 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|