feat: SFR-05~14 화면 시안 전면 반영 및 UI 신규 구현

- SFR-05: 위험도지도 좌측 필터 패널 + 우측 격자상세(SHAP) + 내보내기
- SFR-06: 단속계획 3단 메뉴 구조 + SFR-11 하위 11개 화면
- SFR-07: 순찰경로 가중치 슬라이더(α/β/γ) + 시나리오 + 결과통계
- SFR-08: 다함정최적화 커버리지/중복 슬라이더 + 함정별 상세 + 일괄승인
- SFR-09: 불법어선탐지 필터탭 + 탐지요약 + SHAP 패널 + AIS등급
- SFR-11: 단속 사건관리 통합(리스트→등록→상세→수정), AI탐지연계
- SFR-12: 경보현황판 지도중심 레이아웃 + 5등급 경보 + 필터 + 선박목록
- SFR-13: 통계분석 세로스크롤 대시보드 + 기관비교표 + 보고서생성
- SFR-14: 외부서비스 비식별정책 + API정의 + 이용현황 + 장비구성도
- SFR-15: 모바일서비스 상태바+경보+퀵메뉴+위치정보+지도+네비바
- 공통: OSM 지도 적용, Vite CORS 프록시 수정, 3단 메뉴 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-04-08 17:05:44 +09:00
부모 8ccae2fee5
커밋 353c960c3f
32개의 변경된 파일4714개의 추가작업 그리고 553개의 파일을 삭제

파일 보기

@ -12,7 +12,7 @@ import { LoginPage } from '@features/auth';
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
/* SFR-10 */ import { GearDetection } from '@features/detection';
/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement';
/* SFR-11 */ import { EnforcementHistory, CaseList, MatchDashboard, MatchVerify, LabelList, HistorySearch, VesselHistory, TypeStats, ReportGen, ChangeHistory } from '@features/enforcement';
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
/* SFR-13 */ import { Statistics } from '@features/statistics';
/* SFR-14 */ import { ExternalService } from '@features/statistics';
@ -82,6 +82,16 @@ export default function App() {
{/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-11 단속 사건 관리 (리스트·등록·상세·수정 통합) */}
<Route path="enforcement-plan/cases" element={<ProtectedRoute resource="enforcement:enforcement-history"><CaseList /></ProtectedRoute>} />
<Route path="enforcement-plan/match-dashboard" element={<ProtectedRoute resource="enforcement:enforcement-history"><MatchDashboard /></ProtectedRoute>} />
<Route path="enforcement-plan/match-verify" element={<ProtectedRoute resource="enforcement:enforcement-history" operation="UPDATE"><MatchVerify /></ProtectedRoute>} />
<Route path="enforcement-plan/label-list" element={<ProtectedRoute resource="enforcement:enforcement-history"><LabelList /></ProtectedRoute>} />
<Route path="enforcement-plan/history-search" element={<ProtectedRoute resource="enforcement:enforcement-history"><HistorySearch /></ProtectedRoute>} />
<Route path="enforcement-plan/vessel-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><VesselHistory /></ProtectedRoute>} />
<Route path="enforcement-plan/type-stats" element={<ProtectedRoute resource="enforcement:enforcement-history"><TypeStats /></ProtectedRoute>} />
<Route path="enforcement-plan/report-gen" element={<ProtectedRoute resource="enforcement:enforcement-history"><ReportGen /></ProtectedRoute>} />
<Route path="enforcement-plan/change-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><ChangeHistory /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />

파일 보기

@ -44,6 +44,15 @@ const PATH_TO_RESOURCE: Record<string, string> = {
'/china-fishing': 'detection:china-fishing',
'/vessel': 'vessel',
'/risk-map': 'risk-assessment:risk-map',
'/enforcement-plan/cases': 'enforcement:enforcement-history',
'/enforcement-plan/match-dashboard': 'enforcement:enforcement-history',
'/enforcement-plan/match-verify': 'enforcement:enforcement-history',
'/enforcement-plan/label-list': 'enforcement:enforcement-history',
'/enforcement-plan/history-search': 'enforcement:enforcement-history',
'/enforcement-plan/vessel-history': 'enforcement:enforcement-history',
'/enforcement-plan/type-stats': 'enforcement:enforcement-history',
'/enforcement-plan/report-gen': 'enforcement:enforcement-history',
'/enforcement-plan/change-history': 'enforcement:enforcement-history',
'/enforcement-plan': 'risk-assessment:enforcement-plan',
'/patrol-route': 'patrol:patrol-route',
'/fleet-optimization': 'patrol:fleet-optimization',

파일 보기

@ -10,6 +10,7 @@ import {
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
Plus, Edit3,
} from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
@ -42,10 +43,17 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
};
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
interface NavSubGroup { subGroupKey: string; icon: React.ElementType; items: NavItem[]; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavSubGroup)[]; }
type NavEntry = NavItem | NavGroup;
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
const isSubGroup = (entry: NavItem | NavSubGroup): entry is NavSubGroup => 'subGroupKey' in entry;
/** 서브그룹 포함 그룹에서 모든 NavItem을 플랫하게 추출 */
function flatGroupItems(items: (NavItem | NavSubGroup)[]): NavItem[] {
return items.flatMap(item => isSubGroup(item) ? item.items : [item]);
}
const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
@ -55,7 +63,42 @@ const NAV_ENTRIES: NavEntry[] = [
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
// ── 위험도·단속 ──
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
// ── SFR-11 단속계획 (3단 메뉴) ──
{
groupKey: 'group.enforcementPlan', icon: Shield,
items: [
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
{
subGroupKey: 'subGroup.caseManagement', icon: FileText,
items: [
{ to: '/enforcement-plan/cases', icon: List, labelKey: 'nav.caseManagement' },
],
},
{
subGroupKey: 'subGroup.aiMatch', icon: Activity,
items: [
{ to: '/enforcement-plan/match-dashboard', icon: Activity, labelKey: 'nav.matchDashboard' },
{ to: '/enforcement-plan/match-verify', icon: CheckSquare, labelKey: 'nav.matchVerify' },
{ to: '/enforcement-plan/label-list', icon: Tag, labelKey: 'nav.labelList' },
],
},
{
subGroupKey: 'subGroup.historyAnalysis', icon: Search,
items: [
{ to: '/enforcement-plan/history-search', icon: Search, labelKey: 'nav.historySearch' },
{ to: '/enforcement-plan/vessel-history', icon: Ship, labelKey: 'nav.vesselHistory' },
{ to: '/enforcement-plan/type-stats', icon: BarChart3, labelKey: 'nav.typeStats' },
{ to: '/enforcement-plan/report-gen', icon: FileSpreadsheet, labelKey: 'nav.reportGen' },
],
},
{
subGroupKey: 'subGroup.auditTrail', icon: History,
items: [
{ to: '/enforcement-plan/change-history', icon: History, labelKey: 'nav.changeHistory' },
],
},
],
},
// ── 탐지 ──
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
@ -106,7 +149,7 @@ const NAV_ENTRIES: NavEntry[] = [
];
// getPageLabel용 flat 목록
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? flatGroupItems(e.items) : [e]);
function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60);
@ -322,15 +365,17 @@ export function MainLayout() {
<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;
// 그룹 내 모든 NavItem 플랫 추출 (RBAC 필터링용)
const allItems = flatGroupItems(entry.items);
const accessiblePaths = new Set(allItems.filter(item => hasAccess(item.to)).map(item => item.to));
if (accessiblePaths.size === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
const isAnyActive = allItems.some(item => location.pathname.startsWith(item.to));
return (
<div key={entry.groupKey}>
{/* 그룹 헤더 */}
{/* 1단: 그룹 헤더 */}
<button
type="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)
@ -346,25 +391,79 @@ export function MainLayout() {
</>
)}
</button>
{/* 그룹 하위 메뉴 */}
{/* 2단: 그룹 하위 메뉴 (NavItem 또는 NavSubGroup) */}
{(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>
))}
{entry.items.map((child) => {
if (isSubGroup(child)) {
// 서브그룹 내 RBAC 필터링
const subItems = child.items.filter(si => accessiblePaths.has(si.to));
if (subItems.length === 0) return null;
const SubIcon = child.icon;
const isSubActive = subItems.some(si => location.pathname.startsWith(si.to));
return (
<div key={child.subGroupKey}>
{/* 2단: 서브그룹 헤더 */}
<button
type="button"
onClick={() => toggleGroup(child.subGroupKey)}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[10px] font-semibold w-full transition-colors ${
isSubActive || openGroups.has(child.subGroupKey)
? 'text-label bg-surface-overlay/60'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<SubIcon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(child.subGroupKey)}</span>
<ChevronRight className={`w-2.5 h-2.5 shrink-0 transition-transform ${openGroups.has(child.subGroupKey) || isSubActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 3단: 서브그룹 아이템 */}
{(openGroups.has(child.subGroupKey) || isSubActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border/50'}`}>
{subItems.map(si => (
<NavLink
key={si.to}
to={si.to}
className={({ isActive }) =>
`flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] 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'
}`
}
>
<si.icon className="w-3 h-3 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(si.labelKey)}</span>}
</NavLink>
))}
</div>
)}
</div>
);
}
// 일반 NavItem (2단 직속)
if (!accessiblePaths.has(child.to)) return null;
return (
<NavLink
key={child.to}
to={child.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'
}`
}
>
<child.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(child.labelKey)}</span>}
</NavLink>
);
})}
</div>
)}
</div>

파일 보기

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react';
import { EyeOff, AlertTriangle, Radio, Tag, Loader2, Shield, ChevronRight, X } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import {
@ -15,7 +15,25 @@ import { formatDateTime } from '@shared/utils/dateFormat';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; }
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; aisGrade: 'A' | 'B' | 'C'; category: FilterTab; [key: string]: unknown; }
type FilterTab = '전체' | 'Dark Vessel' | '공조조업' | 'MMSI 이상' | '금어기';
const FILTER_TABS: FilterTab[] = ['전체', 'Dark Vessel', '공조조업', 'MMSI 이상', '금어기'];
/* SHAP feature contribution data for the selected vessel */
interface ShapFeature {
label: string;
value: number; // 0-1 contribution fraction
color: string;
}
const DAILY_SUMMARY = [
{ label: '고위험', count: 12, color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30' },
{ label: '중위험', count: 28, color: 'text-orange-400', bg: 'bg-orange-500/15 border-orange-500/30' },
{ label: '주의', count: 41, color: 'text-yellow-400', bg: 'bg-yellow-500/15 border-yellow-500/30' },
{ label: '정상전환', count: 5, color: 'text-green-400', bg: 'bg-green-500/15 border-green-500/30' },
];
const GAP_FULL_BLOCK_MIN = 1440;
const GAP_LONG_LOSS_MIN = 60;
@ -44,21 +62,37 @@ function deriveFlag(mmsi: string): string {
return '미상';
}
function deriveAisGrade(item: VesselAnalysisItem): 'A' | 'B' | 'C' {
const gap = item.algorithms.darkVessel.gapDurationMin;
if (gap <= 5) return 'A';
if (gap <= 60) return 'B';
return 'C';
}
function deriveCategory(pattern: string): FilterTab {
if (pattern === 'AIS 완전차단' || pattern === '장기소실') return 'Dark Vessel';
if (pattern === 'MMSI 변조 의심') return 'MMSI 이상';
return 'Dark Vessel';
}
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
const risk = item.algorithms.riskScore.score;
const status = deriveStatus(item);
const pattern = derivePattern(item);
return {
id: `DV-${String(idx + 1).padStart(3, '0')}`,
mmsi: item.mmsi,
name: item.classification.vesselType || item.mmsi,
flag: deriveFlag(item.mmsi),
pattern: derivePattern(item),
pattern,
risk,
lastAIS: formatDateTime(item.timestamp),
status,
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
lat: 0,
lng: 0,
aisGrade: deriveAisGrade(item),
category: deriveCategory(pattern),
};
}
@ -77,6 +111,8 @@ const cols: DataColumn<Suspect>[] = [
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
{ key: 'aisGrade', label: 'AIS 신뢰', width: '65px', align: 'center', sortable: true,
render: v => { const g = v as string; const c = g === 'A' ? 'bg-green-500/20 text-green-400 border-green-500/30' : g === 'B' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' : 'bg-red-500/20 text-red-400 border-red-500/30'; return <span className={`inline-flex items-center justify-center w-6 h-5 rounded text-[10px] font-bold border ${c}`}>{g}</span>; } },
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '추적중' ? 'bg-red-500/20 text-red-400' : s === '감시중' ? 'bg-yellow-500/20 text-yellow-400' : s === '확인중' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
@ -90,6 +126,8 @@ export function DarkVesselDetection() {
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [activeFilter, setActiveFilter] = useState<FilterTab>('전체');
const [selectedVessel, setSelectedVessel] = useState<Suspect | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
@ -118,6 +156,28 @@ export function DarkVesselDetection() {
[DATA],
);
const tabCounts = useMemo(() => {
const counts: Record<FilterTab, number> = { '전체': DATA.length, 'Dark Vessel': 0, '공조조업': 0, 'MMSI 이상': 0, '금어기': 0 };
DATA.forEach(d => { if (counts[d.category] !== undefined) counts[d.category]++; });
return counts;
}, [DATA]);
const filteredData = useMemo(
() => activeFilter === '전체' ? DATA : DATA.filter(d => d.category === activeFilter),
[DATA, activeFilter],
);
/* SHAP explanation data for the selected vessel (mock) */
const shapFeatures = useMemo((): ShapFeature[] => {
if (!selectedVessel) return [];
return [
{ label: 'AIS 신호 차단 47분 (제한수역)', value: 0.38, color: '#ef4444' },
{ label: '블랙리스트+단속이력', value: 0.26, color: '#f97316' },
{ label: '협정선 체류 82분', value: 0.18, color: '#eab308' },
{ label: 'COG 변화율 높음', value: 0.09, color: '#3b82f6' },
];
}, [selectedVessel]);
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
@ -183,7 +243,135 @@ export function DarkVesselDetection() {
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
{/* ── Daily Detection Summary ── */}
<div className="flex gap-2">
{DAILY_SUMMARY.map(s => (
<div key={s.label} className={`flex-1 rounded-xl border px-4 py-3 ${s.bg}`}>
<div className={`text-lg font-bold ${s.color}`}>{s.count}<span className="text-[10px] font-normal ml-0.5"></span></div>
<div className="text-[10px] text-hint mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* ── Filter Tabs ── */}
<div className="flex items-center gap-1 border-b border-border pb-0">
{FILTER_TABS.map(tab => (
<button
type="button"
key={tab}
onClick={() => { setActiveFilter(tab); setSelectedVessel(null); }}
className={`relative px-3 py-2 text-[11px] font-medium rounded-t-lg transition-colors ${
activeFilter === tab
? 'text-cyan-400 bg-card border border-border border-b-transparent -mb-px'
: 'text-hint hover:text-label hover:bg-surface-overlay'
}`}
>
{tab}
<span className={`ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[16px] rounded-full text-[9px] font-bold px-1 ${
activeFilter === tab ? 'bg-cyan-500/20 text-cyan-400' : 'bg-border text-hint'
}`}>{tabCounts[tab]}</span>
</button>
))}
</div>
{/* ── Main Content: Table + SHAP Detail Panel ── */}
<div className="flex gap-4">
<div className={selectedVessel ? 'flex-1 min-w-0' : 'w-full'}>
<DataTable data={filteredData} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" onRowClick={(row) => setSelectedVessel(row)} />
</div>
{/* ── SHAP Explanation Panel ── */}
{selectedVessel && (
<div className="w-[380px] shrink-0 rounded-xl border border-border bg-card overflow-hidden">
{/* Panel Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface-overlay/50">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-cyan-400" />
<span className="text-[12px] font-bold text-heading">AI </span>
</div>
<button type="button" title="패널 닫기" onClick={() => setSelectedVessel(null)} className="text-hint hover:text-label"><X className="w-4 h-4" /></button>
</div>
<div className="p-4 space-y-4 overflow-y-auto max-h-[600px]">
{/* Vessel Info Header */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-bold text-sm">{selectedVessel.name}</span>
<span className={`inline-flex items-center justify-center w-5 h-4 rounded text-[9px] font-bold border ${
selectedVessel.aisGrade === 'A' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
selectedVessel.aisGrade === 'B' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>{selectedVessel.aisGrade}</span>
</div>
<div className="flex items-center gap-3 text-[10px] text-hint">
<span>MMSI <span className="text-label font-mono">{selectedVessel.mmsi}</span></span>
<span> <span className="text-label">{selectedVessel.flag}</span></span>
<span> <span className="text-label">{selectedVessel.pattern}</span></span>
</div>
</div>
{/* Risk Score Display */}
<div className="rounded-lg border border-border bg-background/50 p-3">
<div className="text-[10px] text-hint mb-2"> </div>
<div className="flex items-baseline gap-1">
<span className={`text-3xl font-bold ${selectedVessel.risk > 80 ? 'text-red-400' : selectedVessel.risk > 50 ? 'text-yellow-400' : 'text-green-400'}`}>
{(selectedVessel.risk / 100).toFixed(2)}
</span>
<span className="text-sm text-hint">/ 1.00</span>
</div>
<div className="mt-2 w-full h-2 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full transition-all ${selectedVessel.risk > 80 ? 'bg-red-500' : selectedVessel.risk > 50 ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${selectedVessel.risk}%` }}
/>
</div>
</div>
{/* SHAP Feature Contribution Bars */}
<div>
<div className="text-[10px] text-hint mb-2 font-medium">SHAP </div>
<div className="space-y-2.5">
{shapFeatures.map((f, i) => (
<div key={i}>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-label">{f.label}</span>
<span className="text-[10px] font-mono text-hint">{(f.value * 100).toFixed(0)}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-border overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${f.value * 100}%`, backgroundColor: f.color }} />
</div>
</div>
))}
</div>
</div>
{/* Scenario Tags */}
<div>
<div className="text-[10px] text-hint mb-1.5 font-medium"> </div>
<div className="flex gap-1.5">
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/15 text-red-400 border border-red-500/30">S-1</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-orange-500/15 text-orange-400 border border-orange-500/30">S-4</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<button type="button" className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-[11px] font-bold bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors">
<ChevronRight className="w-3.5 h-3.5" />
</button>
<div className="flex gap-2">
<button type="button" className="flex-1 px-3 py-2 rounded-lg text-[11px] font-medium bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25 transition-colors">
</button>
<button type="button" className="flex-1 px-3 py-2 rounded-lg text-[11px] font-medium bg-orange-500/15 text-orange-400 border border-orange-500/30 hover:bg-orange-500/25 transition-colors">
&amp;
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* 탐지 위치 지도 */}
<Card>

파일 보기

@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { FileText, Ship, MapPin, Clock, User, AlertTriangle } from 'lucide-react';
/** SFR11-03: 단속 사건 상세 조회 */
export function CaseDetail() {
const { t } = useTranslation('enforcement');
const detail = {
id: 'ENF-20260401-0012', date: '2026-04-01 14:30:22', zone: '서해 NLL',
lat: 37.7891, lon: 124.6234, vessel: '금성호', mmsi: '440123456',
flag: 'CN', violation: '불법조업 (IUU)', action: '나포',
result: '과태료 부과', officer: '박현우 경위', aiMatch: '92%',
remarks: '중국 어선 2척과 동시 조업 중 적발. AIS 신호 단절 이력 확인.',
};
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('caseDetail.title')}</h1>
<p className="text-xs text-hint mt-1">{t('caseDetail.desc')}</p>
</div>
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-heading flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-400" />
{detail.id}
</h2>
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400 text-[10px] font-medium">{detail.violation}</span>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ icon: Clock, label: '단속 일시', value: detail.date },
{ icon: MapPin, label: '단속 위치', value: `${detail.zone} (${detail.lat}°N, ${detail.lon}°E)` },
{ icon: Ship, label: '대상 선박', value: `${detail.vessel} (${detail.mmsi}) / ${detail.flag}` },
{ icon: AlertTriangle, label: '조치', value: detail.action },
{ icon: FileText, label: '처리 결과', value: detail.result },
{ icon: User, label: '담당자', value: detail.officer },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center gap-1.5 text-[9px] text-hint mb-1"><Icon className="w-3 h-3" />{label}</div>
<div className="text-xs text-heading font-medium">{value}</div>
</div>
))}
</div>
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[9px] text-hint mb-1">AI </div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: detail.aiMatch }} />
</div>
<span className="text-xs text-green-400 font-bold">{detail.aiMatch}</span>
</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[9px] text-hint mb-1"></div>
<div className="text-xs text-label">{detail.remarks}</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next';
import { Edit3, Search } from 'lucide-react';
/** SFR11-02: 단속 사건 수정 */
export function CaseEdit() {
const { t } = useTranslation('enforcement');
const mockCases = [
{ id: 'ENF-20260401-0012', date: '2026-04-01 14:30', zone: '서해 NLL', vessel: '금성호 (440123456)', violation: '불법조업', status: '처리중' },
{ id: 'ENF-20260401-0011', date: '2026-04-01 11:15', zone: '동해 EEZ', vessel: 'LIAO DONG 77 (412345678)', violation: '관할수역 침범', status: '완료' },
{ id: 'ENF-20260331-0045', date: '2026-03-31 09:20', zone: '남해', vessel: '대한호 (440987654)', violation: '불법어구 사용', status: '처리중' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('caseEdit.title')}</h1>
<p className="text-xs text-hint mt-1">{t('caseEdit.desc')}</p>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" placeholder="사건번호 또는 선박명 검색" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"> </th>
<th className="text-left px-4 py-2.5 text-hint font-medium"> </th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-center px-4 py-2.5 text-hint font-medium"></th>
</tr>
</thead>
<tbody>
{mockCases.map((c) => (
<tr key={c.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-2.5 text-blue-400 font-mono text-[10px]">{c.id}</td>
<td className="px-4 py-2.5 text-label">{c.date}</td>
<td className="px-4 py-2.5 text-label">{c.zone}</td>
<td className="px-4 py-2.5 text-heading">{c.vessel}</td>
<td className="px-4 py-2.5"><span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{c.violation}</span></td>
<td className="px-4 py-2.5"><span className={`px-1.5 py-0.5 rounded text-[10px] ${c.status === '완료' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>{c.status}</span></td>
<td className="px-4 py-2.5 text-center">
<button className="p-1 rounded hover:bg-blue-500/10 text-hint hover:text-blue-400 transition-colors">
<Edit3 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,713 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plus, Search, Ship, MapPin, Calendar, FileText, Upload,
Edit3, X, ChevronLeft, User, Building2, Anchor, AlertTriangle,
Clock, CheckCircle, Crosshair, Loader2, Radar,
} from 'lucide-react';
import { useAuth } from '@/app/auth/AuthContext';
/** AI 탐지 결과 (SFR-09/10에서 전달) */
interface AiDetectionResult {
detectionId: string;
mmsi: string;
vesselName: string;
flag: string;
fishType: string;
position: string;
detectedAt: string;
suspicionType: string;
confidence: number;
analysisDetail: string;
violationDesc: string;
}
/** AI 탐지 결과 목록 (실제로는 API 호출) */
const AI_DETECTIONS: AiDetectionResult[] = [
{
detectionId: 'DET-20260408-0032', mmsi: '412345678', vesselName: 'LIAO DONG 77', flag: 'CN', fishType: '통발',
position: '36.8°N 125.9°E', detectedAt: '2026-04-08 08:45', suspicionType: 'Dark Vessel',
confidence: 94, analysisDetail: 'AIS 신호 6시간 단절 후 조업 구역 재진입 탐지',
violationDesc: '서해 NLL 인근 AIS 신호 단절 후 불법 조업 재개. 24시간 내 3회 반복 패턴 감지. 중국 선단 2척과 동시 행동.',
},
{
detectionId: 'DET-20260408-0031', mmsi: '412876543', vesselName: 'YUE DONG 12', flag: 'CN', fishType: '자망',
position: '37.6°N 124.6°E', detectedAt: '2026-04-08 07:30', suspicionType: 'AIS 조작',
confidence: 88, analysisDetail: 'MMSI 변조 감지, 위치 스푸핑 의심 좌표 이동 패턴',
violationDesc: '서해 5도 해역에서 MMSI 변조 및 위치 스푸핑 탐지. 실제 위치와 AIS 보고 위치 간 12km 차이 확인.',
},
{
detectionId: 'DET-20260408-0030', mmsi: '440567890', vesselName: '신양호', flag: 'KR', fishType: '연승',
position: '33.2°N 126.5°E', detectedAt: '2026-04-08 06:15', suspicionType: '어구 위치 위반',
confidence: 72, analysisDetail: 'SFR-10 어구 탐지 모듈에서 금지 구역 내 어구 AIS 신호 감지',
violationDesc: '제주 남방 금어기 해역에서 어구 AIS(Class-B) 신호 탐지. 해당 구역 4~5월 금어기 위반 의심.',
},
];
/**
* SFR11
* 3 :
* 1) () +
* 2) / ,
* 3)
*/
interface CaseItem {
id: string;
date: string;
zone: string;
lat: number;
lon: number;
vessel: string;
mmsi: string;
flag: string;
fishType: string;
violationType: string;
violationDesc: string;
action: string;
result: string;
officer: string;
org: string;
aiMatch: number;
status: string;
}
type ViewMode = 'list' | 'detail' | 'register';
const MOCK_CASES: CaseItem[] = [
{ id: 'ENF-20260408-0015', date: '2026-04-08 09:12', zone: '서해 NLL', lat: 36.8, lon: 125.9, vessel: '금성호', mmsi: '440123456', flag: 'CN', fishType: '자망', violationType: '금어기 조업', violationDesc: '금어기 중 자망 설치 적발. 2척 동시 조업 중 확인.', action: '나포', result: '과태료 부과', officer: '박현우 경위', org: '서해지방해양경찰청', aiMatch: 92, status: 'APPROVED' },
{ id: 'ENF-20260407-0014', date: '2026-04-07 14:30', zone: '동해 EEZ', lat: 37.1, lon: 129.5, vessel: 'LIAO DONG 77', mmsi: '412345678', flag: 'CN', fishType: '통발', violationType: '협정선 위반', violationDesc: '한중어업협정 수역 밖 불법 조업 확인.', action: '퇴거', result: '완료', officer: '김해양 경감', org: '동해지방해양경찰청', aiMatch: 87, status: 'APPROVED' },
{ id: 'ENF-20260406-0013', date: '2026-04-06 11:20', zone: '남해', lat: 34.5, lon: 128.2, vessel: '대한호', mmsi: '440987654', flag: 'KR', fishType: '연승', violationType: '불법어구 사용', violationDesc: '규격 미달 어구 사용 적발.', action: '경고', result: '처리중', officer: '이단속 경사', org: '남해지방해양경찰청', aiMatch: 78, status: 'DRAFT' },
{ id: 'ENF-20260405-0012', date: '2026-04-05 08:45', zone: '서해 NLL', lat: 37.3, lon: 124.8, vessel: 'MIN RONG 8', mmsi: '412234567', flag: 'CN', fishType: '저인망', violationType: '무허가 조업', violationDesc: 'AIS 신호 단절 후 조업 재개. Dark Vessel 탐지 연계.', action: '나포', result: '수사의뢰', officer: '박현우 경위', org: '서해지방해양경찰청', aiMatch: 95, status: 'APPROVED' },
{ id: 'ENF-20260404-0011', date: '2026-04-04 16:10', zone: '제주', lat: 33.2, lon: 126.5, vessel: '신양호', mmsi: '440567890', flag: 'KR', fishType: '자망', violationType: '미신고 조업', violationDesc: '출항 미신고 상태에서 조업 적발.', action: '경고', result: '완료', officer: '최민수 경장', org: '제주지방해양경찰청', aiMatch: 71, status: 'APPROVED' },
{ id: 'ENF-20260403-0010', date: '2026-04-03 13:55', zone: '서해 5도', lat: 37.6, lon: 124.6, vessel: 'YUE DONG 12', mmsi: '412876543', flag: 'CN', fishType: '통발', violationType: 'AIS 조작', violationDesc: 'MMSI 변조 및 위치 스푸핑 탐지.', action: '나포', result: '과태료 부과', officer: '김해양 경감', org: '서해지방해양경찰청', aiMatch: 88, status: 'APPROVED' },
];
/** 현재 로컬 시간을 datetime-local input 형식(YYYY-MM-DDTHH:mm)으로 반환 */
function nowLocalISO(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function CaseList() {
const { t } = useTranslation('enforcement');
const { user } = useAuth();
const [view, setView] = useState<ViewMode>('list');
const [selected, setSelected] = useState<CaseItem | null>(null);
const [editing, setEditing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// ─── 등록 폼 기본값: 현재 시각 + GPS 위치 + 로그인 정보 ───
const [regDateTime, setRegDateTime] = useState(nowLocalISO());
const [regLocation, setRegLocation] = useState('');
const [gpsLoading, setGpsLoading] = useState(false);
const [gpsError, setGpsError] = useState('');
// ─── AI 탐지 연계 ───
const [aiDetection, setAiDetection] = useState<AiDetectionResult | null>(null);
const [showAiLookup, setShowAiLookup] = useState(false);
/** 브라우저 Geolocation API로 현재 GPS 좌표 가져오기 */
const fetchGPS = useCallback(() => {
if (!navigator.geolocation) {
setGpsError('GPS를 지원하지 않는 브라우저입니다.');
return;
}
setGpsLoading(true);
setGpsError('');
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude.toFixed(4);
const lon = pos.coords.longitude.toFixed(4);
setRegLocation(`${lat}°N ${lon}°E`);
setGpsLoading(false);
},
(err) => {
setGpsError(err.code === 1 ? '위치 권한이 거부되었습니다.' : '위치를 가져올 수 없습니다.');
setGpsLoading(false);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
}, []);
const filtered = MOCK_CASES.filter(c =>
!searchTerm || c.vessel.includes(searchTerm) || c.id.includes(searchTerm) || c.zone.includes(searchTerm) || c.mmsi.includes(searchTerm)
);
const openDetail = (c: CaseItem) => { setSelected(c); setEditing(false); setView('detail'); };
const openRegister = () => {
setSelected(null);
setEditing(false);
setRegDateTime(nowLocalISO());
setRegLocation('');
setGpsError('');
setAiDetection(null);
setShowAiLookup(false);
fetchGPS(); // 등록 진입 시 자동으로 GPS 위치 가져오기
setView('register');
};
const backToList = () => { setView('list'); setSelected(null); setEditing(false); };
const statusBadge = (s: string) =>
s === 'APPROVED' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400';
// ─── 사건 리스트 ─────────────────────
if (view === 'list') {
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-heading">{t('caseList.title')}</h1>
</div>
<button type="button" onClick={openRegister} className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* 검색 + 통계 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value)}
placeholder="사건번호, 선박명, MMSI, 해역 검색..."
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60"
/>
</div>
<div className="flex items-center gap-4 text-[10px]">
<span className="text-hint"> <span className="text-heading font-bold">{MOCK_CASES.length}</span></span>
<span className="text-hint"> <span className="text-green-400 font-bold">{MOCK_CASES.filter(c => c.status === 'APPROVED').length}</span></span>
<span className="text-hint"> <span className="text-yellow-400 font-bold">{MOCK_CASES.filter(c => c.status === 'DRAFT').length}</span></span>
</div>
</div>
{/* 그리드 테이블 */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
<th className="text-left px-4 py-2.5 text-hint font-medium w-[170px]"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-left px-4 py-2.5 text-hint font-medium"> </th>
<th className="text-left px-4 py-2.5 text-hint font-medium"> </th>
<th className="text-left px-4 py-2.5 text-hint font-medium"></th>
<th className="text-center px-4 py-2.5 text-hint font-medium w-[70px]">AI </th>
<th className="text-center px-4 py-2.5 text-hint font-medium w-[90px]"></th>
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr
key={c.id}
onClick={() => openDetail(c)}
className="border-b border-border hover:bg-blue-500/5 cursor-pointer transition-colors"
>
<td className="px-4 py-2.5 text-blue-400 font-mono text-[10px]">{c.id}</td>
<td className="px-4 py-2.5 text-label">{c.date}</td>
<td className="px-4 py-2.5 text-label">{c.zone}</td>
<td className="px-4 py-2.5">
<span className="text-heading font-medium">{c.vessel}</span>
<span className="text-hint text-[10px] ml-1">({c.mmsi})</span>
</td>
<td className="px-4 py-2.5">
<span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{c.violationType}</span>
</td>
<td className="px-4 py-2.5 text-label">{c.action}</td>
<td className="px-4 py-2.5 text-center">
<span className={`font-bold ${c.aiMatch >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{c.aiMatch}%</span>
</td>
<td className="px-4 py-2.5 text-center">
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${statusBadge(c.status)}`}>{c.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-[9px] text-hint"> .</div>
</div>
);
}
// ─── 사건 상세 / 수정 ─────────────────────
if (view === 'detail' && selected) {
return (
<div className="p-6 space-y-5">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button type="button" onClick={backToList} className="p-1.5 rounded-lg hover:bg-surface-overlay text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-lg font-bold text-heading flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-400" />
{selected.id}
</h1>
<p className="text-[10px] text-hint mt-0.5">
{editing ? '수정 모드 — 변경 후 저장을 누르세요' : '상세 조회 — 수정하려면 수정 버튼을 클릭하세요'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!editing ? (
<button type="button" onClick={() => setEditing(true)} className="flex items-center gap-1.5 px-4 py-2 bg-orange-600 hover:bg-orange-500 text-heading text-[11px] font-bold rounded-lg transition-colors">
<Edit3 className="w-3.5 h-3.5" />
</button>
) : (
<>
<button type="button" onClick={() => setEditing(false)} className="flex items-center gap-1.5 px-4 py-2 border border-border text-hint text-[11px] rounded-lg hover:bg-surface-overlay transition-colors">
<X className="w-3.5 h-3.5" />
</button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-green-600 hover:bg-green-500 text-heading text-[11px] font-bold rounded-lg transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
<div className={`bg-card border rounded-xl p-6 space-y-5 ${editing ? 'border-orange-500/30' : 'border-border'}`}>
{editing && (
<div className="flex items-center gap-2 px-3 py-2 bg-orange-500/10 border border-orange-500/20 rounded-lg text-[10px] text-orange-400">
<Edit3 className="w-3.5 h-3.5" />
</div>
)}
{/* 기본 정보 */}
<div className="border-l-2 border-blue-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
{([
{ icon: Calendar, label: '단속 일시', value: selected.date, key: 'date' },
{ icon: MapPin, label: '단속 장소', value: `${selected.zone} (${selected.lat}°N, ${selected.lon}°E)`, key: 'zone' },
{ icon: Building2, label: '담당 관서', value: selected.org, key: 'org' },
{ icon: User, label: '단속 요원', value: selected.officer, key: 'officer' },
] as const).map(f => (
<div key={f.key}>
<label className="text-[10px] text-hint font-medium mb-1 flex items-center gap-1"><f.icon className="w-3 h-3" />{f.label}</label>
{editing ? (
<input type="text" defaultValue={f.value} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-orange-500/60" />
) : (
<div className="px-3 py-2 bg-surface-overlay rounded-lg text-xs text-heading">{f.value}</div>
)}
</div>
))}
</div>
</div>
{/* 대상 정보 */}
<div className="border-l-2 border-orange-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-3 gap-4">
{([
{ icon: Ship, label: '선박명', value: selected.vessel },
{ icon: Ship, label: 'MMSI', value: selected.mmsi },
{ icon: Ship, label: '선적 국적', value: selected.flag },
{ icon: Anchor, label: '어업 종류', value: selected.fishType },
] as const).map(f => (
<div key={f.label}>
<label className="text-[10px] text-hint font-medium mb-1 flex items-center gap-1"><f.icon className="w-3 h-3" />{f.label}</label>
{editing ? (
<input type="text" defaultValue={f.value} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-orange-500/60" />
) : (
<div className="px-3 py-2 bg-surface-overlay rounded-lg text-xs text-heading">{f.value}</div>
)}
</div>
))}
</div>
</div>
{/* 위반 및 처리 */}
<div className="border-l-2 border-red-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 flex items-center gap-1"><AlertTriangle className="w-3 h-3" /> </label>
{editing ? (
<select defaultValue={selected.violationType} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-orange-500/60 appearance-none" title="위반 유형">
<option> </option><option> </option><option> </option><option>AIS </option><option> </option><option> </option>
</select>
) : (
<div className="px-3 py-2 bg-surface-overlay rounded-lg text-xs">
<span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">{selected.violationType}</span>
</div>
)}
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 flex items-center gap-1"><CheckCircle className="w-3 h-3" /> </label>
{editing ? (
<select defaultValue={selected.action} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-orange-500/60 appearance-none" title="조치 결과">
<option></option><option></option><option></option><option></option><option></option>
</select>
) : (
<div className="px-3 py-2 bg-surface-overlay rounded-lg text-xs text-heading">{selected.action}</div>
)}
</div>
<div className="col-span-2">
<label className="text-[10px] text-hint font-medium mb-1 flex items-center gap-1"><FileText className="w-3 h-3" /> </label>
{editing ? (
<textarea rows={3} defaultValue={selected.violationDesc} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-orange-500/60 resize-none" />
) : (
<div className="px-3 py-2 bg-surface-overlay rounded-lg text-xs text-label leading-relaxed">{selected.violationDesc}</div>
)}
</div>
</div>
</div>
{/* AI 매칭 + 상태 */}
<div className="border-l-2 border-green-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3">AI · </h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[9px] text-hint">AI </div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: `${selected.aiMatch}%` }} />
</div>
<span className={`text-sm font-bold ${selected.aiMatch >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{selected.aiMatch}%</span>
</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[9px] text-hint"> </div>
<div className="text-sm font-medium text-heading mt-1">{selected.result}</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[9px] text-hint"></div>
<div className="mt-1">
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${statusBadge(selected.status)}`}>{selected.status}</span>
</div>
</div>
</div>
</div>
</div>
{editing && (
<div className="text-[9px] text-hint">
(INSERT-ONLY ). .
</div>
)}
</div>
);
}
// ─── 사건 등록 ─────────────────────
return (
<div className="p-6 space-y-5">
<div className="flex items-center gap-3">
<button type="button" onClick={backToList} className="p-1.5 rounded-lg hover:bg-surface-overlay text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-lg font-bold text-heading flex items-center gap-2">
<Plus className="w-5 h-5 text-blue-400" />
{t('caseRegister.title')}
</h1>
<p className="text-[10px] text-hint mt-0.5">{t('caseRegister.desc')}</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
{/* 기본 정보 */}
<div className="border-l-2 border-blue-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
{/* 단속 일시 — 현재 시간 기본값 */}
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="datetime-local"
value={regDateTime}
onChange={e => setRegDateTime(e.target.value)}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60"
/>
</div>
<p className="text-[8px] text-hint mt-0.5 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
</p>
</div>
{/* 단속 장소 — GPS 자동 등록 */}
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text"
value={regLocation}
onChange={e => setRegLocation(e.target.value)}
placeholder="GPS 좌표 자동 입력 중..."
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-9 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60"
/>
<button
type="button"
onClick={fetchGPS}
disabled={gpsLoading}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-blue-500/10 text-hint hover:text-blue-400 transition-colors disabled:opacity-50"
title="현재 GPS 위치 다시 가져오기"
>
{gpsLoading
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Crosshair className="w-3.5 h-3.5" />
}
</button>
</div>
{gpsError ? (
<p className="text-[8px] text-red-400 mt-0.5">{gpsError}</p>
) : (
<p className="text-[8px] text-hint mt-0.5 flex items-center gap-1">
<Crosshair className="w-2.5 h-2.5" /> GPS {gpsLoading && '(위치 확인 중...)'}
</p>
)}
</div>
{/* 담당 관서 — 로그인 정보 기준 자동 입력 (읽기 전용) */}
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-400" />
<input
type="text"
value={user?.org || '해양경찰청'}
readOnly
className="w-full bg-blue-500/5 border border-blue-500/20 rounded-lg pl-9 pr-3 py-2 text-xs text-heading cursor-not-allowed"
/>
</div>
<p className="text-[8px] text-blue-400/70 mt-0.5 flex items-center gap-1">
<User className="w-2.5 h-2.5" />
</p>
</div>
{/* 단속 요원 — 로그인 정보 기준 자동 입력 (읽기 전용) */}
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-400" />
<input
type="text"
value={user ? `${user.name} (${user.account})` : ''}
readOnly
className="w-full bg-blue-500/5 border border-blue-500/20 rounded-lg pl-9 pr-3 py-2 text-xs text-heading cursor-not-allowed"
/>
</div>
<p className="text-[8px] text-blue-400/70 mt-0.5 flex items-center gap-1">
<User className="w-2.5 h-2.5" />
</p>
</div>
</div>
</div>
{/* 대상 정보 — AI 탐지 불러오기 */}
<div className="border-l-2 border-orange-500 pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[11px] font-bold text-heading"> </h3>
{!aiDetection && (
<button
type="button"
onClick={() => setShowAiLookup(true)}
className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-heading text-[10px] font-bold rounded-lg transition-colors"
>
<Radar className="w-3.5 h-3.5" /> AI
</button>
)}
{aiDetection && (
<button
type="button"
onClick={() => { setAiDetection(null); setShowAiLookup(false); }}
className="flex items-center gap-1 px-3 py-1.5 border border-border text-hint text-[10px] rounded-lg hover:bg-surface-overlay transition-colors"
>
<X className="w-3 h-3" /> AI
</button>
)}
</div>
{/* AI 탐지 선택 팝업 */}
{showAiLookup && !aiDetection && (
<div className="mb-4 bg-purple-500/5 border border-purple-500/20 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-[11px] text-purple-400 font-bold">
<Radar className="w-4 h-4" /> AI
</div>
<div className="space-y-2">
{AI_DETECTIONS.map(d => (
<button
type="button"
key={d.detectionId}
onClick={() => { setAiDetection(d); setShowAiLookup(false); }}
className="w-full flex items-center justify-between px-3 py-2.5 bg-card border border-border rounded-lg hover:border-purple-500/40 hover:bg-purple-500/5 transition-colors text-left"
>
<div className="flex items-center gap-3">
<span className="text-purple-400 font-mono text-[10px]">{d.detectionId}</span>
<span className="text-heading font-medium text-xs">{d.vesselName}</span>
<span className="text-hint text-[10px]">({d.mmsi})</span>
<span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{d.suspicionType}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${d.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{d.confidence}%</span>
<span className="text-hint text-[10px]">{d.detectedAt}</span>
</div>
</button>
))}
</div>
<button type="button" onClick={() => setShowAiLookup(false)} className="text-[10px] text-hint hover:text-label transition-colors">
AI
</button>
</div>
)}
{/* AI 연계된 경우: 자동 채워진 읽기 전용 정보 */}
{aiDetection ? (
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500/10 border border-purple-500/20 rounded-lg text-[10px] text-purple-400">
<Radar className="w-3.5 h-3.5" />
AI {aiDetection.detectionId} | {aiDetection.confidence}%
</div>
<div className="grid grid-cols-2 gap-3">
{([
{ label: '선박명', value: aiDetection.vesselName },
{ label: 'MMSI', value: aiDetection.mmsi },
{ label: '선적 국적', value: aiDetection.flag },
{ label: '어업 종류', value: aiDetection.fishType },
{ label: '탐지 위치', value: aiDetection.position },
{ label: '탐지 시각', value: aiDetection.detectedAt },
]).map(f => (
<div key={f.label}>
<label className="text-[9px] text-hint mb-0.5 block">{f.label}</label>
<div className="px-3 py-1.5 bg-purple-500/5 border border-purple-500/10 rounded-lg text-xs text-heading">{f.value}</div>
</div>
))}
</div>
</div>
) : !showAiLookup && (
/* AI 연계 없이 수동 입력 */
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> MMSI <span className="text-red-400">*</span></label>
<div className="relative">
<Ship className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" placeholder="123456789" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"></label>
<input type="text" placeholder="선박 DB 자동 연결" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<input type="text" placeholder="ISO 국가코드 (예: CHN, KOR)" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Anchor className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none" title="어업 종류">
<option value=""> </option>
<option></option><option></option><option></option><option></option>
</select>
</div>
</div>
</div>
)}
</div>
{/* 위반 및 처리 — AI 연계 시 자동 채움, 아니면 수동 */}
<div className="border-l-2 border-red-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
{aiDetection ? (
/* AI 연계: 자동 채워진 위반 정보 (읽기 전용) + 조치 결과만 선택 */
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[9px] text-hint mb-0.5 block"> (AI)</label>
<div className="px-3 py-1.5 bg-red-500/5 border border-red-500/10 rounded-lg text-xs">
<span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">{aiDetection.suspicionType}</span>
</div>
</div>
<div>
<label className="text-[9px] text-hint mb-0.5 block">AI </label>
<div className="px-3 py-1.5 bg-red-500/5 border border-red-500/10 rounded-lg text-xs text-label">{aiDetection.analysisDetail}</div>
</div>
</div>
<div>
<label className="text-[9px] text-hint mb-0.5 block">AI </label>
<div className="px-3 py-2 bg-red-500/5 border border-red-500/10 rounded-lg text-xs text-label leading-relaxed">{aiDetection.violationDesc}</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none" title="조치 결과">
<option value=""> </option>
<option></option><option></option><option></option><option></option><option></option>
</select>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<input type="text" placeholder="현장에서 확인한 추가 사항" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
</div>
) : (
/* 수동 입력 */
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none" title="위반 유형">
<option value=""> </option>
<option> </option><option> </option><option> </option><option>AIS </option><option> </option><option> </option>
</select>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none" title="조치 결과">
<option value=""> </option>
<option></option><option></option><option></option><option></option><option></option>
</select>
</div>
<div className="col-span-2">
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<textarea rows={3} placeholder="위반 행위 상세 기술" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 resize-none" />
</div>
</div>
)}
</div>
{/* 사진 및 증적 첨부 */}
<div className="border-l-2 border-green-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-3 gap-3">
{/* 사진 */}
<div className="border border-dashed border-slate-600 rounded-xl p-4 flex flex-col items-center justify-center gap-2 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer min-h-[100px]">
<Upload className="w-5 h-5 text-hint" />
<span className="text-[10px] text-hint font-medium"> </span>
<span className="text-[8px] text-hint">JPG, PNG ( 10MB)</span>
</div>
{/* 영상 */}
<div className="border border-dashed border-slate-600 rounded-xl p-4 flex flex-col items-center justify-center gap-2 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer min-h-[100px]">
<Upload className="w-5 h-5 text-hint" />
<span className="text-[10px] text-hint font-medium"> </span>
<span className="text-[8px] text-hint">MP4, MOV ( 100MB)</span>
</div>
{/* 조서 */}
<div className="border border-dashed border-slate-600 rounded-xl p-4 flex flex-col items-center justify-center gap-2 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer min-h-[100px]">
<Upload className="w-5 h-5 text-hint" />
<span className="text-[10px] text-hint font-medium"> </span>
<span className="text-[8px] text-hint">PDF, HWP ( 20MB)</span>
</div>
</div>
{aiDetection && (
<p className="text-[9px] text-purple-400/70 mt-2 flex items-center gap-1">
<Radar className="w-2.5 h-2.5" /> AI · . .
</p>
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-border">
<span className="text-[9px] text-hint"><span className="text-red-400">*</span> | </span>
<div className="flex gap-2">
<button type="button" onClick={backToList} className="px-4 py-2 text-xs text-hint border border-border rounded-lg hover:bg-surface-overlay transition-colors"></button>
<button type="button" className="px-4 py-2 text-xs text-heading bg-blue-600 hover:bg-blue-500 rounded-lg font-bold transition-colors"></button>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,169 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Ship, MapPin, Calendar, FileText, Upload, User, Anchor, Building2 } from 'lucide-react';
/**
* SFR11-01: 단속
* PPT : (·· /· · )
* - 항목: 단속일시, (PostGIS), MMSI, , , ,
* - 첨부파일: 사진··
* - SFR-15
*/
export function CaseRegister() {
const { t } = useTranslation('enforcement');
const [files, setFiles] = useState<string[]>([]);
const handleFileAdd = () => {
setFiles(prev => [...prev, `단속현장${prev.length + 1}.jpg`]);
};
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('caseRegister.title')}</h1>
<p className="text-xs text-hint mt-1">{t('caseRegister.desc')}</p>
</div>
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-heading flex items-center gap-2">
<Plus className="w-4 h-4 text-blue-400" />
</h2>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-[10px] text-heading bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors"></button>
<button className="px-3 py-1.5 text-[10px] text-heading bg-green-600 hover:bg-green-500 rounded-lg font-medium transition-colors"></button>
<button className="px-3 py-1.5 text-[10px] text-hint border border-border rounded-lg hover:bg-surface-overlay transition-colors"></button>
</div>
</div>
{/* 기본 정보 */}
<div className="border-l-2 border-blue-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="datetime-local" defaultValue="2026-04-07T09:12" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" defaultValue="서해 36.8°N 125.9°E" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none">
<option value=""> </option>
<option></option>
<option></option>
<option></option>
<option></option>
</select>
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" placeholder="단속 요원 ID" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
</div>
</div>
{/* 대상 정보 */}
<div className="border-l-2 border-orange-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> MMSI <span className="text-red-400">*</span></label>
<div className="relative">
<Ship className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" placeholder="123456789" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"></label>
<input type="text" placeholder="선박 DB 자동 연결" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<input type="text" placeholder="ISO 국가코드 (예: CHN, KOR)" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<Anchor className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none">
<option value=""> </option>
<option></option><option></option><option></option><option></option>
</select>
</div>
</div>
<div className="col-span-2">
<label className="text-[10px] text-hint font-medium mb-1 block"> AIS ID (SFR-10 )</label>
<input type="text" placeholder="어구 AIS Class-B ID" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
</div>
{/* 위반 및 처리 */}
<div className="border-l-2 border-red-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"> </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none">
<option value=""> </option>
<option> </option><option> </option><option> </option><option>AIS </option><option> </option>
</select>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none">
<option value=""> </option>
<option></option><option></option><option></option><option></option>
</select>
</div>
<div className="col-span-2">
<label className="text-[10px] text-hint font-medium mb-1 block"> <span className="text-red-400">*</span></label>
<div className="relative">
<FileText className="absolute left-3 top-3 w-3.5 h-3.5 text-hint" />
<textarea rows={3} placeholder="위반 행위 상세 기술" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 resize-none" />
</div>
</div>
<div>
<label className="text-[10px] text-hint font-medium mb-1 block"> </label>
<input type="datetime-local" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60" />
</div>
</div>
</div>
{/* 첨부파일 */}
<div className="border-l-2 border-green-500 pl-4">
<h3 className="text-[11px] font-bold text-heading mb-3"></h3>
<div className="flex items-center gap-2 flex-wrap">
{files.map((f, i) => (
<span key={i} className="px-2 py-1 bg-surface-overlay border border-border rounded text-[10px] text-label">{f}</span>
))}
<button onClick={handleFileAdd} className="flex items-center gap-1 px-2.5 py-1 border border-dashed border-slate-600 rounded-lg text-[10px] text-hint hover:text-blue-400 hover:border-blue-500/50 transition-colors">
<Upload className="w-3 h-3" />
</button>
</div>
<p className="text-[9px] text-hint mt-1">·· | SFR-15 </p>
</div>
<div className="text-[9px] text-hint pt-2 border-t border-border">
<span className="text-red-400">*</span> | ( ) | INSERT-ONLY
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next';
import { History, User, FileText } from 'lucide-react';
/** SFR11-11: 변경 이력 조회 */
export function ChangeHistory() {
const { t } = useTranslation('enforcement');
const logs = [
{ id: 1, time: '2026-04-01 14:35:12', user: '김영수', action: '수정', target: 'ENF-20260401-0012', field: '조치', before: '경고', after: '나포', reason: '현장 상황 변경' },
{ id: 2, time: '2026-04-01 13:20:45', user: '박현우', action: '등록', target: 'ENF-20260401-0012', field: '-', before: '-', after: '신규 등록', reason: '현장 단속' },
{ id: 3, time: '2026-03-31 17:10:33', user: '이지현', action: '수정', target: 'ENF-20260331-0045', field: '위반유형', before: '미신고', after: '불법어구', reason: '증거 확인 후 변경' },
{ id: 4, time: '2026-03-31 09:22:18', user: '김영수', action: '삭제', target: 'ENF-20260331-0044', field: '-', before: '전체', after: '-', reason: '오입력 삭제' },
{ id: 5, time: '2026-03-30 16:45:55', user: '최민수', action: '수정', target: 'ENF-20260330-0033', field: '결과', before: '처리중', after: '완료', reason: '과태료 납부 확인' },
];
const actionColor = (a: string) =>
a === '등록' ? 'bg-green-500/10 text-green-400' :
a === '수정' ? 'bg-blue-500/10 text-blue-400' :
'bg-red-500/10 text-red-400';
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('changeHistory.title')}</h1>
<p className="text-xs text-hint mt-1">{t('changeHistory.desc')}</p>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['시각', '작업자', '구분', '대상', '변경 필드', '변경 전', '변경 후', '사유'].map((h) => (
<th key={h} className="text-left px-4 py-2.5 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-2.5 text-hint text-[10px] font-mono">{log.time}</td>
<td className="px-4 py-2.5 text-label flex items-center gap-1"><User className="w-3 h-3 text-hint" />{log.user}</td>
<td className="px-4 py-2.5"><span className={`px-1.5 py-0.5 rounded text-[10px] ${actionColor(log.action)}`}>{log.action}</span></td>
<td className="px-4 py-2.5 text-blue-400 font-mono text-[10px]">{log.target}</td>
<td className="px-4 py-2.5 text-label">{log.field}</td>
<td className="px-4 py-2.5 text-red-400/70 line-through">{log.before}</td>
<td className="px-4 py-2.5 text-green-400">{log.after}</td>
<td className="px-4 py-2.5 text-hint">{log.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,88 @@
import { useTranslation } from 'react-i18next';
import { Search, Filter, Calendar, MapPin, Ship } from 'lucide-react';
/** SFR11-07: 다차원 이력 검색 */
export function HistorySearch() {
const { t } = useTranslation('enforcement');
const results = [
{ id: 'ENF-20260401-0012', date: '2026-04-01', zone: '서해 NLL', vessel: '금성호', type: '불법조업', action: '나포', aiMatch: '92%', result: '과태료' },
{ id: 'ENF-20260331-0045', date: '2026-03-31', zone: '남해', vessel: '대한호', type: '불법어구', action: '경고', aiMatch: '78%', result: '처리중' },
{ id: 'ENF-20260330-0033', date: '2026-03-30', zone: '동해 EEZ', vessel: 'LIAO DONG 77', type: '수역침범', action: '퇴거', aiMatch: '95%', result: '완료' },
{ id: 'ENF-20260328-0021', date: '2026-03-28', zone: '서해 NLL', vessel: 'MIN RONG 8', type: '불법조업', action: '나포', aiMatch: '88%', result: '과태료' },
{ id: 'ENF-20260325-0015', date: '2026-03-25', zone: '제주', vessel: '신양호', type: '미신고', action: '경고', aiMatch: '71%', result: '완료' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('historySearch.title')}</h1>
<p className="text-xs text-hint mt-1">{t('historySearch.desc')}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-blue-400" />
<span className="text-xs font-bold text-heading"> </span>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-[9px] text-hint mb-1 block"></label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint" />
<input type="date" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-8 pr-2 py-1.5 text-[10px] text-heading focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div>
<label className="text-[9px] text-hint mb-1 block"></label>
<div className="relative">
<MapPin className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint" />
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-8 pr-2 py-1.5 text-[10px] text-heading focus:outline-none focus:border-blue-500/60 appearance-none">
<option value=""></option>
<option> NLL</option><option> EEZ</option><option></option><option></option>
</select>
</div>
</div>
<div>
<label className="text-[9px] text-hint mb-1 block">/MMSI</label>
<div className="relative">
<Ship className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint" />
<input type="text" placeholder="검색" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-8 pr-2 py-1.5 text-[10px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
</div>
<div className="flex items-end">
<button className="w-full flex items-center justify-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-heading font-medium transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
</div>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['사건번호', '일자', '해역', '선박', '유형', '조치', 'AI매칭', '결과'].map((h) => (
<th key={h} className="text-left px-4 py-2.5 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-2.5 text-blue-400 font-mono text-[10px]">{r.id}</td>
<td className="px-4 py-2.5 text-label">{r.date}</td>
<td className="px-4 py-2.5 text-label">{r.zone}</td>
<td className="px-4 py-2.5 text-heading">{r.vessel}</td>
<td className="px-4 py-2.5"><span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{r.type}</span></td>
<td className="px-4 py-2.5 text-label">{r.action}</td>
<td className="px-4 py-2.5 text-green-400">{r.aiMatch}</td>
<td className="px-4 py-2.5 text-label">{r.result}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next';
import { Tag, Clock, User, CheckCircle } from 'lucide-react';
/** SFR11-06: 라벨링 작업 목록 */
export function LabelList() {
const { t } = useTranslation('enforcement');
const tasks = [
{ id: 'LBL-001', type: '불법조업', assigned: '김영수', created: '2026-04-01', deadline: '2026-04-03', total: 120, done: 85, status: '진행중' },
{ id: 'LBL-002', type: '다크베셀', assigned: '이지현', created: '2026-03-30', deadline: '2026-04-02', total: 80, done: 80, status: '완료' },
{ id: 'LBL-003', type: '어구위반', assigned: '박현우', created: '2026-04-01', deadline: '2026-04-05', total: 200, done: 42, status: '진행중' },
{ id: 'LBL-004', type: '환적의심', assigned: '최민수', created: '2026-03-28', deadline: '2026-04-01', total: 60, done: 60, status: '완료' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('labelList.title')}</h1>
<p className="text-xs text-hint mt-1">{t('labelList.desc')}</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-[10px] text-hint"> </div>
<div className="text-xl font-bold text-heading mt-1">4</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-[10px] text-hint"></div>
<div className="text-xl font-bold text-yellow-400 mt-1">2</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-[10px] text-hint"></div>
<div className="text-xl font-bold text-green-400 mt-1">2</div>
</div>
</div>
<div className="space-y-3">
{tasks.map((task) => (
<div key={task.id} className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-blue-400" />
<span className="text-sm font-bold text-heading">{task.id}</span>
<span className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 text-[10px]">{task.type}</span>
</div>
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${task.status === '완료' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
{task.status === '완료' && <CheckCircle className="w-3 h-3 inline mr-0.5" />}
{task.status}
</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-hint mb-2">
<span className="flex items-center gap-1"><User className="w-3 h-3" />{task.assigned}</span>
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{task.created} ~ {task.deadline}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-background rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${(task.done / task.total) * 100}%` }} />
</div>
<span className="text-[10px] text-label font-medium">{task.done}/{task.total}</span>
</div>
</div>
))}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,122 @@
import { useTranslation } from 'react-i18next';
import { Activity, CheckCircle, XCircle, Clock, TrendingUp, AlertTriangle } from 'lucide-react';
/**
* SFR11-04: 매칭
* PPT : AI vs
* - TP/FP/FN + HitL + SFR-18 MLOps
* - : |t_detect t_enforce| 24h AND |d(detect_pos, enforce_pos)| 5km
*/
export function MatchDashboard() {
const { t } = useTranslation('enforcement');
const kpis = [
{ icon: Activity, label: '오늘 탐지', value: '47', color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ icon: CheckCircle, label: '매칭 완료', value: '32', color: 'text-green-400', bg: 'bg-green-500/10' },
{ icon: Clock, label: '미매칭', value: '15', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ icon: TrendingUp, label: '매칭 정확도', value: '≥95%', color: 'text-purple-400', bg: 'bg-purple-500/10' },
];
const results = [
{ time: '09:28', type: 'Dark Vessel', pos: '36.8°N 125.9°E', match: 'TP', label: '불법', detail: '나포 확인' },
{ time: '09:15', type: 'AIS 조작', pos: '37.1°N 126.1°E', match: 'FP', label: '정상', detail: '정상 확인' },
{ time: '08:52', type: '공조 조업', pos: '36.5°N 125.7°E', match: 'TP', label: '불법', detail: '나포 확인' },
{ time: '08:30', type: '신호 차단', pos: '37.3°N 126.3°E', match: '-', label: '대기', detail: '검토중' },
{ time: '08:10', type: '어구 위반', pos: '36.2°N 125.5°E', match: 'FN', label: '불법', detail: 'AI 미탐지 건' },
];
const matchColor = (m: string) =>
m === 'TP' ? 'bg-green-500/10 text-green-400' :
m === 'FP' ? 'bg-red-500/10 text-red-400' :
m === 'FN' ? 'bg-orange-500/10 text-orange-400' :
'bg-slate-500/10 text-hint';
const labelIcon = (l: string) =>
l === '불법' ? <span className="text-green-400"> 🟢</span> :
l === '정상' ? <span className="text-red-400"> 🔴</span> :
<span className="text-yellow-400"> </span>;
// HitL 환류 3개월 효과
const hitlStats = [
{ metric: '탐지 정확도', before: '85%', after: '92%', change: '+7%', source: '제주대 2024' },
{ metric: '오탐율 (FPR)', before: '12%', after: '6%', change: '-50%', source: '부경대 2022' },
{ metric: '미탐율 (FNR)', before: '18%', after: '8%', change: '-55%', source: 'KIOST 2023' },
{ metric: 'F1-Score', before: '0.81', after: '0.91', change: '+10%', source: '종합 평균' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('matchDashboard.title')}</h1>
<p className="text-xs text-hint mt-1">{t('matchDashboard.desc')}</p>
</div>
{/* KPI */}
<div className="grid grid-cols-4 gap-3">
{kpis.map(({ icon: Icon, label, value, color, bg }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<div className={`w-8 h-8 rounded-lg ${bg} flex items-center justify-center mb-2`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="text-[10px] text-hint">{label}</div>
<div className={`text-xl font-bold ${color} mt-1`}>{value}</div>
</div>
))}
</div>
{/* 매칭 결과 테이블 */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<h3 className="text-xs font-bold text-heading">AI </h3>
<span className="text-[9px] text-hint"> : |Δt| 24h, |Δd| 5km</span>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['탐지 일시', '의심 유형', '탐지 위치', '매칭 결과', '라벨', '상세'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-2.5 text-label">{r.time}</td>
<td className="px-4 py-2.5 text-heading">{r.type}</td>
<td className="px-4 py-2.5 text-label font-mono text-[10px]">{r.pos}</td>
<td className="px-4 py-2.5"><span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${matchColor(r.match)}`}>{r.match || '미매칭'}</span></td>
<td className="px-4 py-2.5">{labelIcon(r.label)}</td>
<td className="px-4 py-2.5 text-hint">{r.detail}</td>
</tr>
))}
</tbody>
</table>
<div className="px-4 py-2 text-[9px] text-hint border-t border-border">
: 관제요원 24 | SFR-12
</div>
</div>
{/* HitL 환류 효과 */}
<div className="bg-card border border-border rounded-xl p-4">
<h3 className="text-xs font-bold text-heading flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-green-400" />
HitL 3
</h3>
<div className="grid grid-cols-4 gap-3">
{hitlStats.map((s) => (
<div key={s.metric} className="bg-surface-overlay rounded-lg p-3 text-center">
<div className="text-[9px] text-hint">{s.metric}</div>
<div className="flex items-center justify-center gap-1 mt-1">
<span className="text-[10px] text-hint">{s.before}</span>
<span className="text-[10px] text-hint"></span>
<span className="text-[10px] text-heading font-bold">{s.after}</span>
</div>
<div className={`text-sm font-bold mt-1 ${s.change.startsWith('+') ? 'text-green-400' : 'text-blue-400'}`}>{s.change}</div>
<div className="text-[8px] text-hint mt-0.5">{s.source}</div>
</div>
))}
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next';
import { CheckCircle, XCircle, Eye } from 'lucide-react';
/** SFR11-05: 매칭 결과 등록·검증 */
export function MatchVerify() {
const { t } = useTranslation('enforcement');
const pending = [
{ id: 'M-0046', vessel: 'LIAO DONG 77', mmsi: '412345678', type: '수역침범', confidence: 87, zone: '서해 NLL', time: '2026-04-01 14:15' },
{ id: 'M-0043', vessel: 'YUE DONG 12', mmsi: '412876543', type: '수역침범', confidence: 65, zone: '동해 EEZ', time: '2026-04-01 13:20' },
{ id: 'M-0040', vessel: '신양호', mmsi: '440567890', type: '불법어구', confidence: 78, zone: '남해', time: '2026-04-01 12:45' },
{ id: 'M-0038', vessel: 'MIN RONG 8', mmsi: '412234567', type: '불법조업', confidence: 82, zone: '서해 NLL', time: '2026-04-01 11:30' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('matchVerify.title')}</h1>
<p className="text-xs text-hint mt-1">{t('matchVerify.desc')}</p>
</div>
<div className="space-y-3">
{pending.map((p) => (
<div key={p.id} className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-blue-400 font-mono text-xs">{p.id}</span>
<span className="text-heading font-medium text-sm">{p.vessel}</span>
<span className="text-hint text-[10px]">({p.mmsi})</span>
<span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{p.type}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${p.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{p.confidence}%</span>
<button className="p-1.5 rounded-lg hover:bg-surface-overlay text-hint hover:text-blue-400 transition-colors" title="상세보기">
<Eye className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-green-500/10 text-hint hover:text-green-400 transition-colors" title="확정">
<CheckCircle className="w-4 h-4" />
</button>
<button className="p-1.5 rounded-lg hover:bg-red-500/10 text-hint hover:text-red-400 transition-colors" title="오탐 처리">
<XCircle className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex items-center gap-4 mt-2 text-[10px] text-hint">
<span>{p.zone}</span>
<span>{p.time}</span>
</div>
</div>
))}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next';
import { FileText, Download, Calendar, Settings } from 'lucide-react';
/** SFR11-10: 보고서 자동 생성 */
export function ReportGen() {
const { t } = useTranslation('enforcement');
const templates = [
{ id: 'RPT-DAILY', name: '일일 단속 보고서', desc: '금일 단속 현황, AI 매칭 결과, 처리 현황 요약', lastGen: '2026-04-01 18:00', format: 'PDF' },
{ id: 'RPT-WEEKLY', name: '주간 단속 통계', desc: '주간 단속 건수, 유형별 분포, 해역별 현황', lastGen: '2026-03-31 09:00', format: 'Excel' },
{ id: 'RPT-MONTHLY', name: '월간 성과 분석', desc: '월간 단속 성과, 전월 대비, AI 정확도 추이', lastGen: '2026-03-01 10:00', format: 'PDF' },
{ id: 'RPT-VESSEL', name: '선박별 이력 보고서', desc: '특정 선박의 단속 이력, 위험도 변화, 위반 패턴', lastGen: '2026-04-01 15:30', format: 'PDF' },
{ id: 'RPT-CUSTOM', name: '맞춤 보고서', desc: '사용자 정의 필터 기반 보고서 생성', lastGen: '-', format: 'PDF/Excel' },
];
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('reportGen.title')}</h1>
<p className="text-xs text-hint mt-1">{t('reportGen.desc')}</p>
</div>
<div className="grid grid-cols-1 gap-3">
{templates.map((tpl) => (
<div key={tpl.id} className="bg-card border border-border rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-blue-400" />
</div>
<div>
<div className="text-sm font-bold text-heading">{tpl.name}</div>
<div className="text-[10px] text-hint mt-0.5">{tpl.desc}</div>
<div className="flex items-center gap-3 mt-1 text-[9px] text-hint">
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" />: {tpl.lastGen}</span>
<span className="px-1.5 py-0.5 bg-surface-overlay rounded">{tpl.format}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button className="p-2 rounded-lg hover:bg-surface-overlay text-hint hover:text-label transition-colors" title="설정">
<Settings className="w-4 h-4" />
</button>
<button className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-xs text-heading font-medium transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,256 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart3, TrendingUp, PieChart, Filter, Anchor, MapPin, Calendar, AlertTriangle } from 'lucide-react';
import { LineChart } from '@lib/charts/presets/LineChart';
import { BarChart } from '@lib/charts/presets/BarChart';
/**
* SFR11-09: 유형별 +
* PPT : ··· +
*/
interface Record {
id: string; date: string; mmsi: string; vessel: string;
fishType: string; zone: string; season: string;
violationType: string; action: string; aiMatch: string;
}
const ALL_RECORDS: Record[] = [
{ id: 'ENF-0401', date: '2026-04-07', mmsi: '123456789', vessel: '금성호', fishType: '자망', zone: '서해', season: '봄', violationType: '금어기 조업', action: '나포', aiMatch: 'TP' },
{ id: 'ENF-0402', date: '2026-04-05', mmsi: '987654321', vessel: 'LIAO DONG 77', fishType: '통발', zone: '제주', season: '봄', violationType: '무허가 조업', action: '경고', aiMatch: 'TP' },
{ id: 'ENF-0403', date: '2026-04-03', mmsi: '456789123', vessel: 'MIN RONG 8', fishType: '연승', zone: '동해', season: '봄', violationType: '협정선 위반', action: '나포', aiMatch: 'FP' },
{ id: 'ENF-0404', date: '2026-03-28', mmsi: '111222333', vessel: '대한호', fishType: '자망', zone: '서해', season: '봄', violationType: 'AIS 조작', action: '수사의뢰', aiMatch: 'TP' },
{ id: 'ENF-0405', date: '2026-03-25', mmsi: '444555666', vessel: '신양호', fishType: '저인망', zone: '서해', season: '봄', violationType: '금어기 조업', action: '방면', aiMatch: 'FN' },
{ id: 'ENF-0406', date: '2026-02-15', mmsi: '412876543', vessel: 'YUE DONG 12', fishType: '통발', zone: '서해 5도', season: '겨울', violationType: '무허가 조업', action: '나포', aiMatch: 'TP' },
{ id: 'ENF-0407', date: '2026-02-10', mmsi: '440567890', vessel: '해왕호', fishType: '자망', zone: '남해', season: '겨울', violationType: '불법어구 사용', action: '경고', aiMatch: 'TP' },
{ id: 'ENF-0408', date: '2026-01-20', mmsi: '412234567', vessel: '冀黄港渔05001', fishType: '연승', zone: '동해', season: '겨울', violationType: '협정선 위반', action: '퇴거', aiMatch: 'TP' },
{ id: 'ENF-0409', date: '2026-01-12', mmsi: '440123456', vessel: '鲁荣渔56555', fishType: '저인망', zone: '서해', season: '겨울', violationType: '금어기 조업', action: '나포', aiMatch: 'FP' },
{ id: 'ENF-0410', date: '2025-12-05', mmsi: '412999888', vessel: '미상선박-A', fishType: '통발', zone: '제주', season: '겨울', violationType: 'AIS 조작', action: '수사의뢰', aiMatch: 'TP' },
];
const FISH_TYPES = ['전체', '자망', '통발', '연승', '저인망'];
const ZONES = ['전체', '서해', '서해 5도', '동해', '남해', '제주'];
const SEASONS = ['전체', '봄', '여름', '가을', '겨울'];
const VIOLATIONS = ['전체', '금어기 조업', '협정선 위반', '무허가 조업', 'AIS 조작', '불법어구 사용'];
export function TypeStats() {
const { t } = useTranslation('enforcement');
const [fishType, setFishType] = useState('전체');
const [zone, setZone] = useState('전체');
const [season, setSeason] = useState('전체');
const [violation, setViolation] = useState('전체');
const filtered = useMemo(() =>
ALL_RECORDS.filter(r =>
(fishType === '전체' || r.fishType === fishType) &&
(zone === '전체' || r.zone === zone) &&
(season === '전체' || r.season === season) &&
(violation === '전체' || r.violationType === violation)
), [fishType, zone, season, violation]);
// 위반 유형별 집계
const violationStats = useMemo(() => {
const map: { [key: string]: number } = {};
filtered.forEach(r => { map[r.violationType] = (map[r.violationType] || 0) + 1; });
const total = filtered.length || 1;
return Object.entries(map)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => ({ type, count, pct: Math.round((count / total) * 1000) / 10 }));
}, [filtered]);
// 해역별 집계 (바 차트용)
const zoneStats = useMemo(() => {
const map: { [key: string]: number } = {};
filtered.forEach(r => { map[r.zone] = (map[r.zone] || 0) + 1; });
return Object.entries(map).map(([z, count]) => ({ zone: z, count })).sort((a, b) => b.count - a.count);
}, [filtered]);
// 월별 추이 (꺾은선)
const monthlyTrend = useMemo(() => {
const map: { [key: string]: number } = {};
filtered.forEach(r => {
const m = r.date.slice(0, 7); // YYYY-MM
map[m] = (map[m] || 0) + 1;
});
return Object.entries(map).sort().map(([month, count]) => ({ month: month.slice(5) + '월', count }));
}, [filtered]);
// 어업종별 집계 (바 차트용)
const fishStats = useMemo(() => {
const map: { [key: string]: number } = {};
filtered.forEach(r => { map[r.fishType] = (map[r.fishType] || 0) + 1; });
return Object.entries(map).map(([f, count]) => ({ fishType: f, count })).sort((a, b) => b.count - a.count);
}, [filtered]);
const violationColors = ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#a855f7'];
const filterCls = "bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[10px] text-heading focus:outline-none focus:border-blue-500/60 appearance-none";
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-lg font-bold text-heading">{t('typeStats.title')}</h1>
<p className="text-xs text-hint mt-1">{t('typeStats.desc')}</p>
</div>
{/* ── 다차원 필터 ── */}
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto"> : <span className="text-heading font-bold">{filtered.length}</span></span>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-[9px] text-hint mb-1 flex items-center gap-1"><Anchor className="w-2.5 h-2.5" /></label>
<select value={fishType} onChange={e => setFishType(e.target.value)} className={filterCls} title="어업종">
{FISH_TYPES.map(f => <option key={f}>{f}</option>)}
</select>
</div>
<div>
<label className="text-[9px] text-hint mb-1 flex items-center gap-1"><MapPin className="w-2.5 h-2.5" /></label>
<select value={zone} onChange={e => setZone(e.target.value)} className={filterCls} title="해역">
{ZONES.map(z => <option key={z}>{z}</option>)}
</select>
</div>
<div>
<label className="text-[9px] text-hint mb-1 flex items-center gap-1"><Calendar className="w-2.5 h-2.5" /></label>
<select value={season} onChange={e => setSeason(e.target.value)} className={filterCls} title="계절">
{SEASONS.map(s => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="text-[9px] text-hint mb-1 flex items-center gap-1"><AlertTriangle className="w-2.5 h-2.5" /> </label>
<select value={violation} onChange={e => setViolation(e.target.value)} className={filterCls} title="위반 유형">
{VIOLATIONS.map(v => <option key={v}>{v}</option>)}
</select>
</div>
</div>
</div>
{/* ── 요약 KPI ── */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: '필터 결과', value: `${filtered.length}`, icon: BarChart3, color: 'text-blue-400' },
{ label: 'TP (탐지 성공)', value: `${filtered.filter(r => r.aiMatch === 'TP').length}`, icon: TrendingUp, color: 'text-green-400' },
{ label: 'FP (오탐)', value: `${filtered.filter(r => r.aiMatch === 'FP').length}`, icon: PieChart, color: 'text-red-400' },
{ label: 'FN (미탐)', value: `${filtered.filter(r => r.aiMatch === 'FN').length}`, icon: AlertTriangle, color: 'text-yellow-400' },
].map(({ label, value, icon: Icon, color }) => (
<div key={label} className="bg-card border border-border rounded-xl p-3 flex items-center gap-3">
<Icon className={`w-5 h-5 ${color} shrink-0`} />
<div>
<div className={`text-lg font-bold ${color}`}>{value}</div>
<div className="text-[9px] text-hint">{label}</div>
</div>
</div>
))}
</div>
{/* ── 차트 2x2 ── */}
<div className="grid grid-cols-2 gap-4">
{/* 위반 유형별 분포 */}
<div className="bg-card border border-border rounded-xl p-5">
<h3 className="text-xs font-bold text-heading flex items-center gap-2 mb-4">
<PieChart className="w-4 h-4 text-blue-400" />
</h3>
{violationStats.length > 0 ? (
<div className="space-y-2.5">
{violationStats.map((s, i) => (
<div key={s.type}>
<div className="flex items-center justify-between text-[10px] mb-1">
<span className="text-label">{s.type}</span>
<span className="text-heading font-medium">{s.count} ({s.pct}%)</span>
</div>
<div className="h-2 bg-background rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${s.pct}%`, backgroundColor: violationColors[i % violationColors.length] }} />
</div>
</div>
))}
</div>
) : (
<div className="text-center text-hint text-xs py-8"> .</div>
)}
</div>
{/* 월별 추이 — 꺾은선 */}
<div className="bg-card border border-border rounded-xl p-5">
<h3 className="text-xs font-bold text-heading flex items-center gap-2 mb-4">
<TrendingUp className="w-4 h-4 text-green-400" />
</h3>
{monthlyTrend.length > 1 ? (
<LineChart data={monthlyTrend} xKey="month" series={[{ key: 'count', name: '단속 건수', color: '#3b82f6' }]} height={180} />
) : (
<div className="text-center text-hint text-xs py-8"> .</div>
)}
</div>
{/* 해역별 현황 — 바 차트 */}
<div className="bg-card border border-border rounded-xl p-5">
<h3 className="text-xs font-bold text-heading flex items-center gap-2 mb-4">
<MapPin className="w-4 h-4 text-orange-400" />
</h3>
{zoneStats.length > 0 ? (
<BarChart data={zoneStats} xKey="zone" series={[{ key: 'count', name: '단속 건수', color: '#f97316' }]} height={180} />
) : (
<div className="text-center text-hint text-xs py-8"> .</div>
)}
</div>
{/* 어업종별 현황 — 바 차트 */}
<div className="bg-card border border-border rounded-xl p-5">
<h3 className="text-xs font-bold text-heading flex items-center gap-2 mb-4">
<Anchor className="w-4 h-4 text-cyan-400" />
</h3>
{fishStats.length > 0 ? (
<BarChart data={fishStats} xKey="fishType" series={[{ key: 'count', name: '단속 건수', color: '#06b6d4' }]} height={180} />
) : (
<div className="text-center text-hint text-xs py-8"> .</div>
)}
</div>
</div>
{/* ── 필터된 이력 테이블 ── */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<h3 className="text-xs font-bold text-heading"> ({filtered.length})</h3>
<div className="flex gap-2">
<button type="button" className="px-2.5 py-1 rounded text-[9px] text-hint border border-border hover:bg-surface-overlay transition-colors"> </button>
<button type="button" className="px-2.5 py-1 rounded text-[9px] text-hint border border-border hover:bg-surface-overlay transition-colors">PDF </button>
</div>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['단속 일시', '선박 MMSI', '어업종', '해역', '위반 유형', '조치 결과', 'AI 매칭'].map(h => (
<th key={h} className="text-left px-4 py-2 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(r => (
<tr key={r.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-2 text-label">{r.date}</td>
<td className="px-4 py-2 text-heading font-mono text-[10px]">{r.mmsi}</td>
<td className="px-4 py-2 text-label">{r.fishType}</td>
<td className="px-4 py-2 text-label">{r.zone}</td>
<td className="px-4 py-2"><span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{r.violationType}</span></td>
<td className="px-4 py-2 text-label">{r.action}</td>
<td className="px-4 py-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${
r.aiMatch === 'TP' ? 'bg-green-500/10 text-green-400' :
r.aiMatch === 'FP' ? 'bg-red-500/10 text-red-400' :
'bg-orange-500/10 text-orange-400'
}`}>{r.aiMatch}</span>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-4 py-8 text-center text-hint"> .</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Ship, Search, AlertTriangle } from 'lucide-react';
/** SFR11-08: 선박별 이력 조회 */
export function VesselHistory() {
const { t } = useTranslation('enforcement');
const vessels = [
{ name: '금성호', mmsi: '440123456', flag: 'CN', totalEnf: 5, lastEnf: '2026-04-01', risk: '상', violations: ['불법조업(3)', '수역침범(2)'] },
{ name: 'LIAO DONG 77', mmsi: '412345678', flag: 'CN', totalEnf: 8, lastEnf: '2026-04-01', risk: '상', violations: ['수역침범(5)', '불법조업(3)'] },
{ name: '대한호', mmsi: '440987654', flag: 'KR', totalEnf: 2, lastEnf: '2026-03-31', risk: '중', violations: ['불법어구(2)'] },
{ name: 'MIN RONG 8', mmsi: '412234567', flag: 'CN', totalEnf: 12, lastEnf: '2026-03-28', risk: '상', violations: ['불법조업(8)', '수역침범(4)'] },
{ name: '신양호', mmsi: '440567890', flag: 'KR', totalEnf: 1, lastEnf: '2026-03-25', risk: '하', violations: ['미신고(1)'] },
];
const riskColor = (r: string) => r === '상' ? 'text-red-400 bg-red-500/10' : r === '중' ? 'text-yellow-400 bg-yellow-500/10' : 'text-green-400 bg-green-500/10';
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-lg font-bold text-heading">{t('vesselHistory.title')}</h1>
<p className="text-xs text-hint mt-1">{t('vesselHistory.desc')}</p>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input type="text" placeholder="선박명 또는 MMSI 검색" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-3 py-2 text-xs text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div className="space-y-3">
{vessels.map((v) => (
<div key={v.mmsi} className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ship className="w-4 h-4 text-blue-400" />
<span className="text-sm font-bold text-heading">{v.name}</span>
<span className="text-[10px] text-hint">({v.mmsi})</span>
<span className="text-[10px] text-hint px-1.5 py-0.5 bg-surface-overlay rounded">{v.flag}</span>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${riskColor(v.risk)}`}>
<AlertTriangle className="w-3 h-3 inline mr-0.5" /> {v.risk}
</span>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-3">
<div className="bg-surface-overlay rounded-lg p-2">
<div className="text-[9px] text-hint"> </div>
<div className="text-sm font-bold text-heading">{v.totalEnf}</div>
</div>
<div className="bg-surface-overlay rounded-lg p-2">
<div className="text-[9px] text-hint"> </div>
<div className="text-sm font-medium text-label">{v.lastEnf}</div>
</div>
<div className="bg-surface-overlay rounded-lg p-2">
<div className="text-[9px] text-hint"> </div>
<div className="text-[10px] text-label">{v.violations.join(', ')}</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

파일 보기

@ -1,2 +1,12 @@
export { EnforcementHistory } from './EnforcementHistory';
export { EventList } from './EventList';
// SFR-11 하위 화면
export { CaseList } from './CaseList';
export { MatchDashboard } from './MatchDashboard';
export { MatchVerify } from './MatchVerify';
export { LabelList } from './LabelList';
export { HistorySearch } from './HistorySearch';
export { VesselHistory } from './VesselHistory';
export { TypeStats } from './TypeStats';
export { ReportGen } from './ReportGen';
export { ChangeHistory } from './ChangeHistory';

파일 보기

@ -1,145 +1,472 @@
import { useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
import {
Smartphone, MapPin, Bell, Shield,
Plus, Ship, Calendar, Clock, Crosshair, Loader2, Upload,
Radar, ChevronLeft, CheckCircle, Navigation, AlertTriangle, Anchor,
Building2, User,
} from 'lucide-react';
import maplibregl from 'maplibre-gl';
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import { useEventStore } from '@stores/eventStore';
import { formatTime } from '@shared/utils/dateFormat';
import { TextLayer } from 'deck.gl';
import { useAuth } from '@/app/auth/AuthContext';
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
/*
* SFR-15: 단속요원
*
* :
* (AI + ) + 2
* 1
* 2 AI
*/
const PUSH_SETTINGS = [
{ name: 'EEZ 침범 경보', enabled: true }, { name: '다크베셀 탐지', enabled: true },
{ name: '불법환적 의심', enabled: true }, { name: '순찰경로 업데이트', enabled: false },
{ name: '기상 특보', enabled: true },
];
type MobileScreen = 'main' | 'register' | 'ai-detail';
// 모바일 지도에 표시할 마커
const MOBILE_MARKERS = [
{ lat: 37.20, lng: 124.63, name: '鲁荣渔56555', type: 'alert', color: '#ef4444' },
{ lat: 37.75, lng: 125.02, name: '미상선박-A', type: 'dark', color: '#f97316' },
{ lat: 36.80, lng: 124.37, name: '冀黄港渔05001', type: 'suspect', color: '#eab308' },
{ lat: 37.45, lng: 125.30, name: '3009함(아군)', type: 'patrol', color: '#a855f7' },
function nowLocalISO(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** AI 탐지 마커 데이터 */
const AI_MARKERS = [
// ── 불법선박 (제주 남방 해상) ──
{ id: 'V1', lat: 32.65, lng: 125.80, name: '鲁荣渔56555', type: '불법선박', risk: 'CRITICAL', confidence: 94, detail: 'EEZ 침범, AIS 6h 단절 후 재진입. 중국 선단 합류 의심.' },
{ id: 'V2', lat: 32.90, lng: 127.40, name: '미상선박-A', type: 'Dark Vessel', risk: 'HIGH', confidence: 87, detail: 'SAR 위성 탐지, AIS 미수신. 야간 조업 패턴.' },
{ id: 'V3', lat: 32.40, lng: 126.50, name: '冀黄港渔05001', type: '불법선박', risk: 'HIGH', confidence: 78, detail: '금어기 해역 불법 조업 의심. 통발 투하 행위.' },
{ id: 'V4', lat: 33.80, lng: 125.60, name: '浙岭渔11038', type: '불법선박', risk: 'CRITICAL', confidence: 91, detail: '협정선 위반 조업. MMSI 변조 이력 3회.' },
{ id: 'V5', lat: 32.55, lng: 127.60, name: '闽东渔60888', type: 'Dark Vessel', risk: 'HIGH', confidence: 83, detail: 'AIS 12h 단절. 위성 레이더 탐지 위치.' },
{ id: 'V6', lat: 33.70, lng: 125.30, name: '미상선박-B', type: 'Dark Vessel', risk: 'HIGH', confidence: 79, detail: '적외선 위성 열원 탐지. 선체 추정 30m급.' },
// ── 불법어구 (제주 근해) ──
{ id: 'G1', lat: 33.05, lng: 126.10, name: '어구-A01', type: '불법어구', risk: 'MEDIUM', confidence: 72, detail: '금지구역 자망 어구 AIS(Class-B) 신호 탐지.' },
{ id: 'G2', lat: 33.15, lng: 127.00, name: '어구-B03', type: '불법어구', risk: 'MEDIUM', confidence: 68, detail: '미등록 통발 어구. 위치 위반 의심.' },
{ id: 'G3', lat: 32.80, lng: 126.30, name: '어구-C07', type: '불법어구', risk: 'MEDIUM', confidence: 75, detail: '금어기 해역 연승 어구 탐지. 09:15 최초 신호.' },
{ id: 'G4', lat: 33.00, lng: 127.30, name: '어구-D12', type: '불법어구', risk: 'MEDIUM', confidence: 65, detail: '허가 범위 외 저인망 어구 신호. 이동 패턴 감지.' },
{ id: 'G5', lat: 32.70, lng: 125.95, name: '어구-E05', type: '불법어구', risk: 'MEDIUM', confidence: 70, detail: '폐어구 추정. 48h 이상 동일 위치 정지.' },
// ── 아군 함정 ──
{ id: 'P1', lat: 33.10, lng: 126.55, name: '3009함', type: '아군함정', risk: 'NONE', confidence: 100, detail: '순찰 중. 속력 12kts.' },
{ id: 'P2', lat: 32.85, lng: 126.80, name: '1502함', type: '아군함정', risk: 'NONE', confidence: 100, detail: '대기 중. 정박.' },
];
export function MobileService() {
const { t } = useTranslation('fieldOps');
const mapRef = useRef<MapHandle>(null);
const { events, load } = useEventStore();
useEffect(() => { load(); }, [load]);
const { user } = useAuth();
const [screen, setScreen] = useState<MobileScreen>('main');
const [selectedMarker, setSelectedMarker] = useState<typeof AI_MARKERS[0] | null>(null);
const buildLayers = useCallback(() => [
createPolylineLayer('eez-simple', [
[38.5, 124.0], [37.0, 123.0], [36.0, 122.5], [35.0, 123.0],
], { color: '#ef4444', width: 1, opacity: 0.3, dashArray: [4, 4] }),
createMarkerLayer('mobile-markers', MOBILE_MARKERS.map(m => ({
lat: m.lat, lng: m.lng, color: m.color,
} as MarkerData)), '#3b82f6', 800),
// GPS
const [regDateTime, setRegDateTime] = useState(nowLocalISO());
const [regLocation, setRegLocation] = useState('');
const [gpsLoading, setGpsLoading] = useState(false);
const [gpsError, setGpsError] = useState('');
const [registered, setRegistered] = useState(false);
const fetchGPS = useCallback(() => {
if (!navigator.geolocation) { setGpsError('GPS 미지원'); return; }
setGpsLoading(true); setGpsError('');
navigator.geolocation.getCurrentPosition(
(pos) => { setRegLocation(`${pos.coords.latitude.toFixed(4)}°N ${pos.coords.longitude.toFixed(4)}°E`); setGpsLoading(false); },
() => { setGpsError('위치 확인 실패'); setGpsLoading(false); },
{ enableHighAccuracy: true, timeout: 10000 },
);
}, []);
useEffect(() => { fetchGPS(); }, [fetchGPS]);
// 모바일 내 지도
const mobileMapRef = useRef<MapHandle>(null);
const markerColor = (m: typeof AI_MARKERS[0]) =>
m.type === '아군함정' ? '#a855f7' : m.type === '불법어구' ? '#eab308' : m.risk === 'CRITICAL' ? '#ef4444' : '#f97316';
const markerEmoji = (m: typeof AI_MARKERS[0]) =>
m.type === '불법어구' ? '🎣' : m.type === '아군함정' ? '⚓' : '🚢';
/** MapLibre 네이티브 HTML 마커를 지도에 추가 */
const addNativeMarkers = useCallback((map: maplibregl.Map) => {
AI_MARKERS.forEach(m => {
const color = markerColor(m);
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;z-index:10;';
// 라벨
const label = document.createElement('div');
label.style.cssText = `font-size:10px;font-weight:800;color:${color};white-space:nowrap;text-shadow:-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #fff,0 0 4px #fff;margin-bottom:2px;line-height:1;`;
label.textContent = `${markerEmoji(m)} ${m.name}`;
el.appendChild(label);
// 도트
const dot = document.createElement('div');
const size = m.type === '불법어구' ? 12 : m.type === '아군함정' ? 12 : 16;
dot.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 8px ${color},0 0 16px ${color}80;`;
el.appendChild(dot);
new maplibregl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([m.lng, m.lat])
.addTo(map);
});
}, []);
/** 모바일 지도 onMapReady */
const onMobileMapReady = useCallback((map: maplibregl.Map) => {
addNativeMarkers(map);
}, [addNativeMarkers]);
// 데스크톱용 상세 지도
const detailMapRef = useRef<MapHandle>(null);
const buildDetailLayers = useCallback(() => [
createPolylineLayer('eez-d', [[38.5, 124.0], [37.0, 123.0], [36.0, 122.5], [35.0, 123.0]], { color: '#ef4444', width: 1, opacity: 0.3, dashArray: [4, 4] }),
createMarkerLayer('all-markers',
AI_MARKERS.map(m => ({
lat: m.lat, lng: m.lng,
color: markerColor(m),
radius: m.type === '불법어구' ? 800 : 1200,
} as MarkerData)),
),
new TextLayer({
id: 'detail-labels',
data: AI_MARKERS,
getPosition: (d: typeof AI_MARKERS[0]) => [d.lng, d.lat],
getText: (d: typeof AI_MARKERS[0]) => {
const icon = d.type === '불법어구' ? '🎣' : d.type === '아군함정' ? '⚓' : '🚢';
return `${icon} ${d.name}`;
},
getSize: 12,
getColor: (d: typeof AI_MARKERS[0]) => {
if (d.type === '아군함정') return [168, 85, 247, 255];
if (d.type === '불법어구') return [234, 179, 8, 255];
if (d.risk === 'CRITICAL') return [239, 68, 68, 255];
return [249, 115, 22, 255];
},
getPixelOffset: [0, -18],
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: false,
sizeUnits: 'pixels',
}),
], []);
useMapLayers(mapRef, buildLayers, []);
useMapLayers(detailMapRef, buildDetailLayers, []);
const ALERTS = useMemo(
() =>
events.slice(0, 3).map((e) => ({
time: formatTime(e.time).slice(0, 5),
title: e.type === 'EEZ 침범' || e.level === 'CRITICAL' ? `[긴급] ${e.title}` : e.title,
detail: e.area ?? e.detail,
level: e.level,
})),
[events],
);
const riskBadge = (r: string) =>
r === 'CRITICAL' ? 'bg-red-500/15 text-red-400' :
r === 'HIGH' ? 'bg-orange-500/15 text-orange-400' :
r === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400' :
'bg-purple-500/15 text-purple-400';
const typeIcon = (type: string) =>
type === '불법어구' ? <Anchor className="w-3 h-3" /> :
type === '아군함정' ? <Shield className="w-3 h-3" /> :
<Ship className="w-3 h-3" />;
// 모바일 하단 네비게이션 탭 상태
const [mobileNav, setMobileNav] = useState<'home' | 'map' | 'alert' | 'profile'>('home');
// ─── 모바일 프리뷰 내부 렌더 ───
const renderMobileContent = () => {
// 메인 홈
if (screen === 'main') return (
<div className="flex flex-col h-full">
{/* ── 상단 상태바 ── */}
<div className="h-7 bg-slate-900 flex items-center justify-between px-3 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-green-400 font-bold">📶 4G</span>
<span className="text-[8px] text-hint">🔋 85%</span>
</div>
<span className="text-[8px] text-hint font-mono">
{new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-red-400 font-bold">🔔 3</span>
</div>
</div>
{/* ── 긴급 경보 배너 ── */}
<div className="mx-2.5 mt-2 px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-xl animate-pulse">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[9px] text-red-400 font-bold truncate"> 5 (14:25)</div>
<div className="text-[7px] text-red-400/70">Dark Vessel 2 · 94%</div>
</div>
<button type="button" onClick={() => setScreen('ai-detail')} className="px-2 py-0.5 bg-red-500/20 rounded text-[7px] text-red-400 font-bold shrink-0"> </button>
</div>
</div>
{/* ── 퀵 메뉴 4개 ── */}
<div className="grid grid-cols-4 gap-2 px-2.5 mt-2.5">
{[
{ label: '위험도\n지도', icon: '🗺️', color: 'bg-red-500/15 border-red-500/30', action: () => setMobileNav('map') },
{ label: '의심\n선박', icon: '🚢', color: 'bg-orange-500/15 border-orange-500/30', action: () => setScreen('ai-detail') },
{ label: '경로\n추천', icon: '🧭', color: 'bg-blue-500/15 border-blue-500/30', action: () => {} },
{ label: '단속\n이력', icon: '📋', color: 'bg-green-500/15 border-green-500/30', action: () => { setScreen('register'); setRegDateTime(nowLocalISO()); setRegistered(false); } },
].map(m => (
<button key={m.label} type="button" onClick={m.action} className={`${m.color} border rounded-xl py-2.5 flex flex-col items-center gap-1 active:scale-95 transition-transform`}>
<span className="text-[16px]">{m.icon}</span>
<span className="text-[8px] text-heading font-bold text-center whitespace-pre-line leading-tight">{m.label}</span>
</button>
))}
</div>
{/* ── 현재 위치 기반 정보 ── */}
<div className="mx-2.5 mt-2.5 px-3 py-2.5 bg-surface-overlay border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" />
<span className="text-[9px] text-heading font-bold">{regLocation || '위치 확인 중...'}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="text-center">
<div className="text-[14px] font-extrabold text-orange-400">85</div>
<div className="text-[7px] text-hint">()</div>
</div>
<div className="text-center">
<div className="text-[14px] font-extrabold text-red-400">2<span className="text-[9px]"></span></div>
<div className="text-[7px] text-hint"></div>
</div>
<div className="text-center">
<div className="text-[14px] font-extrabold text-hint">3</div>
<div className="text-[7px] text-hint"></div>
</div>
</div>
</div>
{/* ── 지도 (축소) ── */}
<div className="flex-1 mx-2.5 mt-2 mb-1 rounded-xl overflow-hidden relative" style={{ minHeight: 160 }}>
<BaseMap ref={mobileMapRef} center={[33.0, 126.5]} zoom={8} height="100%" forceTheme="light" navPosition="bottom-right" onMapReady={onMobileMapReady} />
<div className="absolute top-1.5 right-1.5 z-[1000] bg-red-500/90 rounded px-1.5 py-0.5">
<span className="text-[8px] text-white font-bold">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
</div>
{/* 범례 */}
<div className="absolute top-1.5 left-1.5 z-[1000] bg-black/60 rounded px-1.5 py-1 flex gap-2">
{[['bg-red-500', '선박'], ['bg-yellow-500', '어구'], ['bg-purple-500', '아군']].map(([c, l]) => (
<div key={l} className="flex items-center gap-0.5"><div className={`w-1.5 h-1.5 rounded-full ${c}`} /><span className="text-[6px] text-white/80">{l}</span></div>
))}
</div>
</div>
{/* ── 하단 네비게이션 바 ── */}
<div className="flex items-center justify-around px-2 py-1.5 bg-slate-900 border-t border-slate-700 shrink-0">
{[
{ key: 'home' as const, icon: '🏠', label: '홈' },
{ key: 'map' as const, icon: '🗺️', label: '지도' },
{ key: 'alert' as const, icon: '🔔', label: '알림' },
{ key: 'profile' as const, icon: '👤', label: '프로필' },
].map(n => (
<button
key={n.key}
type="button"
onClick={() => {
setMobileNav(n.key);
if (n.key === 'alert') setScreen('ai-detail');
}}
className={`flex flex-col items-center gap-0.5 px-3 py-0.5 rounded-lg transition-colors ${mobileNav === n.key ? 'text-blue-400' : 'text-hint'}`}
>
<span className="text-[14px]">{n.icon}</span>
<span className={`text-[7px] font-bold ${mobileNav === n.key ? 'text-blue-400' : 'text-hint'}`}>{n.label}</span>
</button>
))}
</div>
</div>
);
// 단속 이력 등록
if (screen === 'register') return (
<div className="flex flex-col h-full">
<div className="h-7 bg-secondary flex items-center px-3 gap-2 shrink-0">
<button type="button" onClick={() => setScreen('main')} className="text-hint hover:text-heading" title="뒤로가기"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-[9px] text-heading font-medium"> </span>
</div>
{registered ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<CheckCircle className="w-10 h-10 text-green-400" />
<span className="text-xs font-bold text-heading"> </span>
<span className="text-[9px] text-hint text-center"> </span>
<button type="button" onClick={() => setRegistered(false)} className="px-4 py-1.5 bg-blue-600 rounded-lg text-[9px] text-white font-bold"> </button>
</div>
) : (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{/* 자동 입력 */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-2.5 space-y-1">
<div className="text-[8px] text-blue-400 font-bold flex items-center gap-1"><Clock className="w-2.5 h-2.5" /> </div>
<div className="grid grid-cols-2 gap-1 text-[8px]">
<div className="text-label flex items-center gap-1"><Calendar className="w-2.5 h-2.5 text-hint" />{regDateTime.replace('T', ' ')}</div>
<div className="text-label flex items-center gap-1"><MapPin className="w-2.5 h-2.5 text-hint" />{regLocation || '확인 중'}</div>
<div className="text-label flex items-center gap-1"><Building2 className="w-2.5 h-2.5 text-hint" />{user?.org || '해양경찰청'}</div>
<div className="text-label flex items-center gap-1"><User className="w-2.5 h-2.5 text-hint" />{user?.name || '-'}</div>
</div>
</div>
{/* 입력 필드 */}
<div className="space-y-2">
<div>
<div className="text-[8px] text-hint mb-0.5"> MMSI <span className="text-red-400">*</span></div>
<input type="text" placeholder="MMSI 입력" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div>
<div className="text-[8px] text-hint mb-0.5"></div>
<input type="text" placeholder="자동 연결" className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[8px] text-hint mb-0.5"> <span className="text-red-400">*</span></div>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2 py-1.5 text-[10px] text-heading focus:outline-none appearance-none" title="위반 유형">
<option value=""></option><option></option><option></option><option></option><option>AIS </option><option> </option>
</select>
</div>
<div>
<div className="text-[8px] text-hint mb-0.5"> <span className="text-red-400">*</span></div>
<select className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-2 py-1.5 text-[10px] text-heading focus:outline-none appearance-none" title="조치 결과">
<option value=""></option><option></option><option></option><option></option><option></option>
</select>
</div>
</div>
</div>
{/* 증적 첨부 */}
<div className="grid grid-cols-3 gap-1.5">
{['사진', '영상', '조서'].map(l => (
<div key={l} className="border border-dashed border-slate-600 rounded-lg p-2.5 flex flex-col items-center gap-1 cursor-pointer hover:border-blue-500/50">
<Upload className="w-4 h-4 text-hint" />
<span className="text-[8px] text-hint">{l}</span>
</div>
))}
</div>
<button type="button" onClick={() => setRegistered(true)} className="w-full py-2.5 bg-blue-600 rounded-xl text-[10px] text-white font-bold"></button>
</div>
)}
</div>
);
// 주변 AI 탐지
return (
<div className="flex flex-col h-full">
<div className="h-7 bg-secondary flex items-center px-3 gap-2 shrink-0">
<button type="button" onClick={() => { setScreen('main'); setSelectedMarker(null); }} className="text-hint hover:text-heading"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-[9px] text-heading font-medium"> AI </span>
<span className="text-[8px] text-red-400 font-bold ml-auto">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{AI_MARKERS.filter(m => m.type !== '아군함정').map(m => (
<div key={m.id}>
<button
type="button"
onClick={() => setSelectedMarker(selectedMarker?.id === m.id ? null : m)}
className={`w-full text-left rounded-lg p-2.5 border transition-colors ${
selectedMarker?.id === m.id ? 'border-blue-500/40 bg-blue-500/5' : 'border-border bg-card hover:bg-surface-overlay'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold ${riskBadge(m.risk)}`}>
{m.risk === 'CRITICAL' ? '긴급' : m.risk === 'HIGH' ? '높음' : '주의'}
</span>
{typeIcon(m.type)}
<span className="text-[9px] text-heading font-medium">{m.type}</span>
</div>
<span className={`text-[9px] font-bold ${m.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{m.confidence}%</span>
</div>
<div className="text-[9px] text-heading">{m.name}</div>
<div className="text-[8px] text-hint mt-0.5">{m.lat.toFixed(2)}°N {m.lng.toFixed(2)}°E</div>
</button>
{selectedMarker?.id === m.id && (
<div className="mx-1 mt-1 p-2.5 bg-surface-overlay rounded-lg space-y-2">
<div className="text-[9px] text-label leading-relaxed">{m.detail}</div>
<div className="grid grid-cols-2 gap-1.5">
<button type="button" className="flex items-center justify-center gap-1 py-1.5 bg-blue-600 rounded-lg text-[9px] text-white font-medium">
<Navigation className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => { setScreen('register'); setRegDateTime(nowLocalISO()); setRegistered(false); }}
className="flex items-center justify-center gap-1 py-1.5 bg-orange-600 rounded-lg text-[9px] text-white font-medium"
>
<Plus className="w-3 h-3" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}</h2>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
</div>
<div className="grid grid-cols-3 gap-3">
{/* 모바일 프리뷰 */}
<div className="grid grid-cols-3 gap-4">
{/* ── 모바일 단말기 프리뷰 ── */}
<Card>
<CardContent className="p-4 flex justify-center">
<div className="w-[220px] h-[420px] bg-background border-2 border-slate-600 rounded-[24px] overflow-hidden relative">
{/* 상태바 */}
<div className="h-6 bg-secondary flex items-center justify-center"><span className="text-[8px] text-hint"> </span></div>
<div className="p-3 space-y-2">
{/* 긴급 경보 */}
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
<div className="text-[9px] text-red-400 font-bold">[] EEZ </div>
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
</div>
{/* 지도 영역 — MapLibre GL */}
<div className="rounded-lg overflow-hidden relative" style={{ height: 128 }}>
<BaseMap
ref={mapRef}
center={[37.2, 125.0]}
zoom={8}
height={128}
interactive={false}
/>
{/* 미니 범례 */}
<div className="absolute bottom-1 right-1 z-[1000] bg-black/70 rounded px-1.5 py-0.5 flex items-center gap-1.5">
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-[6px] text-muted-foreground"></span></span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-[6px] text-muted-foreground"></span></span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-purple-500" /><span className="text-[6px] text-muted-foreground"></span></span>
</div>
</div>
{/* KPI 그리드 */}
<div className="grid grid-cols-2 gap-1">
{[['위험도', '87점'], ['의심선박', '3척'], ['추천경로', '2건'], ['경보', '5건']].map(([k, v]) => (
<div key={k} className="bg-surface-overlay rounded p-1.5 text-center">
<div className="text-[10px] text-heading font-bold">{v}</div>
<div className="text-[7px] text-hint">{k}</div>
</div>
))}
</div>
{/* 최근 알림 */}
<div className="space-y-1">
{ALERTS.slice(0, 2).map((a, i) => (
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
<span className="text-[8px] text-label truncate">{a.title}</span>
</div>
))}
</div>
<div className="w-[320px] h-[640px] bg-background border-2 border-slate-600 rounded-[32px] overflow-hidden relative shadow-2xl shadow-black/50">
{/* 노치 */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-5 bg-secondary rounded-b-xl z-10" />
{/* 컨텐츠 */}
<div className="h-full pt-5 pb-5">
{renderMobileContent()}
</div>
{/* 홈바 */}
<div className="absolute bottom-0 left-0 right-0 h-5 bg-secondary rounded-b-[22px]" />
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 w-28 h-1 bg-slate-500 rounded-full" />
</div>
</CardContent>
</Card>
{/* 기능 설명 + 푸시 설정 */}
{/* ── 데스크톱 상세 영역 ── */}
<div className="col-span-2 space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="grid grid-cols-2 gap-2">
{[{ icon: AlertTriangle, name: '예측 정보 수신', desc: '불법행위 위험도, 의심 선박·어구 정보' },
{ icon: Navigation, name: '경로 추천', desc: 'AI 순찰 경로 수신 및 네비게이션' },
{ icon: MapPin, name: '지도 조회', desc: '해상 위치 확인·해양환경정보 간단 조회' },
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
].map(f => (
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
{/* 지도 (전체 마커 표시) */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={detailMapRef} center={[33.0, 126.5]} zoom={8} height={340} className="rounded-lg overflow-hidden" forceTheme="light" onMapReady={onMobileMapReady} />
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-[10px] text-heading font-medium"> </span>
<span className="text-[9px] text-hint">{regLocation || 'GPS 확인 중'}</span>
</div>
<div className="absolute top-3 right-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-red-400 font-bold">{AI_MARKERS.filter(m => m.type !== '아군함정').length}</span>
<span className="text-[9px] text-hint ml-1">AI </span>
</div>
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="space-y-0.5">
{[['bg-red-500', '불법선박 (긴급)'], ['bg-orange-500', 'Dark Vessel'], ['bg-yellow-500', '불법어구'], ['bg-purple-500', '아군함정']].map(([c, l]) => (
<div key={l} className="flex items-center gap-1.5"><div className={`w-2 h-2 rounded-full ${c}`} /><span className="text-[8px] text-muted-foreground">{l}</span></div>
))}
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" /> </div>
<div className="space-y-2">
{PUSH_SETTINGS.map(p => (
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-label">{p.name}</span>
<div className={`w-9 h-5 rounded-full relative ${p.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 shadow-sm" style={{ left: p.enabled ? '18px' : '2px' }} />
</div>
</CardContent>
</Card>
{/* AI 탐지 리스트 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Radar className="w-4 h-4 text-purple-400" />
<span className="text-xs font-bold text-heading">AI </span>
</div>
<div className="grid grid-cols-2 gap-2">
{AI_MARKERS.filter(m => m.type !== '아군함정').map(m => (
<div key={m.id} className="flex items-center gap-3 p-3 bg-surface-overlay rounded-lg border border-border">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${riskBadge(m.risk)}`}>
{typeIcon(m.type)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-heading font-medium truncate">{m.name}</span>
<span className={`text-[9px] font-bold ${m.confidence >= 80 ? 'text-green-400' : 'text-yellow-400'}`}>{m.confidence}%</span>
</div>
<div className="text-[9px] text-hint">{m.type} · {m.lat.toFixed(2)}°N {m.lng.toFixed(2)}°E</div>
<div className="text-[8px] text-hint mt-0.5 truncate">{m.detail}</div>
</div>
</div>
</div>
))}
</div>
</CardContent></Card>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>

파일 보기

@ -1,32 +1,69 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell, Clock, Target, ChevronRight } from 'lucide-react';
import {
Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell,
Clock, Target, ChevronRight, Filter, MapPin, Calendar,
Volume2, Search, RotateCcw,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
import { SystemStatusPanel } from './SystemStatusPanel';
import { BaseMap, createMarkerLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import maplibregl from 'maplibre-gl';
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
/**
* SFR-12: 모니터링 ()
*
* RFP :
* , ·, ·,
*
*
* : ( + / + + )
* : · (5 ··)
* : ·Drill-down (···)
*/
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
'다크베셀': { icon: Eye, color: '#f97316' },
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
'추적 중': { icon: Target, color: '#06b6d4' },
'나포/검문': { icon: Shield, color: '#10b981' },
// ─── Mock 데이터 ───
const MOCK_ALERTS = [
{ time: '14:28', type: 'Dark Vessel', level: 'CRITICAL', area: '서해 NLL', vessel: '미상선박', mmsi: '-' },
{ time: '14:12', type: 'AIS 조작', level: 'HIGH', area: '서해 5도', vessel: '冀黄港渔05001', mmsi: '412876001' },
{ time: '13:55', type: '공조 조업', level: 'CRITICAL', area: '서해 NLL', vessel: '鲁荣渔56555', mmsi: '412999888' },
{ time: '13:30', type: '협정선 접근', level: 'MEDIUM', area: '동해 EEZ', vessel: 'LIAO DONG 77', mmsi: '412345678' },
{ time: '12:44', type: '불법 어구', level: 'HIGH', area: '제주 남방', vessel: '-', mmsi: '-' },
{ time: '12:10', type: '금어기 조업', level: 'MEDIUM', area: '남해', vessel: '대한호', mmsi: '440987654' },
{ time: '11:35', type: 'Dark Vessel', level: 'HIGH', area: '서해 NLL', vessel: '미상선박-B', mmsi: '-' },
];
const MOCK_VESSELS = [
{ mmsi: '412999888', name: '鲁荣渔56555', type: 'Dark Vessel', lat: 37.20, lng: 124.63, risk: 5, time: '14:28', status: '경보발령' },
{ mmsi: '412876001', name: '冀黄港渔05001', type: 'AIS 조작', lat: 37.75, lng: 125.02, risk: 4, time: '14:12', status: '모니터링' },
{ mmsi: '412345678', name: 'LIAO DONG 77', type: '협정선 접근', lat: 36.80, lng: 129.50, risk: 3, time: '13:30', status: '모니터링' },
{ mmsi: '440987654', name: '대한호', type: '불법어구', lat: 34.50, lng: 128.20, risk: 3, time: '12:44', status: '확인중' },
{ mmsi: '-', name: '미상선박-B', type: 'Dark Vessel', lat: 37.50, lng: 124.80, risk: 4, time: '11:35', status: '추적중' },
{ mmsi: '412234567', name: 'MIN RONG 8', type: '공조 조업', lat: 36.20, lng: 125.30, risk: 5, time: '13:55', status: '경보발령' },
];
const RISK_COLORS: Record<number, string> = { 5: '#ef4444', 4: '#f97316', 3: '#eab308', 2: '#3b82f6', 1: '#06b6d4' };
const RISK_LABELS: Record<number, string> = { 5: '5등급(적)', 4: '4등급(주황)', 3: '3등급(황)', 2: '2등급(청)', 1: '1등급(녹)' };
const LV: Record<string, string> = {
CRITICAL: 'text-red-400 bg-red-500/15 border-red-500/30',
HIGH: 'text-orange-400 bg-orange-500/15 border-orange-500/30',
MEDIUM: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
LOW: 'text-blue-400 bg-blue-500/15 border-blue-500/30',
};
// 위반 유형 → 차트 색상 매핑
const LV_LABEL: Record<string, string> = { CRITICAL: '긴급', HIGH: '고위험', MEDIUM: '주의', LOW: '관심' };
// PIE 색상
const PIE_COLOR_MAP: Record<string, string> = {
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
'불법환적': '#a855f7', '어구 불법': '#6b7280',
};
const LV: Record<string, string> = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' };
export function MonitoringDashboard() {
const { t } = useTranslation('dashboard');
@ -35,101 +72,354 @@ export function MonitoringDashboard() {
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
// ─── 필터 상태 ───
const [filterArea, setFilterArea] = useState('전국');
const [filterPeriod, setFilterPeriod] = useState('금일');
const [filterAlertType, setFilterAlertType] = useState('전체');
const [filterRisk, setFilterRisk] = useState('전체');
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => {
getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([]));
}, []);
useEffect(() => { getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([])); }, []);
// 24시간 위험도/경보 추이: hourly stats → 차트 데이터
// 24시간 추이 차트 데이터
const TREND = useMemo(() => hourlyStats.map((h) => {
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}` : '';
// 위험도 점수: byRiskLevel 가중합 (CRITICAL=100, HIGH=70, MEDIUM=40, LOW=10) 정규화
let riskScore = 0;
let total = 0;
let riskScore = 0, total = 0;
if (h.byRiskLevel) {
const weights: Record<string, number> = { CRITICAL: 100, HIGH: 70, MEDIUM: 40, LOW: 10 };
Object.entries(h.byRiskLevel).forEach(([k, v]) => {
const cnt = Number(v) || 0;
riskScore += (weights[k.toUpperCase()] ?? 0) * cnt;
total += cnt;
});
Object.entries(h.byRiskLevel).forEach(([k, v]) => { const cnt = Number(v) || 0; riskScore += (weights[k.toUpperCase()] ?? 0) * cnt; total += cnt; });
}
const risk = total > 0 ? Math.round(riskScore / total) : 0;
return {
h: hourLabel,
risk,
alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0),
};
return { h: hourLabel, risk: total > 0 ? Math.round(riskScore / total) : 0, alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0) };
}), [hourlyStats]);
// KPI: store metrics + UI 매핑
const KPI = kpiStore.metrics.map((m) => ({
label: m.label,
value: m.value,
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
}));
// PIE 데이터
const PIE = kpiStore.violationTypes.map((v) => ({ name: v.type, value: v.pct, color: PIE_COLOR_MAP[v.type] ?? '#6b7280' }));
// PIE: store violationTypes → 차트 데이터 변환
const PIE = kpiStore.violationTypes.map((v) => ({
name: v.type,
value: v.pct,
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
}));
// ─── 지도: MapLibre 네이티브 마커 ───
const mapRef = useRef<MapHandle>(null);
const onMapReady = useCallback((map: maplibregl.Map) => {
MOCK_VESSELS.forEach(v => {
const color = RISK_COLORS[v.risk] ?? '#3b82f6';
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;z-index:10;';
const label = document.createElement('div');
label.style.cssText = `font-size:9px;font-weight:800;color:${color};white-space:nowrap;text-shadow:-1px -1px 0 rgba(0,0,0,0.8),1px -1px 0 rgba(0,0,0,0.8),-1px 1px 0 rgba(0,0,0,0.8),1px 1px 0 rgba(0,0,0,0.8);margin-bottom:2px;`;
label.textContent = `🚢 ${v.name}`;
el.appendChild(label);
const dot = document.createElement('div');
const isAlert = v.risk >= 4;
dot.style.cssText = `width:${isAlert ? 14 : 10}px;height:${isAlert ? 14 : 10}px;border-radius:50%;background:${color};border:2px solid rgba(255,255,255,0.8);box-shadow:0 0 8px ${color};${isAlert ? 'animation:pulse 1.5s infinite;' : ''}`;
el.appendChild(dot);
new maplibregl.Marker({ element: el, anchor: 'bottom' }).setLngLat([v.lng, v.lat]).addTo(map);
});
}, []);
// 이벤트: store events → 첫 6개, time 포맷 변환
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
level: e.level,
title: e.title,
detail: e.detail,
}));
// 경보 깜박임 CSS
const alertFlash = (level: string) => level === 'CRITICAL' ? 'animate-pulse' : '';
const filterCls = "bg-surface-overlay border border-slate-700/50 rounded-lg px-2.5 py-1.5 text-[10px] text-heading focus:outline-none focus:border-blue-500/60 appearance-none";
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p>
</div>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="space-y-0">
{/* ════════ 지도 중심 영역 (전체 폭, 높이 큼) ════════ */}
<div className="relative">
<BaseMap ref={mapRef} center={[36.0, 127.0]} zoom={6} height={560} forceTheme="light" onMapReady={onMapReady} />
<div className="flex gap-2">
{KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${k.color}15` }}><k.icon className="w-4 h-4" style={{ color: k.color }} /></div>
<span className="text-lg font-bold text-heading">{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3">
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">24 · </div>
<AreaChart data={TREND} xKey="h" height={200} series={[{ key: 'risk', name: '위험도', color: '#ef4444' }, { key: 'alarms', name: '경보', color: '#3b82f6' }]} />
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<PieChart data={PIE} height={140} innerRadius={30} outerRadius={55} />
<div className="space-y-1 mt-2">{PIE.map(d => (
<div key={d.name} className="flex justify-between text-[10px]"><div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} /><span className="text-muted-foreground">{d.name}</span></div><span className="text-heading font-bold">{d.value}%</span></div>
))}</div>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-red-400" /> </div>
<div className="space-y-2">
{EVENTS.map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-10">{e.time}</span>
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
<span className="text-[10px] text-hint">{e.detail}</span>
{/* 지도 위 오버레이: 헤더 + KPI */}
<div className="absolute top-0 left-0 right-0 z-[1000] pointer-events-none">
{/* 헤더 바 */}
<div className="pointer-events-auto flex items-center justify-between px-5 py-2.5 bg-background/80 backdrop-blur-sm border-b border-border">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-green-400" />
<h2 className="text-base font-bold text-heading">{t('monitoring.title')}</h2>
</div>
))}
<div className="flex items-center gap-2 text-[10px] text-hint">
<Clock className="w-3.5 h-3.5" />
<span>{new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
<span className="text-heading font-medium ml-1">| {filterArea}</span>
</div>
</div>
{/* KPI 4개 — 지도 위 상단 */}
<div className="pointer-events-auto flex gap-2 px-5 py-2">
{[
{ label: '오늘 경보', value: `${MOCK_ALERTS.length}`, sub: '▲2', icon: Bell, color: '#ef4444' },
{ label: '의심 선박', value: `${MOCK_VESSELS.length}`, sub: `고위험 ${MOCK_VESSELS.filter(v => v.risk >= 4).length}`, icon: Ship, color: '#f97316' },
{ label: '의심 어구', value: '12개', sub: '위반 4', icon: Anchor, color: '#eab308' },
{ label: '단속 완료', value: '3건', sub: '오늘', icon: Shield, color: '#10b981' },
].map(k => (
<div key={k.label} className="flex items-center gap-2 px-3 py-2 bg-background/90 backdrop-blur-sm border border-border rounded-xl">
<k.icon className="w-4 h-4" style={{ color: k.color }} />
<span className="text-lg font-bold" style={{ color: k.color }}>{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
<span className="text-[8px] text-hint">({k.sub})</span>
</div>
))}
</div>
</div>
</CardContent></Card>
{/* 지도 위 오버레이: 필터 바 (하단) */}
<div className="absolute bottom-0 left-0 right-0 z-[1000] pointer-events-auto px-5 py-2 bg-background/80 backdrop-blur-sm border-t border-border flex items-center gap-3">
<Filter className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<select value={filterArea} onChange={e => setFilterArea(e.target.value)} className={filterCls} title="관할 해역">
{['전국', '서해청', '남해청', '동해청', '제주청', '중부청'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterPeriod} onChange={e => setFilterPeriod(e.target.value)} className={filterCls} title="기간">
{['금일', '최근 24h', '최근 7일', '사용자 정의'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterAlertType} onChange={e => setFilterAlertType(e.target.value)} className={filterCls} title="경보 유형">
{['전체', 'AIS 조작', 'Dark Vessel', '공조 조업', '금어기', '협정선', '불법 어구'].map(v => <option key={v}>{v}</option>)}
</select>
<select value={filterRisk} onChange={e => setFilterRisk(e.target.value)} className={filterCls} title="위험 등급">
{['전체', '5등급(긴급)', '4등급(고위험)', '3등급(주의)', '2등급(관심)', '1등급(참고)'].map(v => <option key={v}>{v}</option>)}
</select>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-heading font-medium transition-colors">
<Search className="w-3 h-3" />
</button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 border border-border rounded-lg text-[10px] text-hint hover:text-label hover:bg-surface-overlay transition-colors">
<RotateCcw className="w-3 h-3" />
</button>
<div className="ml-auto text-[9px] text-hint">{MOCK_VESSELS.length} </div>
</div>
{/* 지도 위 오버레이: 범례 (좌측 하단 필터 위) */}
<div className="absolute bottom-12 left-5 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[8px] text-hint font-bold mb-1"> </div>
<div className="flex gap-2">
{[5, 4, 3, 2, 1].map(r => (
<div key={r} className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: RISK_COLORS[r] }} />
<span className="text-[7px] text-muted-foreground">{RISK_LABELS[r]}</span>
</div>
))}
</div>
</div>
{/* 지도 위 오버레이: 최근 경보 (우측) */}
<div className="absolute top-[88px] right-5 z-[1000] w-64 pointer-events-auto">
<div className="bg-background/90 backdrop-blur-sm border border-border rounded-xl overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-1.5">
<Bell className="w-3 h-3 text-red-400" />
<span className="text-[10px] font-bold text-heading"> </span>
<span title="경보 알림음 활성"><Volume2 className="w-3 h-3 text-red-400 ml-auto animate-pulse" /></span>
</div>
<div className="p-1.5 space-y-1 max-h-[350px] overflow-y-auto">
{MOCK_ALERTS.slice(0, 5).map((a, i) => (
<div
key={i}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-surface-overlay ${
a.level === 'CRITICAL' ? 'bg-red-500/10' : a.level === 'HIGH' ? 'bg-orange-500/10' : ''
} ${alertFlash(a.level)}`}
>
<span className="text-[9px] text-hint font-mono w-9 shrink-0">{a.time}</span>
<span className={`px-1 py-0.5 rounded text-[7px] font-bold shrink-0 ${LV[a.level]}`}>{LV_LABEL[a.level]}</span>
<span className="text-[9px] text-heading font-medium truncate">{a.type}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* ════════ 지도 아래 정보 영역 ════════ */}
<div className="p-5 space-y-4">
{/* 시스템 상태 */}
<SystemStatusPanel />
{/* ── 기능① 단속 계획·실적 요약 (SFR-06 연계) + 경보 설정 요약 ── */}
<div className="grid grid-cols-2 gap-3">
{/* 단속 계획·실적 */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-orange-400" /> · <span className="text-[8px] text-hint font-normal">(SFR-06 )</span>
</div>
<div className="grid grid-cols-4 gap-2">
{[
{ label: '금일 계획', value: '5건', color: 'text-blue-400' },
{ label: '경보 발령', value: '2건', color: 'text-red-400' },
{ label: '투입 함정', value: '10척', color: 'text-cyan-400' },
{ label: '단속 완료', value: '3건', color: 'text-green-400' },
].map(s => (
<div key={s.label} className="bg-surface-overlay rounded-lg p-2.5 text-center">
<div className={`text-lg font-bold ${s.color}`}>{s.value}</div>
<div className="text-[9px] text-hint">{s.label}</div>
</div>
))}
</div>
<div className="mt-3 space-y-1.5">
{[
{ zone: '서해 NLL 인근', risk: 85, status: 'APPROVED', ships: '2척' },
{ zone: '동해 EEZ 인근', risk: 65, status: 'DRAFT', ships: '1척' },
{ zone: '제주 남방 해역', risk: 78, status: 'APPROVED', ships: '2척' },
].map(p => (
<div key={p.zone} className="flex items-center gap-2 px-3 py-1.5 bg-surface-overlay rounded-lg text-[10px]">
<span className="text-heading font-medium flex-1">{p.zone}</span>
<span className={`font-bold ${p.risk >= 80 ? 'text-red-400' : 'text-orange-400'}`}>{p.risk}</span>
<span className="text-hint">{p.ships}</span>
<span className={`px-1.5 py-0.5 rounded text-[9px] ${p.status === 'APPROVED' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>{p.status}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 기능② 시각·청각적 경보 강조 설정 현황 */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Volume2 className="w-4 h-4 text-red-400" /> <span className="text-[8px] text-hint font-normal">()</span>
</div>
<div className="space-y-2">
{[
{ label: '5등급(긴급)', desc: '빨강 깜박임 + 3회 연속 경고음 + 팝업', color: 'bg-red-500', active: true },
{ label: '4등급(고위험)', desc: '주황 강조 + 단일 알림음 + 팝업', color: 'bg-orange-500', active: true },
{ label: '3등급(주의)', desc: '노랑 테두리 + 무음', color: 'bg-yellow-500', active: true },
{ label: '2등급(관심)', desc: '파랑 테두리 + 무음', color: 'bg-blue-500', active: false },
{ label: '1등급(참고)', desc: '회색 표시 + 로그만', color: 'bg-gray-500', active: false },
].map(s => (
<div key={s.label} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
<div className={`w-3 h-3 rounded-full ${s.color} ${s.label.includes('긴급') ? 'animate-pulse' : ''}`} />
<span className="text-[10px] text-heading font-medium w-24">{s.label}</span>
<span className="text-[9px] text-hint flex-1">{s.desc}</span>
<div className={`w-8 h-4 rounded-full relative ${s.active ? 'bg-blue-600' : 'bg-slate-700'}`}>
<div className="w-3 h-3 bg-white rounded-full absolute top-0.5 shadow-sm" style={{ left: s.active ? '18px' : '2px' }} />
</div>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[9px]">
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2">
<span className="text-hint"> </span>
<span className="text-heading font-medium">30 </span>
</div>
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2">
<span className="text-hint"> </span>
<span className="text-heading font-medium"> + </span>
</div>
<div className="bg-surface-overlay rounded-lg p-2 flex items-center gap-2 col-span-2">
<span className="text-hint">SFR-17 </span>
<span className="text-heading font-medium"> AI </span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 의심 선박 목록 */}
<Card>
<CardContent className="p-0">
<div className="px-4 py-2.5 border-b border-border flex items-center gap-2">
<Ship className="w-4 h-4 text-orange-400" />
<span className="text-[11px] font-bold text-heading"> </span>
<span className="text-[9px] text-hint ml-auto">{MOCK_VESSELS.length}</span>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['MMSI', '선박명', '탐지유형', '위치', '위험도', '시각', '상태'].map(h => (
<th key={h} className="text-left px-4 py-2 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{MOCK_VESSELS.map(v => (
<tr key={v.mmsi + v.name} className="border-b border-border hover:bg-surface-overlay/50 cursor-pointer transition-colors">
<td className="px-4 py-2 text-heading font-mono text-[10px]">{v.mmsi}</td>
<td className="px-4 py-2 text-heading font-medium">{v.name}</td>
<td className="px-4 py-2"><span className="px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 text-[10px]">{v.type}</span></td>
<td className="px-4 py-2 text-label font-mono text-[10px]">{v.lat.toFixed(1)}°N {v.lng.toFixed(1)}°E</td>
<td className="px-4 py-2">
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold" style={{ backgroundColor: `${RISK_COLORS[v.risk]}20`, color: RISK_COLORS[v.risk] }}>
{RISK_LABELS[v.risk]}
</span>
</td>
<td className="px-4 py-2 text-hint font-mono text-[10px]">{v.time}</td>
<td className="px-4 py-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
v.status === '경보발령' ? 'bg-red-500/10 text-red-400 animate-pulse' :
v.status === '추적중' ? 'bg-blue-500/10 text-blue-400' :
'bg-yellow-500/10 text-yellow-400'
}`}>{v.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* ── 기능③ Drill-down 안내 ── */}
<Card>
<CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" /> Drill-down <span className="text-[8px] text-hint font-normal">()</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-surface-overlay rounded-lg p-3 flex items-start gap-3">
<MapPin className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />
<div>
<div className="text-[10px] text-heading font-medium"> </div>
<div className="text-[9px] text-hint mt-0.5"> · , , .</div>
</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 flex items-start gap-3">
<Eye className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
<div>
<div className="text-[10px] text-heading font-medium"> (SHAP) + </div>
<div className="text-[9px] text-hint mt-0.5"> AI (SHAP ), , .</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 차트: 24시간 추이 + 탐지 유형 분포 */}
<div className="grid grid-cols-3 gap-3">
<Card className="col-span-2"><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" /> 24 ·
</div>
<AreaChart data={TREND} xKey="h" height={180} series={[
{ key: 'risk', name: '위험도', color: '#ef4444' },
{ key: 'alarms', name: '경보', color: '#3b82f6' },
]} />
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3"> </div>
<PieChart data={PIE} height={120} innerRadius={25} outerRadius={48} />
<div className="space-y-1 mt-2">
{PIE.map(d => (
<div key={d.name} className="flex justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-muted-foreground">{d.name}</span>
</div>
<span className="text-heading font-bold">{d.value}%</span>
</div>
))}
</div>
</CardContent></Card>
</div>
{/* 실시간 이벤트 타임라인 */}
<Card><CardContent className="p-4">
<div className="text-[11px] font-bold text-heading mb-3 flex items-center gap-2">
<Bell className="w-4 h-4 text-red-400" />
</div>
<div className="space-y-1.5">
{eventStore.events.slice(0, 6).map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-10">{e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time}</span>
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level] ?? LV.LOW}`}>{e.level}</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
<span className="text-[10px] text-hint">{e.detail}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
);
}

파일 보기

@ -26,6 +26,9 @@ export function FleetOptimization() {
const mapRef = useRef<MapHandle>(null);
const [simRunning, setSimRunning] = useState(false);
const [approved, setApproved] = useState(false);
const [coverageTarget, setCoverageTarget] = useState(80);
const [overlapLimit, setOverlapLimit] = useState(15);
const [activeScenario, setActiveScenario] = useState<string | null>(null);
const FLEET = useMemo(
() =>
@ -128,6 +131,31 @@ export function FleetOptimization() {
))}
</div>
{/* 저장 시나리오 & 설정 슬라이더 */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-hint font-bold"> </span>
{['주간 3함정', '야간 집중', '전해역'].map(s => (
<button type="button" key={s} onClick={() => setActiveScenario(activeScenario === s ? null : s)}
className={`px-3 py-1 rounded-full text-[10px] font-bold border transition-colors ${activeScenario === s ? 'bg-purple-600 border-purple-500 text-white' : 'bg-surface-overlay border-border text-muted-foreground hover:border-purple-500/50 hover:text-heading'}`}>{s}</button>
))}
</div>
<div className="flex items-center gap-4 ml-auto">
<div className="flex items-center gap-2">
<span className="text-[10px] text-hint whitespace-nowrap"> </span>
<input type="range" min={50} max={100} value={coverageTarget} onChange={e => setCoverageTarget(Number(e.target.value))} title="커버리지 목표 (%)"
className="w-24 h-1.5 accent-cyan-500 bg-switch-background/60 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-cyan-400" />
<span className="text-[10px] text-cyan-400 font-bold w-8">{coverageTarget}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-hint whitespace-nowrap"> </span>
<input type="range" min={0} max={30} value={overlapLimit} onChange={e => setOverlapLimit(Number(e.target.value))} title="중첩 한도 (%)"
className="w-24 h-1.5 accent-orange-500 bg-switch-background/60 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-orange-400" />
<span className="text-[10px] text-orange-400 font-bold w-8">{overlapLimit}%</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
{/* 함정 목록 */}
<Card>
@ -186,6 +214,36 @@ export function FleetOptimization() {
<span className="text-[11px] text-green-400 font-bold"> (Human in the loop)</span>
</div>
)}
{/* 함정별 배정 상세 */}
{simRunning && (
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[11px] font-bold text-heading mb-2 flex items-center gap-1.5">
<Ship className="w-3.5 h-3.5 text-cyan-400" />
</div>
<div className="space-y-1.5">
{([
{ name: '1501함', dist: 138, time: 7.2, cells: 28, color: '#ef4444' },
{ name: '1502함', dist: 112, time: 5.8, cells: 22, color: '#f97316' },
{ name: '1503함', dist: 156, time: 9.1, cells: 31, color: '#eab308' },
] as const).map(a => (
<div key={a.name} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-overlay">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: a.color }} />
<span className="text-[10px] font-bold text-heading w-14">{a.name}</span>
<div className="flex gap-3 text-[9px] text-muted-foreground">
<span>{a.dist}km</span>
<span>{a.time}h</span>
<span className="text-cyan-400">{a.cells}</span>
</div>
<div className="flex-1 flex justify-end">
<div className="h-1 w-16 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-cyan-500" style={{ width: `${Math.round((a.cells / 40) * 100)}%` }} />
</div>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
@ -218,6 +276,18 @@ export function FleetOptimization() {
</div>
</CardContent>
</Card>
{/* 일괄 액션 버튼 */}
<div className="flex items-center justify-end gap-2">
<button type="button" onClick={() => {}}
className="flex items-center gap-1.5 px-4 py-2 bg-surface-overlay border border-border hover:border-purple-500/50 text-muted-foreground hover:text-heading text-[11px] font-bold rounded-lg transition-colors">
<BarChart3 className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => {}} disabled={!approved}
className="flex items-center gap-1.5 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:opacity-30 disabled:cursor-not-allowed text-white text-[11px] font-bold rounded-lg transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

파일 보기

@ -26,6 +26,13 @@ export function PatrolRoute() {
const [selectedShip, setSelectedShip] = useState('P-3001');
const [selectedScenario, setSelectedScenario] = useState(1);
/* SFR-07 가중치 슬라이더 */
const [alpha, setAlpha] = useState(0.5);
const [beta, setBeta] = useState(0.3);
const [gamma, setGamma] = useState(0.2);
const [missionHours, setMissionHours] = useState(8);
const [activeQuickScenario, setActiveQuickScenario] = useState<string | null>(null);
const SHIPS = useMemo(
() =>
ships
@ -128,6 +135,81 @@ export function PatrolRoute() {
</div>
))}
</div>
{/* SFR-07: 가중치 슬라이더 */}
<div className="mt-4 pt-3 border-t border-border space-y-2.5">
<div className="text-[10px] font-bold text-muted-foreground mb-1"> </div>
{/* Alpha */}
<div>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-hint">α </span>
<span className="text-heading font-mono font-bold">{alpha.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value={alpha} title="위험도 가중치 (α)"
onChange={e => setAlpha(Number(e.target.value))}
className="w-full h-1 appearance-none rounded bg-red-500/30 accent-red-500 cursor-pointer [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-400 [&::-webkit-slider-thumb]:appearance-none" />
</div>
{/* Beta */}
<div>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-hint">β </span>
<span className="text-heading font-mono font-bold">{beta.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value={beta} title="거리 가중치 (β)"
onChange={e => setBeta(Number(e.target.value))}
className="w-full h-1 appearance-none rounded bg-blue-500/30 accent-blue-500 cursor-pointer [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-400 [&::-webkit-slider-thumb]:appearance-none" />
</div>
{/* Gamma */}
<div>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-hint">γ </span>
<span className="text-heading font-mono font-bold">{gamma.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value={gamma} title="시간 가중치 (γ)"
onChange={e => setGamma(Number(e.target.value))}
className="w-full h-1 appearance-none rounded bg-green-500/30 accent-green-500 cursor-pointer [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-green-400 [&::-webkit-slider-thumb]:appearance-none" />
</div>
{/* Cost function display */}
<div className="mt-2 px-2 py-1.5 bg-surface-overlay rounded text-[9px] font-mono text-center text-muted-foreground">
Cost(e) = <span className="text-red-400">{alpha.toFixed(1)}</span>·Risk + <span className="text-blue-400">{beta.toFixed(1)}</span>·Dist + <span className="text-green-400">{gamma.toFixed(1)}</span>·Time
</div>
</div>
{/* SFR-07: 임무 시간 슬라이더 */}
<div className="mt-3 pt-3 border-t border-border">
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-hint flex items-center gap-1"><Clock className="w-3 h-3" /> </span>
<span className="text-heading font-mono font-bold">{missionHours}h</span>
</div>
<input type="range" min="1" max="24" step="1" value={missionHours} title="임무 시간"
onChange={e => setMissionHours(Number(e.target.value))}
className="w-full h-1 appearance-none rounded bg-cyan-500/30 accent-cyan-500 cursor-pointer [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-cyan-400 [&::-webkit-slider-thumb]:appearance-none" />
<div className="flex justify-between text-[8px] text-hint mt-0.5">
<span>1h</span><span>12h</span><span>24h</span>
</div>
</div>
{/* SFR-07: 저장된 시나리오 퀵 버튼 */}
<div className="mt-3 pt-3 border-t border-border">
<div className="text-[9px] text-hint mb-1.5"> </div>
<div className="flex flex-wrap gap-1.5">
{[
{ key: 'night-high', label: '야간 고위험', a: 0.7, b: 0.1, g: 0.2, h: 6 },
{ key: 'fuel-eff', label: '연료 효율', a: 0.2, b: 0.5, g: 0.3, h: 12 },
{ key: 'full-area', label: '전해역 순찰', a: 0.3, b: 0.4, g: 0.3, h: 24 },
].map(sc => (
<button type="button" key={sc.key}
onClick={() => { setAlpha(sc.a); setBeta(sc.b); setGamma(sc.g); setMissionHours(sc.h); setActiveQuickScenario(sc.key); }}
className={`px-2.5 py-1 rounded-full text-[9px] font-medium transition-colors ${activeQuickScenario === sc.key ? 'bg-cyan-600 text-white' : 'bg-surface-overlay text-muted-foreground border border-border hover:border-cyan-500/40 hover:text-heading'}`}>
{sc.label}
</button>
))}
</div>
</div>
</CardContent>
</Card>
@ -215,6 +297,45 @@ export function PatrolRoute() {
</div>
</CardContent>
</Card>
{/* SFR-07: 결과 통계 패널 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<BarChart3 className="w-4 h-4 text-cyan-400" />
</div>
<div className="grid grid-cols-4 gap-3">
<div className="bg-surface-overlay rounded-lg p-3 text-center">
<div className="text-[9px] text-hint mb-1"> </div>
<div className="text-lg font-bold text-heading">142<span className="text-[10px] text-muted-foreground ml-0.5">km</span></div>
<div className="text-[8px] text-muted-foreground mt-0.5"> </div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 text-center">
<div className="text-[9px] text-hint mb-1"> </div>
<div className="text-lg font-bold text-heading">7.8<span className="text-[10px] text-muted-foreground ml-0.5">h</span></div>
<div className="text-[8px] text-muted-foreground mt-0.5"> </div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 text-center">
<div className="text-[9px] text-hint mb-1"> </div>
<div className="text-lg font-bold text-cyan-400">78<span className="text-[10px] text-cyan-400/70 ml-0.5">%</span></div>
<div className="text-[8px] text-muted-foreground mt-0.5"> </div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 text-center">
<div className="text-[9px] text-hint mb-1"> </div>
<div className="text-lg font-bold text-green-400">+23<span className="text-[10px] text-green-400/70 ml-0.5">%</span></div>
<div className="text-[8px] text-muted-foreground mt-0.5"> </div>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border">
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-surface-overlay border border-border rounded-lg text-[11px] text-muted-foreground hover:text-heading hover:border-cyan-500/40 transition-colors">
<Settings className="w-3.5 h-3.5" />
</button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white text-[11px] font-bold rounded-lg transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
</button>
</div>
</CardContent>
</Card>
</div>
);
}

파일 보기

@ -29,8 +29,8 @@ function toPlan(p: EnforcementPlanApi): Plan {
}
const cols: DataColumn<Plan>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'id', label: 'ID', width: '180px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'zone', label: '단속 구역', width: '180px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <Badge className={`border-0 text-[9px] ${n > 80 ? 'bg-red-500/20 text-red-400' : n > 60 ? 'bg-orange-500/20 text-orange-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{n}</Badge>; } },
{ key: 'period', label: '단속 시간', width: '160px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },

파일 보기

@ -2,7 +2,7 @@ import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp, FileText, FileDown, Filter, X, ChevronRight, Search, Check, Eye, EyeOff, Crosshair } from 'lucide-react';
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts';
const MTIS_BADGE = (
@ -120,6 +120,24 @@ const ACC_RATE = [
{ type: '레저', registered: 28900, accidents: 178, rate: 0.62 },
];
// ─── SHAP 영향인자 (격자 상세) ──────────────────
const SHAP_FACTORS = [
{ key: 'ais', label: 'AIS 패턴', value: 0.34, color: '#ef4444' },
{ key: 'past', label: '과거단속', value: 0.25, color: '#f97316' },
{ key: 'weather', label: '기상', value: 0.18, color: '#eab308' },
{ key: 'fishing', label: '어장정보', value: 0.13, color: '#3b82f6' },
{ key: 'season', label: '계절요인', value: 0.10, color: '#8b5cf6' },
];
// ─── 단속 이력 (격자 상세) ──────────────────
const ENFORCEMENT_HISTORY = [
{ date: '2026-04-06 14:32', result: '단속 성공 — 중국어선 2척 나포', status: 'success' as const },
{ date: '2026-03-28 09:15', result: '단속 출동 — 도주하여 미검거', status: 'fail' as const },
{ date: '2026-03-15 22:47', result: '단속 성공 — 무허가 조업 1척', status: 'success' as const },
{ date: '2026-02-20 06:10', result: '경고 조치 — EEZ 이탈 경고', status: 'warn' as const },
{ date: '2026-01-11 18:55', result: '단속 성공 — 불법어구 사용 1척', status: 'success' as const },
];
// ─── 히트맵 포인트 데이터 (한반도 주변 해역) ──────
// [lat, lng, intensity] — 서해 NLL·EEZ 위주 고위험 분포
function generateHeatPoints(): [number, number, number][] {
@ -164,6 +182,30 @@ export function RiskMap() {
const [tab, setTab] = useState<Tab>('heatmap');
const mapRef = useRef<MapHandle>(null);
// ── SFR-05 패널 상태 ──
const [filterOpen, setFilterOpen] = useState(true);
const [gridDetailOpen, setGridDetailOpen] = useState(false);
const [forecastHour, setForecastHour] = useState(0);
const [selectedArea, setSelectedArea] = useState<'border' | 'coastal' | 'offshore'>('border');
const [riskGrades, setRiskGrades] = useState<Set<number>>(new Set([1, 2, 3, 4, 5]));
const [layerToggles, setLayerToggles] = useState({ weather: true, fishing: true, shipPos: false });
const [selectedGridId] = useState('GRID-37.5N-126.8E');
const [selectedGridScore] = useState(0.893);
const [selectedGridLevel] = useState(5);
const toggleRiskGrade = (grade: number) => {
setRiskGrades(prev => {
const next = new Set(prev);
if (next.has(grade)) next.delete(grade);
else next.add(grade);
return next;
});
};
const toggleLayer = (key: keyof typeof layerToggles) => {
setLayerToggles(prev => ({ ...prev, [key]: !prev[key] }));
};
const buildLayers = useCallback(() => {
if (tab !== 'heatmap') return [];
return [
@ -185,8 +227,10 @@ export function RiskMap() {
<p className="text-[10px] text-hint mt-0.5">SFR-05 | ( ) + MTIS ()</p>
</div>
<div className="flex gap-1.5">
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><FileText className="w-3 h-3" />PDF</button>
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><FileDown className="w-3 h-3" />CSV</button>
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Download className="w-3 h-3" />GeoJSON</button>
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Printer className="w-3 h-3" /></button>
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Download className="w-3 h-3" /></button>
</div>
</div>
@ -228,71 +272,283 @@ export function RiskMap() {
))}
</div>
<div className="grid grid-cols-3 gap-3">
{/* 지도 히트맵 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-0 relative">
<BaseMap
ref={mapRef}
center={[35.8, 127.0]}
zoom={7}
height={480}
className="w-full rounded-lg overflow-hidden"
/>
{/* 범례 오버레이 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="flex items-center gap-1">
<span className="text-[8px] text-blue-400"></span>
<div className="w-32 h-2.5 rounded-full" style={{
background: 'linear-gradient(to right, #1e40af, #3b82f6, #22c55e, #eab308, #f97316, #ef4444)',
}} />
<span className="text-[8px] text-red-400"></span>
<div className="flex gap-3">
{/* ── 좌측 필터 패널 (SFR-05) ── */}
{filterOpen && (
<Card className="w-56 shrink-0 bg-surface-raised border-border">
<CardContent className="p-3 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-heading">
<Filter className="w-3.5 h-3.5 text-blue-400" />
</div>
<button onClick={() => setFilterOpen(false)} title="필터 닫기" className="p-0.5 rounded hover:bg-surface-overlay text-hint hover:text-heading">
<X className="w-3 h-3" />
</button>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/60" /><span className="text-[8px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/80" /><span className="text-[8px] text-hint">NLL</span></div>
</div>
</div>
</CardContent>
</Card>
{/* 해역별 위험도 사이드 패널 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-2">
{ZONE_SUMMARY.map(z => (
<div key={z.zone} className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-16 truncate">{z.zone}</span>
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${z.risk}%`, backgroundColor: z.risk > 70 ? '#ef4444' : z.risk > 50 ? '#f97316' : '#22c55e' }} />
{/* 시간대 예측 시점 */}
<div>
<label htmlFor="forecast-slider" className="text-[10px] text-muted-foreground font-medium mb-1.5 block"> </label>
<input
id="forecast-slider"
title="예측 시점 선택"
type="range" min={0} max={72} step={6} value={forecastHour}
onChange={e => setForecastHour(Number(e.target.value))}
className="w-full h-1.5 accent-blue-500 bg-switch-background rounded-full cursor-pointer"
/>
<div className="flex justify-between text-[9px] text-hint mt-1">
<span></span>
<span className="text-blue-400 font-bold">+{forecastHour}h</span>
<span>+72h</span>
</div>
<span className="text-[10px] text-heading font-bold w-6 text-right">{z.risk}</span>
<span className={`text-[9px] w-6 ${z.trend.startsWith('+') ? 'text-red-400' : 'text-green-400'}`}>{z.trend}</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-1.5">
{RISK_LEVELS.map(r => (
<div key={r.level} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: r.color }} />
<span className="text-[10px] text-muted-foreground flex-1">{r.label}</span>
<span className="text-[10px] text-heading font-bold">{r.count}</span>
<span className="text-[9px] text-hint">{r.pct}%</span>
{/* 해역 선택 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1.5 block"> </label>
<div className="flex gap-1">
{([
{ key: 'border' as const, label: '접경해역' },
{ key: 'coastal' as const, label: '연안' },
{ key: 'offshore' as const, label: '원해' },
]).map(a => (
<button key={a.key} onClick={() => setSelectedArea(a.key)}
className={`flex-1 px-1.5 py-1.5 rounded-lg text-[9px] font-medium border transition-colors ${
selectedArea === a.key
? 'bg-blue-500/20 border-blue-500/50 text-blue-300'
: 'bg-surface-overlay border-border text-hint hover:text-label'
}`}>
{a.label}
</button>
))}
</div>
))}
</div>
</div>
{/* 위험등급 필터 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1.5 block"> </label>
<div className="space-y-1">
{RISK_LEVELS.map(r => (
<label key={r.level} className="flex items-center gap-2 cursor-pointer group">
<div
onClick={() => toggleRiskGrade(r.level)}
className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-colors ${
riskGrades.has(r.level)
? 'border-transparent'
: 'border-border bg-surface-overlay'
}`}
style={riskGrades.has(r.level) ? { backgroundColor: r.color } : undefined}
>
{riskGrades.has(r.level) && <Check className="w-2.5 h-2.5 text-white" />}
</div>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: r.color }} />
<span className="text-[10px] text-muted-foreground group-hover:text-label flex-1">{r.level} {r.label}</span>
</label>
))}
</div>
</div>
{/* 레이어 토글 */}
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1.5 block"></label>
<div className="space-y-1">
{([
{ key: 'weather' as const, label: '기상', icon: '🌤' },
{ key: 'fishing' as const, label: '어장', icon: '🐟' },
{ key: 'shipPos' as const, label: '함정위치', icon: '🚢' },
]).map(l => (
<button key={l.key} onClick={() => toggleLayer(l.key)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-[10px] border transition-colors ${
layerToggles[l.key]
? 'bg-blue-500/10 border-blue-500/30 text-blue-300'
: 'bg-surface-overlay border-border text-hint'
}`}>
{layerToggles[l.key] ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
<span>{l.icon}</span>
<span>{l.label}</span>
</button>
))}
</div>
</div>
{/* 조회 버튼 */}
<button className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold rounded-lg transition-colors">
<Search className="w-3.5 h-3.5" />
</button>
</CardContent>
</Card>
)}
{/* 필터 패널 닫혀있을 때 열기 버튼 */}
{!filterOpen && (
<button
onClick={() => setFilterOpen(true)}
className="shrink-0 flex items-center justify-center w-8 h-8 mt-2 bg-surface-overlay border border-border rounded-lg text-hint hover:text-heading"
title="필터 열기"
>
<Filter className="w-3.5 h-3.5" />
</button>
)}
{/* ── 중앙: 지도 + 해역 패널 (기존) ── */}
<div className="flex-1 min-w-0">
<div className="grid grid-cols-3 gap-3">
{/* 지도 히트맵 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-0 relative">
<BaseMap
ref={mapRef}
center={[35.8, 127.0]}
zoom={7}
height={480}
className="w-full rounded-lg overflow-hidden"
/>
{/* 격자 클릭 시뮬레이션 버튼 */}
<button
onClick={() => setGridDetailOpen(true)}
className="absolute top-3 right-3 z-[1000] flex items-center gap-1 px-2 py-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg text-[9px] text-muted-foreground hover:text-heading transition-colors"
>
<Crosshair className="w-3 h-3" />
</button>
{/* 범례 오버레이 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="flex items-center gap-1">
<span className="text-[8px] text-blue-400"></span>
<div className="w-32 h-2.5 rounded-full" style={{
background: 'linear-gradient(to right, #1e40af, #3b82f6, #22c55e, #eab308, #f97316, #ef4444)',
}} />
<span className="text-[8px] text-red-400"></span>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/60" /><span className="text-[8px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/80" /><span className="text-[8px] text-hint">NLL</span></div>
</div>
</div>
</CardContent>
</Card>
{/* 해역별 위험도 사이드 패널 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-2">
{ZONE_SUMMARY.map(z => (
<div key={z.zone} className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-16 truncate">{z.zone}</span>
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${z.risk}%`, backgroundColor: z.risk > 70 ? '#ef4444' : z.risk > 50 ? '#f97316' : '#22c55e' }} />
</div>
<span className="text-[10px] text-heading font-bold w-6 text-right">{z.risk}</span>
<span className={`text-[9px] w-6 ${z.trend.startsWith('+') ? 'text-red-400' : 'text-green-400'}`}>{z.trend}</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[12px] font-bold text-heading mb-2"> </div>
<div className="space-y-1.5">
{RISK_LEVELS.map(r => (
<div key={r.level} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: r.color }} />
<span className="text-[10px] text-muted-foreground flex-1">{r.label}</span>
<span className="text-[10px] text-heading font-bold">{r.count}</span>
<span className="text-[9px] text-hint">{r.pct}%</span>
</div>
))}
</div>
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[9px] text-hint">
단위: 1km × 1km<br/>
주기: 6시간<br/>
기반: AIS·SAR··VMS
</div>
</div>
</CardContent></Card>
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[9px] text-hint">
단위: 1km × 1km<br/>
주기: 6시간<br/>
기반: AIS·SAR··VMS
</div>
</div>
</CardContent></Card>
</div>
{/* ── 우측 격자 상세 패널 (SFR-05) ── */}
{gridDetailOpen && (
<Card className="w-72 shrink-0 bg-surface-raised border-border">
<CardContent className="p-3 space-y-4 max-h-[540px] overflow-y-auto">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-heading">
<Crosshair className="w-3.5 h-3.5 text-red-400" />
</div>
<button onClick={() => setGridDetailOpen(false)} title="격자 상세 닫기" className="p-0.5 rounded hover:bg-surface-overlay text-hint hover:text-heading">
<X className="w-3 h-3" />
</button>
</div>
{/* Grid ID */}
<div className="px-2.5 py-2 bg-surface-overlay rounded-lg border border-border">
<div className="text-[9px] text-hint mb-0.5"> ID</div>
<div className="text-[11px] text-heading font-mono font-bold">{selectedGridId}</div>
</div>
{/* 위험등급 배지 + 점수 */}
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg border" style={{
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.3)',
}}>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-500/20">
<span className="text-sm font-black text-red-400">{selectedGridLevel}</span>
</div>
<div>
<div className="text-[10px] text-red-300 font-bold"></div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="ml-auto text-lg font-black text-red-400">{selectedGridScore}</div>
</div>
{/* SHAP 영향인자 바 */}
<div>
<div className="text-[10px] text-muted-foreground font-medium mb-2">SHAP </div>
<div className="space-y-1.5">
{SHAP_FACTORS.map(f => (
<div key={f.key}>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-muted-foreground">{f.label}</span>
<span className="text-[9px] text-heading font-bold">{(f.value * 100).toFixed(0)}%</span>
</div>
<div className="h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${f.value * 100}%`, backgroundColor: f.color }}
/>
</div>
</div>
))}
</div>
</div>
{/* 단속 이력 타임라인 */}
<div>
<div className="text-[10px] text-muted-foreground font-medium mb-2"> </div>
<div className="space-y-0 relative">
{/* 타임라인 수직선 */}
<div className="absolute left-[5px] top-1 bottom-1 w-px bg-border" />
{ENFORCEMENT_HISTORY.map((h, i) => (
<div key={i} className="flex gap-2.5 py-1.5 relative">
<div className={`w-2.5 h-2.5 rounded-full shrink-0 mt-0.5 z-10 ring-2 ring-background ${
h.status === 'success' ? 'bg-green-500' : h.status === 'fail' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<div className="min-w-0">
<div className="text-[9px] text-hint">{h.date}</div>
<div className="text-[10px] text-label leading-tight">{h.result}</div>
</div>
</div>
))}
</div>
</div>
{/* 단속 출동 버튼 */}
<button className="w-full flex items-center justify-center gap-1.5 px-3 py-2.5 bg-red-600 hover:bg-red-500 text-white text-[11px] font-bold rounded-lg transition-colors">
<ChevronRight className="w-3.5 h-3.5" />
</button>
</CardContent>
</Card>
)}
</div>
</div>
)}

파일 보기

@ -1,54 +1,718 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock } from 'lucide-react';
import {
Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock,
Eye, EyeOff, FileText, Settings, Bell, RefreshCw, Download,
AlertTriangle, CheckCircle, XCircle, Filter, Search, TrendingUp,
ArrowUpRight, Calendar, Users, Database, Code, FileSpreadsheet,
} from 'lucide-react';
import { BarChart, LineChart } from '@lib/charts';
/* SFR-14: 외부 서비스(예보·경보) 제공 결과 연계 */
/**
* SFR-14: 외부 (·)
*
* RFP :
* · , ·
* API/
*
*
*/
interface Service { id: string; name: string; target: string; type: string; format: string; cycle: string; privacy: string; status: string; calls: string; [key: string]: unknown; }
const DATA: Service[] = [
{ id: 'EXT-01', name: '위험도 지도 제공', target: '해수부', type: 'API', format: 'JSON', cycle: '1시간', privacy: '비식별', status: '운영', calls: '12,450' },
{ id: 'EXT-02', name: '의심 선박 목록', target: '해수부', type: 'API', format: 'JSON', cycle: '실시간', privacy: '비식별', status: '운영', calls: '8,320' },
{ id: 'EXT-03', name: '단속 통계', target: '수협', type: '파일', format: 'Excel', cycle: '일 1회', privacy: '익명화', status: '운영', calls: '365' },
{ id: 'EXT-04', name: '어구 현황', target: '해양조사원', type: 'API', format: 'JSON', cycle: '6시간', privacy: '공개', status: '테스트', calls: '540' },
{ id: 'EXT-05', name: '경보 이력', target: '기상청', type: 'API', format: 'XML', cycle: '실시간', privacy: '비공개', status: '계획', calls: '-' },
// ─── Mock 데이터 ───
const SERVICES = [
{ id: 'EXT-01', name: '위험도 지도 제공', target: '해수부', type: 'REST API', format: 'JSON', cycle: '1시간', privacy: '비식별', status: '운영', calls: 12450, lastUpdate: '2026-04-08 14:00', endpoint: '/api/v1/risk-map' },
{ id: 'EXT-02', name: '의심 선박 목록', target: '해수부', type: 'REST API', format: 'JSON', cycle: '실시간', privacy: '비식별', status: '운영', calls: 8320, lastUpdate: '2026-04-08 14:28', endpoint: '/api/v1/suspect-vessels' },
{ id: 'EXT-03', name: '단속 통계 리포트', target: '수협', type: '파일(SFTP)', format: 'Excel', cycle: '일 1회', privacy: '익명화', status: '운영', calls: 365, lastUpdate: '2026-04-08 06:00', endpoint: '/sftp/reports/daily/' },
{ id: 'EXT-04', name: '어구 현황 데이터', target: '해양조사원', type: 'REST API', format: 'JSON', cycle: '6시간', privacy: '공개', status: '운영', calls: 2540, lastUpdate: '2026-04-08 12:00', endpoint: '/api/v1/gear-status' },
{ id: 'EXT-05', name: '경보 발령 이력', target: '기상청', type: 'REST API', format: 'XML', cycle: '실시간', privacy: '비공개', status: '테스트', calls: 540, lastUpdate: '2026-04-08 14:28', endpoint: '/api/v1/alerts' },
{ id: 'EXT-06', name: '예측 위험도 예보', target: '해경 내부', type: 'WebSocket', format: 'JSON', cycle: '실시간', privacy: '비공개', status: '운영', calls: 45200, lastUpdate: '2026-04-08 14:30', endpoint: 'wss://push/risk-forecast' },
{ id: 'EXT-07', name: '단속 사례 공유', target: '유관기관', type: '파일(API)', format: 'PDF', cycle: '주 1회', privacy: '익명화', status: '운영', calls: 52, lastUpdate: '2026-04-07 09:00', endpoint: '/api/v1/case-reports' },
];
const cols: DataColumn<Service>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'name', label: '서비스명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'target', label: '제공 대상', width: '80px', sortable: true },
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'format', label: '포맷', width: '60px', align: 'center' },
{ key: 'cycle', label: '갱신주기', width: '70px' },
{ key: 'privacy', label: '정보등급', width: '70px', align: 'center',
render: v => { const p = v as string; const c = p === '비공개' ? 'bg-red-500/20 text-red-400' : p === '비식별' ? 'bg-yellow-500/20 text-yellow-400' : p === '익명화' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
{ key: 'status', label: '상태', width: '60px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '운영' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
{ key: 'calls', label: '호출 수', width: '70px', align: 'right', render: v => <span className="text-heading font-bold">{v as string}</span> },
const MONTHLY_CALLS = [
{ m: '10월', calls: 15200, orgs: 12 }, { m: '11월', calls: 18400, orgs: 14 },
{ m: '12월', calls: 22100, orgs: 15 }, { m: '1월', calls: 19800, orgs: 14 },
{ m: '2월', calls: 25600, orgs: 16 }, { m: '3월', calls: 28300, orgs: 18 },
];
const PRIVACY_POLICY = [
{ level: '공개', desc: '비식별 처리 없이 공개 가능한 일반 통계·예보 정보', color: '#22c55e', icon: Unlock, fields: '해역 위험도 등급, 공개 통계 요약, 기상 정보' },
{ level: '비식별', desc: '개인·선박 식별정보를 제거한 후 제공 (k-익명성 적용)', color: '#eab308', icon: Eye, fields: 'MMSI 해시화, 좌표 격자화(1km), 시간 라운딩(1h)' },
{ level: '익명화', desc: '통계적 재식별 불가능 수준으로 변환 (차분 프라이버시)', color: '#3b82f6', icon: EyeOff, fields: '집계 통계만 제공, 개별 건 비노출, 노이즈 추가' },
{ level: '비공개', desc: '내부 전용 — 외부 제공 불가 (보안 등급 데이터)', color: '#ef4444', icon: Lock, fields: '원본 AIS, 실시간 위치, 단속 요원 정보, 수사 자료' },
];
type Tab = 'overview' | 'privacy' | 'api' | 'usage' | 'schedule' | 'collect' | 'load' | 'monitor';
export function ExternalService() {
const { t } = useTranslation('statistics');
const [tab, setTab] = useState<Tab>('overview');
const privacyBadge = (p: string) => {
const m: Record<string, string> = { '공개': 'bg-green-500/20 text-green-400', '비식별': 'bg-yellow-500/20 text-yellow-400', '익명화': 'bg-blue-500/20 text-blue-400', '비공개': 'bg-red-500/20 text-red-400' };
return m[p] ?? 'bg-slate-500/20 text-slate-400';
};
const statusBadge = (s: string) => s === '운영' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-slate-500/20 text-slate-400';
const tabCls = (active: boolean) => `px-4 py-2.5 text-xs font-medium transition-colors border-b-2 ${active ? 'border-blue-500 text-blue-400' : 'border-transparent text-hint hover:text-label'}`;
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
<div className="p-6 space-y-5 max-w-[1400px] mx-auto">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading flex items-center gap-2">
<Globe className="w-6 h-6 text-green-400" /> {t('externalService.title')}
</h1>
<p className="text-xs text-hint mt-1">SFR-14 | , </p>
</div>
</div>
<div className="flex gap-2">
{[{ l: '운영 서비스', v: DATA.filter(d => d.status === '운영').length, c: 'text-green-400' }, { l: '테스트', v: DATA.filter(d => d.status === '테스트').length, c: 'text-blue-400' }, { l: '총 호출', v: '21,675', c: 'text-heading' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
{/* KPI */}
<div className="grid grid-cols-5 gap-3">
{[
{ label: '운영 서비스', value: SERVICES.filter(s => s.status === '운영').length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '연계 기관', value: '6개', icon: Users, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '총 API 호출', value: '69,267', icon: Database, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '비식별 적용률', value: '85.7%', icon: Shield, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '갱신 정상률', value: '100%', icon: RefreshCw, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
].map(k => (
<div key={k.label} className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${k.bg} flex items-center justify-center`}>
<k.icon className={`w-5 h-5 ${k.color}`} />
</div>
<div>
<div className={`text-lg font-bold ${k.color}`}>{k.value}</div>
<div className="text-[10px] text-hint">{k.label}</div>
</div>
</div>
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="서비스명, 대상기관 검색..." searchKeys={['name', 'target']} exportFilename="외부서비스연계" />
{/* 탭 */}
<div className="flex border-b border-border">
{([
{ key: 'overview' as Tab, label: '서비스 현황' },
{ key: 'privacy' as Tab, label: '① 정보등급·비식별 정책' },
{ key: 'api' as Tab, label: '② API/인터페이스 정의' },
{ key: 'usage' as Tab, label: '③ 이용 현황 분석' },
{ key: 'schedule' as Tab, label: '④ 갱신 주기·공지' },
{ key: 'collect' as Tab, label: '수집 작업 관리' },
{ key: 'load' as Tab, label: '적재 작업 관리' },
{ key: 'monitor' as Tab, label: '연계서버 모니터링' },
]).map(tb => (
<button key={tb.key} type="button" onClick={() => setTab(tb.key)} className={tabCls(tab === tb.key)}>{tb.label}</button>
))}
</div>
{/* ══ 서비스 현황 ══ */}
{tab === 'overview' && (
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-surface-overlay border-b border-border">
{['ID', '서비스명', '제공 대상', '방식', '포맷', '갱신주기', '정보등급', '상태', '호출 수', '최종 갱신'].map(h => (
<th key={h} className="text-left px-4 py-3 text-hint font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{SERVICES.map(s => (
<tr key={s.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-3 text-hint font-mono text-[10px]">{s.id}</td>
<td className="px-4 py-3 text-heading font-medium">{s.name}</td>
<td className="px-4 py-3 text-label">{s.target}</td>
<td className="px-4 py-3"><Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{s.type}</Badge></td>
<td className="px-4 py-3 text-label">{s.format}</td>
<td className="px-4 py-3 text-label">{s.cycle}</td>
<td className="px-4 py-3"><Badge className={`border-0 text-[9px] ${privacyBadge(s.privacy)}`}>{s.privacy}</Badge></td>
<td className="px-4 py-3"><Badge className={`border-0 text-[9px] ${statusBadge(s.status)}`}>{s.status}</Badge></td>
<td className="px-4 py-3 text-heading font-bold text-right">{s.calls.toLocaleString()}</td>
<td className="px-4 py-3 text-hint text-[10px]">{s.lastUpdate}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ══ ① 정보등급·비식별 정책 ══ */}
{tab === 'privacy' && (
<div className="space-y-4">
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Shield className="w-4 h-4 text-yellow-400" /> · </h3>
</div>
<div className="p-6 space-y-4">
{PRIVACY_POLICY.map(p => (
<div key={p.level} className="flex items-start gap-4 p-4 rounded-xl border border-border bg-surface-overlay/30">
<div className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: `${p.color}15` }}>
<p.icon className="w-6 h-6" style={{ color: p.color }} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Badge className="border-0 text-xs font-bold px-2.5 py-0.5" style={{ backgroundColor: `${p.color}20`, color: p.color }}>{p.level}</Badge>
<span className="text-xs text-heading font-medium">{p.desc}</span>
</div>
<div className="text-[11px] text-hint mt-1">
<span className="font-medium text-label"> :</span> {p.fields}
</div>
</div>
</div>
))}
</div>
</div>
{/* 서비스별 적용 현황 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading"> </h3>
</div>
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['서비스명', '정보등급', '비식별 방법', 'k-익명성', '적용 필드', '검증 상태'].map(h => (
<th key={h} className="text-left px-5 py-3 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ name: '위험도 지도', level: '비식별', method: 'k-익명성 + 격자화', k: 'k=5', fields: 'MMSI→해시, 좌표→1km격자', verified: true },
{ name: '의심 선박 목록', level: '비식별', method: 'k-익명성 + 시간 라운딩', k: 'k=5', fields: 'MMSI→해시, 시간→1h단위', verified: true },
{ name: '단속 통계', level: '익명화', method: '차분 프라이버시', k: 'ε=1.0', fields: '집계만, 개별건 비노출', verified: true },
{ name: '어구 현황', level: '공개', method: '-', k: '-', fields: '해역별 통계 (식별정보 없음)', verified: true },
{ name: '경보 이력', level: '비공개', method: '외부 비제공', k: '-', fields: '-', verified: false },
].map(r => (
<tr key={r.name} className="border-b border-border hover:bg-surface-overlay/50">
<td className="px-5 py-3 text-heading font-medium">{r.name}</td>
<td className="px-5 py-3"><Badge className={`border-0 text-[9px] ${privacyBadge(r.level)}`}>{r.level}</Badge></td>
<td className="px-5 py-3 text-label">{r.method}</td>
<td className="px-5 py-3 text-cyan-400 font-mono">{r.k}</td>
<td className="px-5 py-3 text-hint text-[10px]">{r.fields}</td>
<td className="px-5 py-3">{r.verified ? <CheckCircle className="w-4 h-4 text-green-400" /> : <XCircle className="w-4 h-4 text-red-400" />}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ══ ② API/인터페이스 정의 ══ */}
{tab === 'api' && (
<div className="space-y-4">
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Code className="w-4 h-4 text-cyan-400" /> API / </h3>
</div>
<div className="p-6 space-y-3">
{SERVICES.map(s => (
<div key={s.id} className="border border-border rounded-xl p-4 hover:bg-surface-overlay/30 transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[10px] font-bold">{s.type}</Badge>
<span className="text-sm font-bold text-heading">{s.name}</span>
<Badge className={`border-0 text-[9px] ${statusBadge(s.status)}`}>{s.status}</Badge>
</div>
<span className="text-[10px] text-hint"> {s.target}</span>
</div>
<div className="bg-background rounded-lg px-4 py-2 font-mono text-[11px] text-cyan-400 flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-hint" />
{s.endpoint}
<span className="ml-auto text-hint">: {s.format}</span>
</div>
<div className="flex items-center gap-4 mt-2 text-[10px] text-hint">
<span className="flex items-center gap-1"><RefreshCw className="w-3 h-3" /> : {s.cycle}</span>
<span className="flex items-center gap-1"><Shield className="w-3 h-3" /> {s.privacy}</span>
<span className="flex items-center gap-1"><BarChart3 className="w-3 h-3" /> : {s.calls.toLocaleString()}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ══ ③ 이용 현황 분석 ══ */}
{tab === 'usage' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><BarChart3 className="w-4 h-4 text-blue-400" /> API </h3>
</div>
<div className="p-6">
<BarChart data={MONTHLY_CALLS} xKey="m" height={220} series={[{ key: 'calls', name: 'API 호출 수', color: '#3b82f6' }]} />
</div>
</div>
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><TrendingUp className="w-4 h-4 text-green-400" /> </h3>
</div>
<div className="p-6">
<LineChart data={MONTHLY_CALLS} xKey="m" height={220} series={[{ key: 'orgs', name: '연계 기관 수', color: '#22c55e' }]} />
</div>
</div>
</div>
{/* 서비스별 이용 현황 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading"> </h3>
</div>
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['서비스명', '제공 대상', '총 호출', '일평균', '피크 시간', '오류율', '전월대비'].map(h => (
<th key={h} className="text-left px-5 py-3 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ name: '예측 위험도 예보', target: '해경 내부', calls: '45,200', avg: '1,506', peak: '08:00~10:00', error: '0.02%', delta: '+18%' },
{ name: '위험도 지도', target: '해수부', calls: '12,450', avg: '415', peak: '09:00~11:00', error: '0.05%', delta: '+12%' },
{ name: '의심 선박 목록', target: '해수부', calls: '8,320', avg: '277', peak: '10:00~14:00', error: '0.03%', delta: '+8%' },
{ name: '어구 현황', target: '해양조사원', calls: '2,540', avg: '85', peak: '07:00~09:00', error: '0.1%', delta: '+22%' },
{ name: '단속 통계', target: '수협', calls: '365', avg: '12', peak: '06:00', error: '0%', delta: '+5%' },
].map(r => (
<tr key={r.name} className="border-b border-border hover:bg-surface-overlay/50">
<td className="px-5 py-3 text-heading font-medium">{r.name}</td>
<td className="px-5 py-3 text-label">{r.target}</td>
<td className="px-5 py-3 text-heading font-bold">{r.calls}</td>
<td className="px-5 py-3 text-label">{r.avg}</td>
<td className="px-5 py-3 text-hint">{r.peak}</td>
<td className="px-5 py-3 text-green-400">{r.error}</td>
<td className="px-5 py-3"><span className="text-green-400 font-bold flex items-center gap-0.5"><ArrowUpRight className="w-3 h-3" />{r.delta}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ══ ④ 갱신 주기·공지 ══ */}
{tab === 'schedule' && (
<div className="space-y-4">
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><RefreshCw className="w-4 h-4 text-cyan-400" /> </h3>
</div>
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['서비스명', '갱신 주기', '최종 갱신', '다음 갱신 예정', '갱신 상태', '연속 성공', '설정'].map(h => (
<th key={h} className="text-left px-5 py-3 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ name: '위험도 지도', cycle: '1시간', last: '2026-04-08 14:00', next: '2026-04-08 15:00', ok: true, streak: '720회' },
{ name: '의심 선박 목록', cycle: '실시간', last: '2026-04-08 14:28', next: '실시간 (이벤트)', ok: true, streak: '연속' },
{ name: '단속 통계', cycle: '일 1회 (06:00)', last: '2026-04-08 06:00', next: '2026-04-09 06:00', ok: true, streak: '365회' },
{ name: '어구 현황', cycle: '6시간', last: '2026-04-08 12:00', next: '2026-04-08 18:00', ok: true, streak: '480회' },
{ name: '단속 사례 공유', cycle: '주 1회 (월 09:00)', last: '2026-04-07 09:00', next: '2026-04-14 09:00', ok: true, streak: '52회' },
].map(r => (
<tr key={r.name} className="border-b border-border hover:bg-surface-overlay/50">
<td className="px-5 py-3 text-heading font-medium">{r.name}</td>
<td className="px-5 py-3"><Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{r.cycle}</Badge></td>
<td className="px-5 py-3 text-label font-mono text-[10px]">{r.last}</td>
<td className="px-5 py-3 text-label font-mono text-[10px]">{r.next}</td>
<td className="px-5 py-3">{r.ok ? <span className="flex items-center gap-1 text-green-400 text-[10px] font-bold"><CheckCircle className="w-3.5 h-3.5" /></span> : <span className="flex items-center gap-1 text-red-400"><XCircle className="w-3.5 h-3.5" /></span>}</td>
<td className="px-5 py-3 text-hint">{r.streak}</td>
<td className="px-5 py-3"><button type="button" className="p-1 rounded hover:bg-surface-overlay text-hint hover:text-heading"><Settings className="w-3.5 h-3.5" /></button></td>
</tr>
))}
</tbody>
</table>
</div>
{/* 공지 관리 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Bell className="w-4 h-4 text-yellow-400" /> </h3>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-white font-medium transition-colors">+ </button>
</div>
<div className="p-6 space-y-3">
{[
{ date: '2026-04-08', title: '[예정] 위험도 지도 API v2.0 업그레이드', type: '변경', desc: '응답 포맷 변경 (GeoJSON 추가). 기존 v1.0은 2026-05-01까지 병행 운영 후 폐기.' },
{ date: '2026-04-05', title: '[완료] 단속 통계 리포트 필드 추가', type: '변경', desc: 'AI 매칭률, 오탐율 필드 추가. Excel 템플릿 업데이트.' },
{ date: '2026-04-01', title: '[공지] 2분기 갱신 주기 조정 안내', type: '공지', desc: '어구 현황 데이터 갱신 주기: 6시간 → 3시간으로 단축 (4/15~).' },
{ date: '2026-03-25', title: '[공지] 연계 기관 추가: 해양환경공단', type: '공지', desc: '해양환경공단 연계 승인. 해양오염 데이터 수신 인터페이스 추가 예정.' },
].map(n => (
<div key={n.title} className="flex items-start gap-3 px-4 py-3 bg-surface-overlay/50 rounded-xl border border-border">
<span className="text-[10px] text-hint font-mono w-20 shrink-0 pt-0.5">{n.date}</span>
<Badge className={`border-0 text-[9px] shrink-0 ${n.type === '변경' ? 'bg-orange-500/20 text-orange-400' : 'bg-blue-500/20 text-blue-400'}`}>{n.type}</Badge>
<div className="flex-1">
<div className="text-xs text-heading font-medium">{n.title}</div>
<div className="text-[10px] text-hint mt-0.5">{n.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ══ 수집 작업 관리 ══ */}
{tab === 'collect' && (
<div className="space-y-4">
{/* 필터 바 */}
<div className="bg-card border border-border rounded-2xl px-6 py-4 flex items-center gap-3">
<div className="flex gap-1.5">
{['전체', 'SQL', 'FILE', 'FTP'].map(t => (
<button key={t} type="button" className="px-3 py-1.5 rounded-lg text-[10px] font-medium bg-surface-overlay border border-border text-hint hover:text-heading hover:bg-blue-500/10 transition-colors">
{t} <span className="ml-1 text-[9px] text-blue-400">10</span>
</button>
))}
</div>
<div className="w-px h-6 bg-border" />
<div className="flex gap-1.5">
{['전체', '정지', '대기중', '수행중', '장애발생'].map(s => (
<button key={s} type="button" className={`px-3 py-1.5 rounded-lg text-[10px] font-medium border transition-colors ${s === '장애발생' ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-surface-overlay border-border text-hint hover:text-heading'}`}>
{s}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<input type="text" placeholder="작업명 검색..." className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[10px] text-heading placeholder:text-hint w-48 focus:outline-none focus:border-blue-500/60" />
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-white font-bold transition-colors">+ </button>
</div>
</div>
{/* 수집 작업 목록 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['작업 ID', '작업명', '수집 서버', '타입', '수집 위치', '스케줄', '상태', '최종 수행', '결과', '관리'].map(h => (
<th key={h} className="text-left px-4 py-3 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ id: 'COL-001', name: 'AIS 실시간 수신', server: 'ais-collector-01', type: 'SQL', path: '/data/ais/realtime', schedule: '5분 주기', status: '수행중', last: '14:30:00', result: '성공' },
{ id: 'COL-002', name: 'V-PASS 데이터', server: 'vpass-agent-01', type: 'FTP', path: '/data/vpass/daily', schedule: '1시간', status: '대기중', last: '14:00:00', result: '성공' },
{ id: 'COL-003', name: '위성영상 수집', server: 'sat-collector-01', type: 'FILE', path: '/data/satellite/sar', schedule: '6시간', status: '수행중', last: '12:00:00', result: '성공' },
{ id: 'COL-004', name: '기상 데이터', server: 'weather-api-01', type: 'SQL', path: '/data/weather/kma', schedule: '3시간', status: '대기중', last: '12:00:00', result: '성공' },
{ id: 'COL-005', name: '해양환경 정보', server: 'ocean-env-01', type: 'FTP', path: '/data/ocean/env', schedule: '일 1회', status: '정지', last: '06:00:00', result: '실패' },
].map(r => (
<tr key={r.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.id}</td>
<td className="px-4 py-3 text-heading font-medium">{r.name}</td>
<td className="px-4 py-3 text-label font-mono text-[10px]">{r.server}</td>
<td className="px-4 py-3"><Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{r.type}</Badge></td>
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.path}</td>
<td className="px-4 py-3 text-label">{r.schedule}</td>
<td className="px-4 py-3">
<Badge className={`border-0 text-[9px] ${r.status === '수행중' ? 'bg-green-500/20 text-green-400' : r.status === '대기중' ? 'bg-blue-500/20 text-blue-400' : r.status === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-slate-500/20 text-slate-400'}`}>{r.status}</Badge>
</td>
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.last}</td>
<td className="px-4 py-3">
<Badge className={`border-0 text-[9px] ${r.result === '성공' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{r.result}</Badge>
</td>
<td className="px-4 py-3 flex gap-1">
<button type="button" className="px-2 py-1 rounded text-[9px] bg-surface-overlay border border-border text-hint hover:text-heading transition-colors"></button>
<button type="button" className="px-2 py-1 rounded text-[9px] bg-surface-overlay border border-border text-hint hover:text-heading transition-colors"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ══ 적재 작업 관리 ══ */}
{tab === 'load' && (
<div className="space-y-4">
{/* 필터 바 */}
<div className="bg-card border border-border rounded-2xl px-6 py-4 flex items-center gap-3">
<div className="flex gap-1.5">
{['전체', '정지', '대기중', '수행중', '장애발생'].map(s => (
<button key={s} type="button" className="px-3 py-1.5 rounded-lg text-[10px] font-medium bg-surface-overlay border border-border text-hint hover:text-heading transition-colors">
{s} <span className="ml-1 text-[9px] text-blue-400">10</span>
</button>
))}
</div>
<div className="w-px h-6 bg-border" />
<div className="flex gap-1.5">
{['전체', '성공', '실패'].map(s => (
<button key={s} type="button" className={`px-3 py-1.5 rounded-lg text-[10px] font-medium border transition-colors ${s === '실패' ? 'bg-red-500/10 border-red-500/30 text-red-400' : 'bg-surface-overlay border-border text-hint'}`}>
{s}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<input type="text" placeholder="작업명 검색..." className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[10px] text-heading placeholder:text-hint w-48 focus:outline-none focus:border-blue-500/60" />
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 rounded-lg text-[10px] text-white font-bold transition-colors">+ </button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 border border-border rounded-lg text-[10px] text-hint hover:text-heading transition-colors"> </button>
</div>
</div>
{/* 적재 작업 목록 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['작업 ID', '작업명', '적재 서버', '파일 위치', '대상 테이블', '스케줄', '상태', '최종 수행', '결과', '관리'].map(h => (
<th key={h} className="text-left px-4 py-3 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ id: 'LOD-001', name: 'AIS 적재', server: 'db-primary-01', path: '/data/sync/ais', table: 'kcg.ais_position', schedule: '5분', status: '수행중', last: '14:30', result: '성공' },
{ id: 'LOD-002', name: 'V-PASS 적재', server: 'db-primary-01', path: '/data/sync/vpass', table: 'kcg.vpass_data', schedule: '1시간', status: '대기중', last: '14:00', result: '성공' },
{ id: 'LOD-003', name: '위성영상 적재', server: 'db-replica-01', path: '/data/sync/sat', table: 'kcg.satellite_img', schedule: '6시간', status: '대기중', last: '12:00', result: '성공' },
{ id: 'LOD-004', name: '분석결과 적재', server: 'db-primary-01', path: '/data/sync/pred', table: 'kcg.prediction_result', schedule: '5분', status: '수행중', last: '14:30', result: '성공' },
{ id: 'LOD-005', name: '단속이력 동기화', server: 'db-replica-01', path: '/data/sync/enf', table: 'kcg.enforcement_records', schedule: '실시간', status: '수행중', last: '14:28', result: '성공' },
].map(r => (
<tr key={r.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.id}</td>
<td className="px-4 py-3 text-heading font-medium">{r.name}</td>
<td className="px-4 py-3 text-label font-mono text-[10px]">{r.server}</td>
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.path}</td>
<td className="px-4 py-3 text-cyan-400 font-mono text-[10px]">{r.table}</td>
<td className="px-4 py-3 text-label">{r.schedule}</td>
<td className="px-4 py-3">
<Badge className={`border-0 text-[9px] ${r.status === '수행중' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}`}>{r.status}</Badge>
</td>
<td className="px-4 py-3 text-hint font-mono text-[10px]">{r.last}</td>
<td className="px-4 py-3"><Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">{r.result}</Badge></td>
<td className="px-4 py-3 flex gap-1">
<button type="button" className="px-2 py-1 rounded text-[9px] bg-surface-overlay border border-border text-hint hover:text-heading transition-colors"></button>
<button type="button" className="px-2 py-1 rounded text-[9px] bg-surface-overlay border border-border text-hint hover:text-heading transition-colors"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ══ 연계서버 모니터링 — 장비 구성도 (토폴로지) ══ */}
{tab === 'monitor' && (
<div className="space-y-4">
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Database className="w-4 h-4 text-cyan-400" /> </h3>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 border border-border rounded-lg text-[10px] text-hint hover:text-heading transition-colors"><RefreshCw className="w-3 h-3" /> </button>
</div>
{/* SVG 토폴로지 구성도 — 장비 아이콘 */}
<div className="p-6">
<svg viewBox="0 0 1000 620" className="w-full" style={{ minHeight: 520 }}>
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0L10 5L0 10z" fill="#3b82f6" opacity="0.5" /></marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0L10 5L0 10z" fill="#22c55e" opacity="0.5" /></marker>
<marker id="arr-o" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0L10 5L0 10z" fill="#f97316" opacity="0.5" /></marker>
{/* 서버 아이콘 (랙서버) */}
<symbol id="ico-server" viewBox="0 0 32 40">
<rect x="2" y="2" width="28" height="10" rx="2" fill="#334155" stroke="#64748b" strokeWidth="1" />
<circle cx="8" cy="7" r="2" fill="#22c55e" /><rect x="14" y="5" width="12" height="2" rx="1" fill="#475569" />
<rect x="2" y="14" width="28" height="10" rx="2" fill="#334155" stroke="#64748b" strokeWidth="1" />
<circle cx="8" cy="19" r="2" fill="#3b82f6" /><rect x="14" y="17" width="12" height="2" rx="1" fill="#475569" />
<rect x="2" y="26" width="28" height="10" rx="2" fill="#334155" stroke="#64748b" strokeWidth="1" />
<circle cx="8" cy="31" r="2" fill="#f97316" /><rect x="14" y="29" width="12" height="2" rx="1" fill="#475569" />
</symbol>
{/* DB 아이콘 (실린더) */}
<symbol id="ico-db" viewBox="0 0 32 40">
<ellipse cx="16" cy="8" rx="14" ry="6" fill="#334155" stroke="#22c55e" strokeWidth="1.5" />
<rect x="2" y="8" width="28" height="24" fill="#334155" />
<ellipse cx="16" cy="32" rx="14" ry="6" fill="#334155" stroke="#22c55e" strokeWidth="1.5" />
<line x1="2" y1="8" x2="2" y2="32" stroke="#22c55e" strokeWidth="1.5" />
<line x1="30" y1="8" x2="30" y2="32" stroke="#22c55e" strokeWidth="1.5" />
<ellipse cx="16" cy="8" rx="14" ry="6" fill="none" stroke="#22c55e" strokeWidth="1.5" />
</symbol>
{/* 안테나 아이콘 */}
<symbol id="ico-antenna" viewBox="0 0 32 40">
<line x1="16" y1="40" x2="16" y2="16" stroke="#64748b" strokeWidth="2" />
<circle cx="16" cy="12" r="4" fill="none" stroke="#3b82f6" strokeWidth="1.5" />
<circle cx="16" cy="12" r="8" fill="none" stroke="#3b82f6" strokeWidth="1" opacity="0.5" />
<circle cx="16" cy="12" r="12" fill="none" stroke="#3b82f6" strokeWidth="0.7" opacity="0.3" />
<circle cx="16" cy="12" r="2" fill="#3b82f6" />
</symbol>
{/* 모바일 아이콘 */}
<symbol id="ico-mobile" viewBox="0 0 24 40">
<rect x="2" y="2" width="20" height="36" rx="4" fill="#334155" stroke="#f97316" strokeWidth="1.5" />
<rect x="5" y="6" width="14" height="22" rx="1" fill="#1e293b" />
<circle cx="12" cy="33" r="2" fill="#475569" />
</symbol>
</defs>
{/* ── 그룹 배경 ── */}
<rect x="10" y="25" width="160" height="570" rx="12" fill="#3b82f608" stroke="#3b82f622" strokeWidth="1" />
<text x="90" y="18" textAnchor="middle" className="text-[11px] font-bold" fill="#60a5fa"> </text>
<rect x="230" y="90" width="190" height="420" rx="12" fill="#06b6d408" stroke="#06b6d422" strokeWidth="1" />
<text x="325" y="82" textAnchor="middle" className="text-[11px] font-bold" fill="#22d3ee"> </text>
<rect x="480" y="120" width="160" height="340" rx="12" fill="#a855f708" stroke="#a855f722" strokeWidth="1.5" />
<text x="560" y="112" textAnchor="middle" className="text-[11px] font-bold" fill="#c084fc">AI </text>
<rect x="700" y="60" width="290" height="480" rx="12" fill="#f9731608" stroke="#f9731622" strokeWidth="1" />
<text x="845" y="52" textAnchor="middle" className="text-[11px] font-bold" fill="#fb923c"> </text>
{/* ── 외부 소스 (안테나 아이콘) ── */}
{['AIS 기지국', 'V-PASS', '위성 (SAR)', '기상청 API', '해양환경', 'VHF-DSC'].map((name, i) => {
const y = 45 + i * 90;
return (
<g key={name}>
<use href="#ico-antenna" x="68" y={y} width="32" height="40" />
<text x="90" y={y + 50} textAnchor="middle" className="text-[9px] font-bold" fill="#e2e8f0">{name}</text>
<text x="90" y={y + 62} textAnchor="middle" className="text-[7px]" fill="#94a3b8">{['실시간', '실시간', '6시간', '3시간', '일1회', '실시간'][i]}</text>
</g>
);
})}
{/* ── 수집 서버 (서버 아이콘) ── */}
{[
{ name: 'AIS Collector', ip: '.119.53', calls: '94/min', ok: true },
{ name: 'VPASS Agent', ip: '.119.54', calls: '7/min', ok: true },
{ name: 'SAT Collector', ip: '.119.61', calls: '20/min', ok: true },
{ name: 'Weather API', ip: '.119.62', calls: '7/min', ok: false },
].map((s, i) => {
const y = 110 + i * 100;
return (
<g key={s.name}>
<use href="#ico-server" x="300" y={y} width="32" height="40" />
<circle cx="298" cy={y + 4} r="4" fill={s.ok ? '#22c55e' : '#3b82f6'} />
<text x="325" y={y + 52} textAnchor="middle" className="text-[9px] font-bold" fill="#e2e8f0">{s.name}</text>
<text x="325" y={y + 64} textAnchor="middle" className="text-[7px] font-mono" fill="#94a3b8">172.16{s.ip}</text>
<text x="325" y={y + 76} textAnchor="middle" className="text-[8px] font-bold" fill="#22d3ee">{s.calls}</text>
</g>
);
})}
{/* ── AI 플랫폼 (서버+DB) ── */}
{[
{ name: 'Prediction', sub: 'FastAPI:8001', icon: '#ico-server' },
{ name: 'Backend API', sub: 'Spring:8080', icon: '#ico-server' },
{ name: 'PostgreSQL', sub: 'kcgaidb', icon: '#ico-db' },
].map((s, i) => {
const y = 140 + i * 105;
return (
<g key={s.name}>
<use href={s.icon} x="540" y={y} width="32" height="40" />
<text x="560" y={y + 52} textAnchor="middle" className="text-[9px] font-bold" fill="#e2e8f0">{s.name}</text>
<text x="560" y={y + 64} textAnchor="middle" className="text-[7px] font-mono" fill="#94a3b8">{s.sub}</text>
</g>
);
})}
{/* ── 외부 제공 (서버+모바일) ── */}
{[
{ name: '해수부', sub: 'api.mof.go.kr', calls: '12,450', icon: '#ico-server' },
{ name: '수협', sub: 'sftp.suhyup.co.kr', calls: '365', icon: '#ico-server' },
{ name: '해양조사원', sub: 'api.khoa.go.kr', calls: '2,540', icon: '#ico-server' },
{ name: '기상청', sub: 'api.kma.go.kr', calls: '540', icon: '#ico-server' },
{ name: '해경 모바일', sub: 'wss://push', calls: '45,200', icon: '#ico-mobile' },
].map((t, i) => {
const y = 80 + i * 88;
return (
<g key={t.name}>
<use href={t.icon} x="720" y={y} width={t.icon === '#ico-mobile' ? 24 : 32} height="40" />
<text x="770" y={y + 15} className="text-[10px] font-bold" fill="#e2e8f0">{t.name}</text>
<text x="770" y={y + 28} className="text-[7px] font-mono" fill="#94a3b8">{t.sub}</text>
<text x="970" y={y + 20} textAnchor="end" className="text-[9px] font-bold" fill="#fb923c">{t.calls} calls</text>
</g>
);
})}
{/* ── Redis (하단) ── */}
<use href="#ico-server" x="494" y="530" width="32" height="40" />
<text x="510" y="580" textAnchor="middle" className="text-[9px] font-bold" fill="#e2e8f0">Redis Pub/Sub</text>
<text x="510" y="595" textAnchor="middle" className="text-[7px] font-mono" fill="#94a3b8">redis:6379</text>
{/* ── 연결선: 외부→수집 ── */}
{[0, 1, 2, 3, 4, 5].map(i => {
const sy = 65 + i * 90;
const ey = [130, 230, 330, 330, 230, 130][i];
return <line key={`e-c-${i}`} x1="105" y1={sy} x2="295" y2={ey} stroke="#3b82f6" strokeWidth="1.2" opacity="0.35" markerEnd="url(#arr)" />;
})}
{[
{ x: 200, y: 90, t: '94/min, 752ms' },
{ x: 200, y: 185, t: '7/min, 14ms' },
{ x: 200, y: 285, t: '20/min, 81ms' },
{ x: 200, y: 370, t: '7/min, 17ms' },
].map((l, i) => (
<text key={`el-${i}`} x={l.x} y={l.y} textAnchor="middle" className="text-[7px] font-mono" fill="#60a5fa">{l.t}</text>
))}
{/* ── 연결선: 수집→AI ── */}
{[0, 1, 2, 3].map(i => {
const sy = 130 + i * 100;
return <line key={`c-a-${i}`} x1="340" y1={sy} x2="535" y2={[160, 160, 265, 265][i]} stroke="#22c55e" strokeWidth="1.2" opacity="0.35" markerEnd="url(#arr-g)" />;
})}
{[
{ x: 438, y: 138, t: '286/min, 200ms' },
{ x: 438, y: 228, t: '13/min, 274ms' },
{ x: 438, y: 318, t: '20/min, 73ms' },
].map((l, i) => (
<text key={`cl-${i}`} x={l.x} y={l.y} textAnchor="middle" className="text-[7px] font-mono" fill="#4ade80">{l.t}</text>
))}
{/* ── 연결선: AI→외부 ── */}
{[0, 1, 2, 3, 4].map(i => {
const ey = 100 + i * 88;
return <line key={`a-e-${i}`} x1="580" y1={[160, 265, 265, 160, 160][i]} x2="715" y2={ey} stroke="#f97316" strokeWidth="1.2" opacity="0.35" markerEnd="url(#arr-o)" />;
})}
{/* ── AI→Redis ── */}
<line x1="555" y1="395" x2="510" y2="530" stroke="#ef4444" strokeWidth="1.2" opacity="0.35" strokeDasharray="4,3" />
<text x="525" y="470" className="text-[7px] font-mono" fill="#f87171">31/min</text>
</svg>
</div>
</div>
{/* 하단: 서버 상태 요약 테이블 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-3 border-b border-border">
<h3 className="text-xs font-bold text-heading"> </h3>
</div>
<table className="w-full text-xs">
<thead><tr className="bg-surface-overlay border-b border-border">
{['서버명', 'IP', '유형', 'CPU', 'MEM', '작업 수', '상태'].map(h => (
<th key={h} className="text-left px-4 py-2.5 text-hint font-medium">{h}</th>
))}
</tr></thead>
<tbody>
{[
{ name: 'AIS Collector', ip: '172.16.119.53', type: '수집', cpu: 45, mem: 62, tasks: 12, status: '수행중' },
{ name: 'VPASS Agent', ip: '172.16.119.54', type: '수집', cpu: 12, mem: 35, tasks: 4, status: '대기중' },
{ name: 'SAT Collector', ip: '172.16.119.61', type: '수집', cpu: 78, mem: 85, tasks: 2, status: '수행중' },
{ name: 'Prediction Engine', ip: '172.16.120.50', type: 'AI', cpu: 65, mem: 78, tasks: 14, status: '수행중' },
{ name: 'Backend API', ip: '172.16.120.10', type: 'API', cpu: 32, mem: 48, tasks: 8, status: '수행중' },
{ name: 'PostgreSQL (kcgaidb)', ip: '172.16.120.30', type: 'DB', cpu: 40, mem: 55, tasks: 286, status: '수행중' },
{ name: 'Redis Pub/Sub', ip: '172.16.120.31', type: 'Cache', cpu: 15, mem: 28, tasks: 31, status: '수행중' },
].map(s => (
<tr key={s.name} className="border-b border-border hover:bg-surface-overlay/50">
<td className="px-4 py-2.5 text-heading font-medium">{s.name}</td>
<td className="px-4 py-2.5 text-hint font-mono text-[10px]">{s.ip}</td>
<td className="px-4 py-2.5"><Badge className={`border-0 text-[9px] ${s.type === '수집' ? 'bg-cyan-500/20 text-cyan-400' : s.type === 'AI' ? 'bg-purple-500/20 text-purple-400' : s.type === 'DB' ? 'bg-green-500/20 text-green-400' : 'bg-orange-500/20 text-orange-400'}`}>{s.type}</Badge></td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-surface-overlay rounded-full overflow-hidden"><div className={`h-full rounded-full ${s.cpu > 70 ? 'bg-red-500' : 'bg-green-500'}`} style={{ width: `${s.cpu}%` }} /></div>
<span className={`text-[10px] font-mono ${s.cpu > 70 ? 'text-red-400' : 'text-heading'}`}>{s.cpu}%</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-surface-overlay rounded-full overflow-hidden"><div className={`h-full rounded-full ${s.mem > 80 ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${s.mem}%` }} /></div>
<span className={`text-[10px] font-mono ${s.mem > 80 ? 'text-red-400' : 'text-heading'}`}>{s.mem}%</span>
</div>
</td>
<td className="px-4 py-2.5 text-heading font-bold">{s.tasks}</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${s.status === '수행중' ? 'bg-green-500' : 'bg-blue-500'}`} />
<span className="text-[10px]">{s.status}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -1,238 +1,450 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { BarChart3, Download } from 'lucide-react';
import { BarChart, AreaChart } from '@lib/charts';
import {
getKpiMetrics,
getMonthlyStats,
toMonthlyTrend,
toViolationTypes,
type PredictionKpi,
type PredictionStatsMonthly,
} from '@/services/kpi';
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
BarChart3, Download, TrendingUp, Filter,
Calendar, MapPin, Building2, Target, FileText, FileSpreadsheet,
Printer, Settings, Anchor, AlertTriangle, Clock,
Activity, Shield, TrendingDown, ArrowUpRight, ArrowDownRight,
PieChart as PieIcon, Search, RotateCcw,
} from 'lucide-react';
import { BarChart, LineChart, PieChart } from '@lib/charts';
/* SFR-13: 통계·지표·성과 분석 */
/**
* SFR-13: 통계··
* 레이아웃: 세로 ( )
* KPI
*/
interface KpiRow {
id: string;
name: string;
target: string;
current: string;
status: string;
[key: string]: unknown;
}
const kpiCols: DataColumn<KpiRow>[] = [
{
key: 'id',
label: 'ID',
width: '70px',
render: (v) => (
<span className="text-hint font-mono text-[10px]">{v as string}</span>
),
},
{
key: 'name',
label: '지표명',
sortable: true,
render: (v) => (
<span className="text-heading font-medium">{v as string}</span>
),
},
{ key: 'target', label: '목표', width: '80px', align: 'center' },
{
key: 'current',
label: '현재',
width: '80px',
align: 'center',
render: (v) => (
<span className="text-cyan-400 font-bold">{v as string}</span>
),
},
{
key: 'status',
label: '상태',
width: '60px',
align: 'center',
render: (v) => (
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
{v as string}
</Badge>
),
},
// ─── Mock 데이터 ───
const MONTHLY = [
{ m: '10월', enforce: 42, detect: 68, accuracy: 87, fp: 8 },
{ m: '11월', enforce: 38, detect: 72, accuracy: 89, fp: 6 },
{ m: '12월', enforce: 51, detect: 85, accuracy: 88, fp: 9 },
{ m: '1월', enforce: 35, detect: 60, accuracy: 90, fp: 5 },
{ m: '2월', enforce: 47, detect: 78, accuracy: 91, fp: 4 },
{ m: '3월', enforce: 55, detect: 92, accuracy: 92, fp: 3 },
];
const VIOLATION_TYPES = [
{ type: 'EEZ 침범', count: 142, pct: 32, color: '#ef4444' },
{ type: 'Dark Vessel', count: 98, pct: 22, color: '#f97316' },
{ type: '금어기 조업', count: 87, pct: 20, color: '#eab308' },
{ type: '협정선 위반', count: 65, pct: 15, color: '#3b82f6' },
{ type: '불법어구', count: 48, pct: 11, color: '#a855f7' },
];
const BY_ZONE = [
{ zone: '서해청', count: 185, delta: '+12%' }, { zone: '남해청', count: 92, delta: '+5%' },
{ zone: '동해청', count: 78, delta: '-3%' }, { zone: '제주청', count: 65, delta: '+8%' }, { zone: '중부청', count: 20, delta: '-1%' },
];
const BY_SEASON = [
{ season: '봄 (3~5월)', count: 125, pct: 28 }, { season: '여름 (6~8월)', count: 142, pct: 32 },
{ season: '가을 (9~11월)', count: 98, pct: 22 }, { season: '겨울 (12~2월)', count: 75, pct: 18 },
];
const BY_ORG = [
{ org: '서해지방해경청', enforce: 120, detect: 185, accuracy: 93, delta: '+2.1%' },
{ org: '남해지방해경청', enforce: 68, detect: 92, accuracy: 91, delta: '+1.5%' },
{ org: '동해지방해경청', enforce: 55, detect: 78, accuracy: 89, delta: '-0.3%' },
{ org: '제주지방해경청', enforce: 42, detect: 65, accuracy: 92, delta: '+3.2%' },
{ org: '중부지방해경청', enforce: 15, detect: 20, accuracy: 87, delta: '+0.8%' },
];
const KPI_LIST = [
{ id: 'KPI-01', name: '탐지 정확도 (Precision)', target: '≥90%', current: '92.3%', trend: 'up', delta: '+2.1%' },
{ id: 'KPI-02', name: '재현율 (Recall)', target: '≥85%', current: '88.7%', trend: 'up', delta: '+3.5%' },
{ id: 'KPI-03', name: 'F1-Score', target: '≥0.87', current: '0.905', trend: 'up', delta: '+0.03' },
{ id: 'KPI-04', name: '오탐율 (FPR)', target: '≤10%', current: '7.3%', trend: 'down', delta: '-2.8%' },
{ id: 'KPI-05', name: '미탐율 (FNR)', target: '≤15%', current: '11.3%', trend: 'down', delta: '-4.2%' },
{ id: 'KPI-06', name: '경보 E2E 지연', target: '≤60초', current: '42초', trend: 'flat', delta: '-' },
{ id: 'KPI-07', name: '월평균 단속 건수', target: '≥40건', current: '44.7건', trend: 'up', delta: '+12%' },
{ id: 'KPI-08', name: 'AI 학습 환류 주기', target: '주 1회', current: '주 1회', trend: 'flat', delta: '-' },
];
type TrendTab = 'hourly' | 'daily' | 'monthly' | 'yearly';
export function Statistics() {
const { t } = useTranslation('statistics');
const [trendTab, setTrendTab] = useState<TrendTab>('monthly');
const [filterOrg, setFilterOrg] = useState('전체');
const [filterPeriod, setFilterPeriod] = useState('최근 6개월');
const [filterZone, setFilterZone] = useState('전체');
const [zoneTab, setZoneTab] = useState<'zone' | 'org'>('zone');
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
const [kpiMetrics, setKpiMetrics] = useState<PredictionKpi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadStats() {
setLoading(true);
setError(null);
try {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
const [data, kpiData] = await Promise.all([
getMonthlyStats(toDateParam(from), toDateParam(now)),
getKpiMetrics().catch(() => [] as PredictionKpi[]),
]);
if (cancelled) return;
setMonthly(data.map(toMonthlyTrend));
setViolationTypes(toViolationTypes(data));
setKpiMetrics(kpiData);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : '통계 데이터 로드 실패',
);
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadStats();
return () => {
cancelled = true;
};
}, []);
const MONTHLY = monthly.map((m) => ({
m: m.month,
enforce: m.enforce,
detect: m.detect,
accuracy: m.accuracy,
}));
const BY_TYPE = violationTypes;
const KPI_DATA: KpiRow[] = kpiMetrics.map((k, i) => {
const trendLabel =
k.trend === 'up' ? '상승' : k.trend === 'down' ? '하락' : k.trend === 'flat' ? '유지' : '-';
const deltaLabel = k.deltaPct != null ? ` (${k.deltaPct > 0 ? '+' : ''}${k.deltaPct}%)` : '';
return {
id: `KPI-${String(i + 1).padStart(2, '0')}`,
name: k.kpiLabel,
target: '-',
current: String(k.value),
status: `${trendLabel}${deltaLabel}`,
};
});
const filterCls = "bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-xs text-heading focus:outline-none focus:border-blue-500/60 appearance-none";
const trendTabCls = (active: boolean) => `px-4 py-2 text-xs font-medium rounded-t-lg transition-colors ${active ? 'bg-blue-600 text-white' : 'bg-surface-overlay text-hint hover:text-label'}`;
return (
<div className="p-5 space-y-4">
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* ═══ 헤더 ═══ */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-purple-400" />
{t('statistics.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('statistics.desc')}
</p>
<h1 className="text-xl font-bold text-heading flex items-center gap-2">
<BarChart3 className="w-6 h-6 text-purple-400" /> {t('statistics.title')}
</h1>
<p className="text-xs text-hint mt-1">SFR-13 | · ·.</p>
</div>
<div className="flex items-center gap-2">
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg text-xs text-white font-medium transition-colors"><FileSpreadsheet className="w-4 h-4" /> </button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg text-xs text-white font-medium transition-colors"><FileText className="w-4 h-4" /> PDF</button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-xs text-white font-medium transition-colors"><Download className="w-4 h-4" /> </button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 border border-border rounded-lg text-xs text-hint hover:text-label hover:bg-surface-overlay transition-colors"><Printer className="w-4 h-4" /> </button>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading">
<Download className="w-3 h-3" />
</button>
</div>
{loading && (
<div className="text-center py-10 text-muted-foreground text-sm">
...
</div>
)}
{error && (
<div className="text-center py-10 text-red-400 text-sm">{error}</div>
)}
{!loading && !error && (
<>
<div className="grid grid-cols-2 gap-3">
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">
·
</div>
<BarChart
data={MONTHLY}
xKey="m"
height={200}
series={[
{ key: 'enforce', name: '단속', color: '#3b82f6' },
{ key: 'detect', name: '탐지', color: '#8b5cf6' },
]}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">
AI
</div>
<AreaChart
data={MONTHLY}
xKey="m"
height={200}
yAxisDomain={[75, 100]}
series={[
{ key: 'accuracy', name: '정확도 %', color: '#22c55e' },
]}
/>
</CardContent>
</Card>
{/* ═══ 실시간 핵심 KPI ═══ */}
<div className="grid grid-cols-6 gap-4">
{[
{ label: '총 단속 건수', value: '268', unit: '건', icon: Shield, color: '#3b82f6', bg: 'bg-blue-500/10', delta: '+14%', up: true },
{ label: 'AI 탐지 건수', value: '440', unit: '건', icon: Activity, color: '#8b5cf6', bg: 'bg-purple-500/10', delta: '+22%', up: true },
{ label: '탐지 정확도', value: '92.3', unit: '%', icon: Target, color: '#22c55e', bg: 'bg-green-500/10', delta: '+2.1%', up: true },
{ label: '오탐율 (FPR)', value: '7.3', unit: '%', icon: AlertTriangle, color: '#ef4444', bg: 'bg-red-500/10', delta: '-2.8%', up: false },
{ label: '월평균 단속', value: '44.7', unit: '건', icon: TrendingUp, color: '#f97316', bg: 'bg-orange-500/10', delta: '+12%', up: true },
{ label: 'E2E 경보지연', value: '42', unit: '초', icon: Clock, color: '#06b6d4', bg: 'bg-cyan-500/10', delta: '안정', up: true },
].map(k => (
<div key={k.label} className="bg-card border border-border rounded-2xl p-5 text-center">
<div className={`w-12 h-12 rounded-xl ${k.bg} flex items-center justify-center mx-auto mb-3`}>
<k.icon className="w-6 h-6" style={{ color: k.color }} />
</div>
<div className="text-3xl font-extrabold text-heading" style={{ color: k.color }}>
{k.value}<span className="text-base font-medium text-hint ml-0.5">{k.unit}</span>
</div>
<div className="text-[11px] text-hint mt-1">{k.label}</div>
<div className={`text-[10px] font-bold mt-1.5 flex items-center justify-center gap-0.5 ${k.up ? 'text-green-400' : 'text-blue-400'}`}>
{k.up ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{k.delta}
</div>
</div>
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">
</div>
<div className="flex gap-3">
{BY_TYPE.map((item) => (
<div
key={item.type}
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
>
<div className="text-lg font-bold text-heading">
{item.count}
))}
</div>
{/* ═══ 필터 바 ═══ */}
<div className="bg-card border border-border rounded-2xl px-6 py-4 flex items-end gap-4">
<div className="flex items-center gap-2 text-sm font-bold text-heading"><Filter className="w-4 h-4 text-blue-400" /> </div>
<div className="flex-1 grid grid-cols-3 gap-3">
<div>
<label className="text-[10px] text-hint mb-1 block">/</label>
<select value={filterOrg} onChange={e => setFilterOrg(e.target.value)} className={filterCls} title="기관">
{['전체', '서해지방해경청', '남해지방해경청', '동해지방해경청', '제주지방해경청', '중부지방해경청'].map(v => <option key={v}>{v}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-hint mb-1 block"></label>
<select value={filterPeriod} onChange={e => setFilterPeriod(e.target.value)} className={filterCls} title="기간">
{['최근 1개월', '최근 3개월', '최근 6개월', '최근 1년', '사용자 정의'].map(v => <option key={v}>{v}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-hint mb-1 block"></label>
<select value={filterZone} onChange={e => setFilterZone(e.target.value)} className={filterCls} title="해역">
{['전체', '서해', '남해', '동해', '제주', '서해 5도', 'NLL 인근'].map(v => <option key={v}>{v}</option>)}
</select>
</div>
</div>
<div className="flex gap-2 shrink-0">
<button type="button" className="flex items-center gap-1.5 px-5 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-xs text-white font-bold transition-colors"><Search className="w-4 h-4" /> </button>
<button type="button" className="flex items-center gap-1.5 px-4 py-2 border border-border rounded-lg text-xs text-hint hover:text-label transition-colors"><RotateCcw className="w-4 h-4" /> </button>
</div>
</div>
{/* ═══ 섹션 1: 단속 건수 현황 (탭: 시간/일/월/연) ═══ */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><BarChart3 className="w-4 h-4 text-blue-400" /> · </h3>
<div className="flex gap-1">
{(['hourly', 'daily', 'monthly', 'yearly'] as TrendTab[]).map(tb => (
<button key={tb} type="button" onClick={() => setTrendTab(tb)} className={trendTabCls(trendTab === tb)}>
{{ hourly: '시간대별', daily: '일별', monthly: '월별', yearly: '연도별' }[tb]}
</button>
))}
</div>
</div>
<div className="p-6">
<BarChart data={MONTHLY} xKey="m" height={280} series={[
{ key: 'enforce', name: '단속 건수', color: '#3b82f6' },
{ key: 'detect', name: 'AI 탐지 건수', color: '#8b5cf6' },
]} />
</div>
</div>
{/* ═══ 섹션 2: 2열 — 위반 유형별 + 해역/기관별 ═══ */}
<div className="grid grid-cols-2 gap-6">
{/* 위반 유형별 분포 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><PieIcon className="w-4 h-4 text-orange-400" /> </h3>
</div>
<div className="p-6">
<div className="flex items-center gap-6">
<PieChart data={VIOLATION_TYPES.map(v => ({ name: v.type, value: v.count, color: v.color }))} height={200} innerRadius={40} outerRadius={80} />
<div className="flex-1 space-y-3">
{VIOLATION_TYPES.map((v, i) => (
<div key={v.type}>
<div className="flex items-center justify-between text-xs mb-1">
<div className="flex items-center gap-2">
<span className="text-heading font-bold w-5 text-center">{i + 1}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: v.color }} />
<span className="text-heading font-medium">{v.type}</span>
</div>
<div className="text-right">
<span className="text-heading font-bold text-base">{v.count}</span>
<span className="text-hint text-[10px] ml-1"> ({v.pct}%)</span>
</div>
</div>
<div className="text-[10px] text-muted-foreground">
{item.type}
<div className="h-2 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${v.pct * 3}%`, backgroundColor: v.color }} />
</div>
<div className="text-[9px] text-hint">{item.pct}%</div>
</div>
))}
</div>
</CardContent>
</Card>
</>
)}
</div>
</div>
</div>
<DataTable
data={KPI_DATA}
columns={kpiCols}
pageSize={10}
title="핵심 성과 지표 (KPI)"
searchPlaceholder="지표명 검색..."
exportFilename="성과지표"
/>
{/* 해역/기관별 현황 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><MapPin className="w-4 h-4 text-cyan-400" /> · </h3>
<div className="flex gap-1">
<button type="button" onClick={() => setZoneTab('zone')} className={trendTabCls(zoneTab === 'zone')}></button>
<button type="button" onClick={() => setZoneTab('org')} className={trendTabCls(zoneTab === 'org')}></button>
</div>
</div>
<div className="p-6">
{zoneTab === 'zone' ? (
<>
<BarChart data={BY_ZONE} xKey="zone" height={180} series={[{ key: 'count', name: '단속 건수', color: '#06b6d4' }]} />
<table className="w-full text-xs mt-4">
<thead><tr className="border-b border-border">
{BY_ZONE.map(z => <th key={z.zone} className="text-center px-3 py-2 text-hint font-medium">{z.zone}</th>)}
</tr></thead>
<tbody><tr>
{BY_ZONE.map(z => (
<td key={z.zone} className="text-center px-3 py-2">
<div className="text-heading font-bold text-base">{z.count}</div>
<div className={`text-[10px] font-bold flex items-center justify-center gap-0.5 ${z.delta.startsWith('+') ? 'text-red-400' : 'text-blue-400'}`}>
{z.delta.startsWith('+') ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{z.delta}
</div>
</td>
))}
</tr></tbody>
</table>
</>
) : (
<>
<BarChart data={BY_ORG.map(o => ({ org: o.org.replace('지방해경청', ''), enforce: o.enforce, detect: o.detect }))} xKey="org" height={180} series={[
{ key: 'enforce', name: '단속', color: '#3b82f6' },
{ key: 'detect', name: '탐지', color: '#8b5cf6' },
]} />
<table className="w-full text-xs mt-4">
<thead><tr className="border-b border-border">
{['기관', '단속', '탐지', '정확도', '전기대비'].map(h => <th key={h} className="text-left px-4 py-2 text-hint font-medium">{h}</th>)}
</tr></thead>
<tbody>
{BY_ORG.map(o => (
<tr key={o.org} className="border-b border-border/50 hover:bg-surface-overlay/50">
<td className="px-4 py-2.5 text-heading font-medium">{o.org}</td>
<td className="px-4 py-2.5 text-blue-400 font-bold">{o.enforce}</td>
<td className="px-4 py-2.5 text-purple-400 font-bold">{o.detect}</td>
<td className="px-4 py-2.5"><span className={`font-bold ${o.accuracy >= 90 ? 'text-green-400' : 'text-yellow-400'}`}>{o.accuracy}%</span></td>
<td className="px-4 py-2.5"><span className={`text-[10px] font-bold ${o.delta.startsWith('+') ? 'text-green-400' : 'text-red-400'}`}>{o.delta}</span></td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
</div>
{/* ═══ 섹션 3: 2열 — AI 정확도 추이 + 계절별 패턴 ═══ */}
<div className="grid grid-cols-2 gap-6">
{/* AI 정확도·오탐율 추이 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><TrendingUp className="w-4 h-4 text-green-400" /> AI · </h3>
</div>
<div className="p-6">
<LineChart data={MONTHLY} xKey="m" height={240} series={[
{ key: 'accuracy', name: '정확도 %', color: '#22c55e' },
{ key: 'fp', name: '오탐율 %', color: '#ef4444' },
]} />
</div>
</div>
{/* 계절별 발생 패턴 */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Calendar className="w-4 h-4 text-yellow-400" /> · </h3>
</div>
<div className="p-6 space-y-4">
{BY_SEASON.map((s, i) => {
const colors = ['#3b82f6', '#ef4444', '#f97316', '#06b6d4'];
return (
<div key={s.season}>
<div className="flex items-center justify-between text-xs mb-2">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-lg flex items-center justify-center text-[10px] font-bold text-white" style={{ backgroundColor: colors[i] }}>{i + 1}</span>
<span className="text-heading font-medium">{s.season}</span>
</div>
<div>
<span className="text-heading font-bold text-lg">{s.count}</span>
<span className="text-hint text-[10px] ml-1"> ({s.pct}%)</span>
</div>
</div>
<div className="h-3 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${s.pct * 3}%`, backgroundColor: colors[i] }} />
</div>
</div>
);
})}
<div className="mt-2 px-3 py-2 bg-yellow-500/10 border border-yellow-500/20 rounded-lg text-[11px] text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" />
(6~8) ·
</div>
</div>
</div>
</div>
{/* ═══ 섹션 4: AI 핵심 성과 지표 (KPI) 테이블 ═══ */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Target className="w-4 h-4 text-cyan-400" /> (KPI) AI · </h3>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 border border-border rounded-lg text-[10px] text-hint hover:text-label transition-colors"><Settings className="w-3 h-3" /> </button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-overlay">
{['ID', '지표명', '목표', '현재', '추세', '전기대비'].map(h => (
<th key={h} className="text-left px-6 py-3 text-hint font-medium text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{KPI_LIST.map(k => (
<tr key={k.id} className="border-b border-border hover:bg-surface-overlay/50 transition-colors">
<td className="px-6 py-3 text-hint font-mono text-xs">{k.id}</td>
<td className="px-6 py-3 text-heading font-medium">{k.name}</td>
<td className="px-6 py-3 text-hint">{k.target}</td>
<td className="px-6 py-3 text-cyan-400 font-bold text-base">{k.current}</td>
<td className="px-6 py-3">
{k.trend === 'up' && <span className="flex items-center gap-1 text-green-400 text-xs font-bold"><ArrowUpRight className="w-4 h-4" /></span>}
{k.trend === 'down' && <span className="flex items-center gap-1 text-blue-400 text-xs font-bold"><ArrowDownRight className="w-4 h-4" /></span>}
{k.trend === 'flat' && <span className="text-hint text-xs"></span>}
</td>
<td className="px-6 py-3">
<span className={`text-xs font-bold ${k.delta.startsWith('+') ? 'text-green-400' : k.delta.startsWith('-') ? 'text-blue-400' : 'text-hint'}`}>{k.delta}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ═══ 섹션 5: 기관/해역별 성과 비교표 ═══ */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Building2 className="w-4 h-4 text-purple-400" /> / </h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-purple-600">
{['해역', '단속건수', '탐지정확도', '오탐율', 'AI 적중률', '전월 대비', '보고서'].map(h => (
<th key={h} className="text-center px-5 py-3 text-white font-bold text-xs">{h}</th>
))}
</tr>
</thead>
<tbody>
{[
{ zone: '서해청', enforce: 83, precision: '93.2%', fpr: '6.8%', hit: '89%', delta: '+12%', up: true },
{ zone: '남해청', enforce: 44, precision: '91.8%', fpr: '8.2%', hit: '85%', delta: '-5%', up: false },
{ zone: '동해청', enforce: 38, precision: '92.5%', fpr: '7.5%', hit: '88%', delta: '+8%', up: true },
{ zone: '제주청', enforce: 27, precision: '94.1%', fpr: '5.9%', hit: '91%', delta: '+15%', up: true },
].map((r, i) => (
<tr key={r.zone} className={`border-b border-border ${i % 2 === 0 ? '' : 'bg-surface-overlay/30'} hover:bg-surface-overlay/50 transition-colors`}>
<td className="text-center px-5 py-4 text-heading font-medium">{r.zone}</td>
<td className="text-center px-5 py-4 text-heading font-bold">{r.enforce}</td>
<td className="text-center px-5 py-4 text-green-400 font-bold">{r.precision}</td>
<td className="text-center px-5 py-4 text-hint">{r.fpr}</td>
<td className="text-center px-5 py-4 text-cyan-400 font-bold">{r.hit}</td>
<td className="text-center px-5 py-4">
<span className={`font-bold flex items-center justify-center gap-0.5 ${r.up ? 'text-green-400' : 'text-red-400'}`}>
{r.up ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
{r.delta}
</span>
</td>
<td className="text-center px-5 py-4">
<div className="flex items-center justify-center gap-2">
<button type="button" className="px-2 py-1 bg-green-600/20 text-green-400 rounded text-[10px] font-medium hover:bg-green-600/30 transition-colors">[]</button>
<button type="button" className="px-2 py-1 bg-red-600/20 text-red-400 rounded text-[10px] font-medium hover:bg-red-600/30 transition-colors">[PDF]</button>
<button type="button" className="px-2 py-1 bg-blue-600/20 text-blue-400 rounded text-[10px] font-medium hover:bg-blue-600/30 transition-colors">[]</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="px-6 py-3 bg-purple-600/10 border-t border-purple-500/20">
<p className="text-[11px] text-purple-300 font-medium">
<Settings className="w-3.5 h-3.5 inline mr-1" />
설정: 관리자가 · ·
</p>
</div>
</div>
{/* ═══ 섹션 6: 보고서 자동 생성 ═══ */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><FileText className="w-4 h-4 text-blue-400" /> ·</h3>
</div>
<div className="p-6 grid grid-cols-4 gap-4">
{[
{ name: '일일 단속 보고서', format: 'PDF', desc: '금일 단속·탐지·경보 현황 요약 보고서', icon: FileText, color: 'text-red-400', bg: 'bg-red-500/10' },
{ name: '주간 통계 보고서', format: 'Excel', desc: '주간 단속 건수, 유형별 분포, AI 성과 분석', icon: FileSpreadsheet, color: 'text-green-400', bg: 'bg-green-500/10' },
{ name: '월간 성과 분석', format: 'PDF', desc: '월간 KPI 달성률, 전월 대비 추이 차트 포함', icon: FileText, color: 'text-orange-400', bg: 'bg-orange-500/10' },
{ name: '맞춤 보고서', format: 'PDF/Excel', desc: '현재 필터 조건 기반 사용자 정의 보고서 생성', icon: Settings, color: 'text-blue-400', bg: 'bg-blue-500/10' },
].map(r => (
<div key={r.name} className="border border-border rounded-xl p-5 flex flex-col">
<div className={`w-10 h-10 rounded-lg ${r.bg} flex items-center justify-center mb-3`}>
<r.icon className={`w-5 h-5 ${r.color}`} />
</div>
<div className="text-sm font-bold text-heading">{r.name}</div>
<div className="text-[11px] text-hint mt-1 flex-1">{r.desc}</div>
<div className="text-[10px] text-hint mt-2 mb-3">: {r.format}</div>
<button type="button" className="w-full py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-xs text-white font-bold transition-colors flex items-center justify-center gap-1.5">
<Download className="w-3.5 h-3.5" /> ·
</button>
</div>
))}
</div>
</div>
{/* ═══ 섹션 6: 사용자 정의 설정 안내 ═══ */}
<div className="bg-card border border-border rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-sm font-bold text-heading flex items-center gap-2"><Settings className="w-4 h-4 text-hint" /> </h3>
</div>
<div className="p-6 grid grid-cols-3 gap-4">
{[
{ label: '표시 지표 선택', desc: '대시보드에 표시할 KPI 카드를 선택하고 순서를 변경할 수 있습니다. 역할(RBAC)에 따라 기본 프리셋이 제공됩니다.', icon: Target, color: 'text-blue-400' },
{ label: '차트 유형 변경', desc: '바·라인·영역·파이 등 차트 유형을 지표별로 변경할 수 있습니다. 시각화 프리셋 저장 기능을 제공합니다.', icon: BarChart3, color: 'text-purple-400' },
{ label: '비교 기준 설정', desc: '기관·해역·기간 등 비교 축을 사용자가 직접 정의할 수 있습니다. 커스텀 필터 조합을 즐겨찾기로 저장합니다.', icon: Filter, color: 'text-cyan-400' },
].map(c => (
<div key={c.label} className="border border-border rounded-xl p-5 flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-surface-overlay flex items-center justify-center shrink-0">
<c.icon className={`w-5 h-5 ${c.color}`} />
</div>
<div>
<div className="text-sm font-bold text-heading">{c.label}</div>
<div className="text-[11px] text-hint mt-1 leading-relaxed">{c.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

파일 보기

@ -30,7 +30,19 @@
"labelSession": "Label Session",
"auditLogs": "Audit Logs",
"accessLogs": "Access Logs",
"loginHistory": "Login History"
"loginHistory": "Login History",
"caseManagement": "Case Management",
"caseRegister": "Case Register",
"caseEdit": "Case Edit",
"caseDetail": "Case Detail",
"matchDashboard": "Match Dashboard",
"matchVerify": "Match Verify",
"labelList": "Label Tasks",
"historySearch": "History Search",
"vesselHistory": "Vessel History",
"typeStats": "Type Stats",
"reportGen": "Report Gen",
"changeHistory": "Change History"
},
"status": {
"active": "Active",
@ -96,6 +108,13 @@
"parentInference": "Parent Workflow",
"statistics": "Statistics",
"aiOps": "AI Ops",
"admin": "Admin"
"admin": "Admin",
"enforcementPlan": "Enforcement Plan"
},
"subGroup": {
"caseManagement": "Case Management",
"aiMatch": "AI Match",
"historyAnalysis": "History & Analysis",
"auditTrail": "Audit Trail"
}
}

파일 보기

@ -10,5 +10,49 @@
"enforcementPlan": {
"title": "Enforcement Plan & Alert",
"desc": "SFR-06 | Risk + past enforcement → priority area/time auto-recommendation & alerts"
},
"caseRegister": {
"title": "Case Register",
"desc": "SFR11-01 | Register enforcement case details (datetime, location, vessel, violation, action)"
},
"caseEdit": {
"title": "Case Edit",
"desc": "SFR11-02 | Edit registered enforcement case information"
},
"caseDetail": {
"title": "Case Detail",
"desc": "SFR11-03 | View full enforcement case details and AI match results"
},
"matchDashboard": {
"title": "Match Dashboard",
"desc": "SFR11-04 | AI detection vs enforcement result matching status (TP/FP/FN)"
},
"matchVerify": {
"title": "Match Verification",
"desc": "SFR11-05 | Verify AI detection match results and confirm labels (HitL)"
},
"labelList": {
"title": "Label Tasks",
"desc": "SFR11-06 | AI training label task progress and management"
},
"historySearch": {
"title": "Multi-dim Search",
"desc": "SFR11-07 | Multi-dimensional filter search by fishery, zone, season, violation type"
},
"vesselHistory": {
"title": "Vessel History",
"desc": "SFR11-08 | Vessel enforcement history, risk changes, violation patterns"
},
"typeStats": {
"title": "Type Statistics",
"desc": "SFR11-09 | Violation type distribution, monthly trends, performance metrics"
},
"reportGen": {
"title": "Report Generator",
"desc": "SFR11-10 | Auto-generate daily/weekly/monthly enforcement reports"
},
"changeHistory": {
"title": "Change History",
"desc": "SFR11-11 | Audit trail for enforcement case modifications (INSERT-ONLY)"
}
}

파일 보기

@ -30,7 +30,19 @@
"labelSession": "학습 세션",
"auditLogs": "감사 로그",
"accessLogs": "접근 이력",
"loginHistory": "로그인 이력"
"loginHistory": "로그인 이력",
"caseManagement": "사건 관리",
"caseRegister": "사건 등록",
"caseEdit": "사건 수정",
"caseDetail": "사건 상세",
"matchDashboard": "매칭 현황",
"matchVerify": "매칭 검증",
"labelList": "라벨링 작업",
"historySearch": "다차원 검색",
"vesselHistory": "선박별 이력",
"typeStats": "유형별 통계",
"reportGen": "보고서 생성",
"changeHistory": "변경 이력"
},
"status": {
"active": "활성",
@ -96,6 +108,13 @@
"parentInference": "모선 워크플로우",
"statistics": "통계·보고",
"aiOps": "AI 운영",
"admin": "시스템 관리"
"admin": "시스템 관리",
"enforcementPlan": "단속 계획"
},
"subGroup": {
"caseManagement": "단속 이력 관리",
"aiMatch": "AI 탐지 매칭",
"historyAnalysis": "이력 조회·분석",
"auditTrail": "수정 이력 감사"
}
}

파일 보기

@ -10,5 +10,45 @@
"enforcementPlan": {
"title": "단속 계획·경보 연계",
"desc": "SFR-06 | 위험도+과거 단속실적 종합 → 우선지역/시간대 자동 추천 · 경보 발령"
},
"caseList": {
"title": "단속 사건 관리",
"desc": "SFR11-01~03 | 단속 사건 리스트 조회 · 등록 · 상세 보기 · 수정"
},
"caseRegister": {
"title": "단속 사건 등록",
"desc": "SFR11-01 | 단속 사건별 기본정보(일시·장소·대상 선박/어구·위반 내용·조치 결과) 등록"
},
"matchDashboard": {
"title": "매칭 현황 대시보드",
"desc": "SFR11-04 | AI 의심 대상 vs 실제 단속 결과 자동 매칭 현황 (TP/FP/FN)"
},
"matchVerify": {
"title": "매칭 결과 등록·검증",
"desc": "SFR11-05 | AI 탐지 매칭 결과 검증 및 라벨 확정 (Human-in-the-Loop)"
},
"labelList": {
"title": "라벨링 작업 목록",
"desc": "SFR11-06 | AI 학습용 라벨링 작업 진행 현황 및 관리"
},
"historySearch": {
"title": "다차원 이력 검색",
"desc": "SFR11-07 | 어업종·해역·계절·위반 유형 다차원 필터 조회"
},
"vesselHistory": {
"title": "선박별 이력 조회",
"desc": "SFR11-08 | 특정 선박의 단속 이력, 위험도 변화, 위반 패턴 조회"
},
"typeStats": {
"title": "유형별 통계 차트",
"desc": "SFR11-09 | 위반 유형별 분포, 월별 추이, 성과 지표 시각화"
},
"reportGen": {
"title": "보고서 자동 생성",
"desc": "SFR11-10 | 일일/주간/월간 단속 보고서 자동 생성 및 다운로드"
},
"changeHistory": {
"title": "변경 이력 조회",
"desc": "SFR11-11 | 단속 사건 수정 이력 감사 추적 (INSERT-ONLY 영구 보존)"
}
}

파일 보기

@ -29,6 +29,10 @@ interface BaseMapProps {
onMapReady?: (map: maplibregl.Map) => void;
onClick?: (info: unknown) => void;
interactive?: boolean;
/** 테마를 강제 지정 (설정 무시). 모바일 프리뷰 등에서 light 강제 시 사용 */
forceTheme?: 'dark' | 'light';
/** 줌 컨트롤 위치 (기본: top-right) */
navPosition?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
}
function createRasterStyle(theme: 'dark' | 'light'): maplibregl.StyleSpecification {
@ -57,13 +61,16 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
onMapReady,
onClick,
interactive = true,
forceTheme,
navPosition = 'top-right',
},
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const theme = useSettingsStore((s) => s.theme);
const storeTheme = useSettingsStore((s) => s.theme);
const theme = forceTheme ?? storeTheme;
const handleClick = useCallback((info: unknown) => { onClick?.(info); }, [onClick]);
@ -86,7 +93,7 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
});
if (interactive) {
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), navPosition);
}
const overlay = new MapboxOverlay({ layers: [], onClick: handleClick });
@ -95,7 +102,13 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
mapRef.current = map;
overlayRef.current = overlay;
map.on('load', () => { onMapReady?.(map); });
if (onMapReady) {
if (map.loaded()) {
onMapReady(map);
} else {
map.on('load', () => { onMapReady(map); });
}
}
return () => {
map.remove();

파일 보기

@ -3,10 +3,10 @@
* 원천: 10개 1
*/
/** CartoDB 래스터 타일 URL (테마별) — 임시: 향후 실 타일 서버로 교체 시 제거 */
/** 래스터 타일 URL (테마별) — OpenStreetMap 기반 */
export const TILE_URLS = {
dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
light: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
} as const;
/** @deprecated TILE_URLS 사용 권장 */

파일 보기

@ -25,6 +25,9 @@ export default defineConfig({
target: process.env.VITE_API_PROXY ?? 'https://kcg-ai-monitoring.gc-si.dev',
changeOrigin: true,
secure: false,
headers: {
Origin: 'https://kcg-ai-monitoring.gc-si.dev',
},
},
},
},