feat: S5 프론트 나머지 화면 실데이터 전환 — 탐지/함정/단속계획

탐지 화면 3개:
- GearDetection: gearStore 더미 → fetchGroups() API (GEAR_IN/OUT_ZONE)
- DarkVesselDetection: vesselStore 더미 → fetchVesselAnalysis() + filterDarkVessels()
  - 패턴 자동 분류 (완전차단/장기소실/MMSI변조/간헐송출)
- ChinaFishing: inline 더미 → fetchVesselAnalysis() + mmsi 412* 필터
  - 센서 카운터 동적 계산, 위험도 분포 도넛 차트

함정/단속계획:
- patrol.ts: 스텁 → GET /api/patrol-ships 실제 호출
- patrolStore: API 기반 (routes/scenarios는 mock 유지)
- EnforcementPlan: GET /api/enforcement/plans 연결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 12:46:08 +09:00
부모 4e6ac8645a
커밋 c17d190e1d
7개의 변경된 파일533개의 추가작업 그리고 214개의 파일을 삭제

파일 보기

@ -1,32 +1,31 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { import {
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, ShieldCheck, Radio, Anchor, RotateCcw, Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { GearIdentification } from './GearIdentification'; import { GearIdentification } from './GearIdentification';
import { RealAllVessels } from './RealVesselAnalysis'; import { RealAllVessels } from './RealVesselAnalysis';
import { BaseChart, PieChart as EcPieChart } from '@lib/charts'; import { PieChart as EcPieChart } from '@lib/charts';
import type { EChartsOption } from 'echarts';
import { useTransferStore } from '@stores/transferStore'; import { useTransferStore } from '@stores/transferStore';
import {
fetchVesselAnalysis,
filterDarkVessels,
filterTransshipSuspects,
type VesselAnalysisItem,
type VesselAnalysisStats,
} from '@/services/vesselAnalysisApi';
// ─── 센서 카운터 (시안 2행) ───────────── // ─── 중국 MMSI prefix ─────────────
const COUNTERS_ROW1 = [ const CHINA_MMSI_PREFIX = '412';
{ label: '통합', count: 1350, color: '#6b7280', icon: '🔵' },
{ label: 'AIS', count: 2212, color: '#3b82f6', icon: '🟢' },
{ label: 'E-Nav', count: 745, color: '#8b5cf6', icon: '🔷' },
{ label: '여객선', count: 1, color: '#10b981', icon: '🟡' },
];
const COUNTERS_ROW2 = [
{ label: '중국어선', count: 20, color: '#f97316', icon: '🟠' },
{ label: 'V-PASS', count: 465, color: '#06b6d4', icon: '🟢' },
{ label: '함정', count: 2, color: '#6b7280', icon: '🔵' },
{ label: '위험물', count: 0, color: '#6b7280', icon: '⚪' },
];
// ─── 특이운항 선박 리스트 ──────────────── function isChinaVessel(mmsi: string): boolean {
return mmsi.startsWith(CHINA_MMSI_PREFIX);
}
// ─── 특이운항 선박 리스트 타입 ────────────────
type VesselStatus = '의심' | '양호' | '경고'; type VesselStatus = '의심' | '양호' | '경고';
interface VesselItem { interface VesselItem {
id: string; id: string;
@ -41,30 +40,27 @@ interface VesselItem {
riskPct: number; riskPct: number;
} }
const VESSEL_LIST: VesselItem[] = [ function deriveVesselStatus(score: number): VesselStatus {
{ id: '1', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 }, if (score >= 70) return '경고';
{ id: '2', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '양호', riskPct: 70 }, if (score >= 40) return '의심';
{ id: '3', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 24 }, return '양호';
{ id: '4', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '경고', riskPct: 84 }, }
{ id: '5', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
{ id: '6', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
];
// ─── 월별 불법조업 통계 ────────────────── function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
const MONTHLY_DATA = [ const score = item.algorithms.riskScore.score;
{ month: 'JAN', 범장망: 45, 쌍끌이: 30, 외끌이: 20, 트롤: 10 }, return {
{ month: 'FEB', 범장망: 55, 쌍끌이: 35, 외끌이: 25, 트롤: 15 }, id: String(idx + 1),
{ month: 'MAR', 범장망: 70, 쌍끌이: 45, 외끌이: 30, 트롤: 20 }, mmsi: item.mmsi,
{ month: 'APR', 범장망: 85, 쌍끌이: 50, 외끌이: 35, 트롤: 25 }, callSign: '-',
{ month: 'MAY', 범장망: 95, 쌍끌이: 55, 외끌이: 40, 트롤: 30 }, channel: '',
{ month: 'JUN', 범장망: 80, 쌍끌이: 45, 외끌이: 35, 트롤: 22 }, source: 'AIS',
{ month: 'JUL', 범장망: 60, 쌍끌이: 35, 외끌이: 25, 트롤: 18 }, name: item.classification.vesselType || item.mmsi,
{ month: 'AUG', 범장망: 50, 쌍끌이: 30, 외끌이: 20, 트롤: 12 }, type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
{ month: 'SEP', 범장망: 65, 쌍끌이: 40, 외끌이: 28, 트롤: 20 }, country: 'China',
{ month: 'OCT', 범장망: 75, 쌍끌이: 48, 외끌이: 32, 트롤: 22 }, status: deriveVesselStatus(score),
{ month: 'NOV', 범장망: 90, 쌍끌이: 52, 외끌이: 38, 트롤: 28 }, riskPct: score,
{ month: 'DEC', 범장망: 100, 쌍끌이: 60, 외끌이: 42, 트롤: 30 }, };
]; }
// ─── VTS 연계 항목 ───────────────────── // ─── VTS 연계 항목 ─────────────────────
const VTS_ITEMS = [ const VTS_ITEMS = [
@ -299,6 +295,81 @@ export function ChinaFishing() {
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
// API state
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [apiLoading, setApiLoading] = useState(false);
const [apiError, setApiError] = useState('');
const loadApi = useCallback(async () => {
setApiLoading(true);
setApiError('');
try {
const res = await fetchVesselAnalysis();
setServiceAvailable(res.serviceAvailable);
setAllItems(res.items);
setApiStats(res.stats);
} catch (e: unknown) {
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally {
setApiLoading(false);
}
}, []);
useEffect(() => { loadApi(); }, [loadApi]);
// 중국어선 필터
const chinaVessels = useMemo(
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
[allItems],
);
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
// 센서 카운터 (API 기반)
const countersRow1 = useMemo(() => [
{ label: '통합', count: allItems.length, color: '#6b7280' },
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
], [allItems]);
const countersRow2 = useMemo(() => [
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
], [chinaVessels, chinaDark, chinaTransship]);
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
const vesselList: VesselItem[] = useMemo(
() => chinaVessels
.filter((i) => i.algorithms.riskScore.score >= 40)
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
.slice(0, 20)
.map((item, idx) => mapToVesselItem(item, idx)),
[chinaVessels],
);
// 위험도별 분포 (도넛 차트용)
const riskDistribution = useMemo(() => {
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
return { critical, high, medium, low, total: chinaVessels.length };
}, [chinaVessels]);
// 안전도 지수 계산
const safetyIndex = useMemo(() => {
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
}, [chinaVessels]);
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
@ -337,6 +408,21 @@ export function ChinaFishing() {
{/* AI 대시보드 모드 */} {/* AI 대시보드 모드 */}
{mode === 'dashboard' && <> {mode === 'dashboard' && <>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
</div>
)}
{apiError && <div className="text-xs text-red-400">: {apiError}</div>}
{apiLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{/* iran 백엔드 실시간 분석 결과 */} {/* iran 백엔드 실시간 분석 결과 */}
<RealAllVessels /> <RealAllVessels />
@ -344,9 +430,9 @@ export function ChinaFishing() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40"> <div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
<Clock className="w-3.5 h-3.5 text-muted-foreground" /> <Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label">기준 : 2023-09-25 14:56</span> <span className="text-[11px] text-label"> : {new Date().toLocaleString('ko-KR')}</span>
</div> </div>
<button className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors"> <button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5"> <div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
@ -368,20 +454,22 @@ export function ChinaFishing() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-heading"> </span> <span className="text-sm font-bold text-heading"> </span>
{apiStats && (
<div className="flex items-center gap-2 text-[10px]"> <div className="flex items-center gap-2 text-[10px]">
<span className="text-hint"></span> <span className="text-hint"> </span>
<span className="text-heading font-bold font-mono">123-456</span> <span className="text-heading font-bold font-mono">{apiStats.total.toLocaleString()}</span>
</div> </div>
)}
</div> </div>
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground"> <div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
<span> </span> <span> </span>
<span className="text-lg font-extrabold text-heading">12,454</span> <span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
<span className="text-hint">()</span> <span className="text-hint">()</span>
</div> </div>
{/* 카운터 Row 1 */} {/* 카운터 Row 1 */}
<div className="grid grid-cols-4 gap-2 mb-2"> <div className="grid grid-cols-4 gap-2 mb-2">
{COUNTERS_ROW1.map((c) => ( {countersRow1.map((c) => (
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30"> <div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1">{c.label}</div> <div className="text-[9px] text-hint mb-1">{c.label}</div>
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div> <div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
@ -390,10 +478,10 @@ export function ChinaFishing() {
</div> </div>
{/* 카운터 Row 2 */} {/* 카운터 Row 2 */}
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{COUNTERS_ROW2.map((c) => ( {countersRow2.map((c) => (
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30"> <div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1">{c.label}</div> <div className="text-[9px] text-hint mb-1">{c.label}</div>
<div className="text-lg font-extrabold font-mono" style={{ color: c.count > 0 ? '#e5e7eb' : '#4b5563' }}> <div className={`text-lg font-extrabold font-mono ${c.count > 0 ? 'text-heading' : 'text-muted'}`}>
{c.count > 0 ? c.count.toLocaleString() : '-'} {c.count > 0 ? c.count.toLocaleString() : '-'}
</div> </div>
</div> </div>
@ -413,13 +501,13 @@ export function ChinaFishing() {
<div className="text-[10px] text-muted-foreground mb-2 text-center"> <div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-orange-400 font-medium"></span> <span className="text-orange-400 font-medium"></span>
</div> </div>
<SemiGauge value={5.21} label="" color="#f97316" /> <SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div> </div>
<div> <div>
<div className="text-[10px] text-muted-foreground mb-2 text-center"> <div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-blue-400 font-medium"></span> <span className="text-blue-400 font-medium"></span>
</div> </div>
<SemiGauge value={5.21} label="" color="#3b82f6" /> <SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -457,7 +545,7 @@ export function ChinaFishing() {
<span className="text-green-400 font-bold ml-auto"></span> <span className="text-green-400 font-bold ml-auto"></span>
</div> </div>
</div> </div>
<CircleGauge value={90.2} label="" /> <CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -490,7 +578,12 @@ export function ChinaFishing() {
{/* 선박 목록 */} {/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto"> <div className="max-h-[420px] overflow-y-auto">
{VESSEL_LIST.map((v) => ( {vesselList.length === 0 && (
<div className="px-4 py-8 text-center text-hint text-xs">
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div>
)}
{vesselList.map((v) => (
<div <div
key={v.id} key={v.id}
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group" className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
@ -498,8 +591,7 @@ export function ChinaFishing() {
<StatusRing status={v.status} riskPct={v.riskPct} /> <StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5"> <div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
<span>ID | <span className="text-label">{v.mmsi}</span></span> <span>MMSI | <span className="text-label">{v.mmsi}</span></span>
<span> | <span className="text-label">{v.callSign}</span></span>
<span> | <span className="text-label">{v.source}</span></span> <span> | <span className="text-label">{v.source}</span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -507,7 +599,6 @@ export function ChinaFishing() {
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge> <Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
</div> </div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint"> <div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>🇰🇷</span>
<span>{v.country}</span> <span>{v.country}</span>
</div> </div>
</div> </div>
@ -543,75 +634,45 @@ export function ChinaFishing() {
</div> </div>
<div className="p-4 flex gap-4"> <div className="p-4 flex gap-4">
{/* 바 차트 */} {/* 월별 통계 - API 미지원, 준비중 안내 */}
<div className="flex-1"> <div className="flex-1 flex flex-col items-center justify-center py-8">
<BaseChart height={220} option={{ <div className="text-muted-foreground text-xs mb-2"> </div>
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false }, <div className="text-hint text-[10px] bg-surface-overlay rounded-lg px-4 py-3 border border-border">
tooltip: { trigger: 'axis' }, API . .
xAxis: { type: 'category', data: MONTHLY_DATA.map(d => d.month) },
yAxis: { type: 'value' },
series: [
{ name: '범장망', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.), itemStyle: { color: '#22c55e' } },
{ name: '쌍끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.), itemStyle: { color: '#f97316' } },
{ name: '외끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.), itemStyle: { color: '#60a5fa' } },
{ name: '트롤', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.), itemStyle: { color: '#6b7280', borderRadius: [2, 2, 0, 0] } },
],
} as EChartsOption} />
{/* 범례 */}
<div className="flex items-center justify-center gap-4 mt-2">
{[
{ label: '범장망 선박', color: '#22c55e' },
{ label: '쌍끌이 선박', color: '#f97316' },
{ label: '외끌이 선박', color: '#60a5fa' },
{ label: '트롤 선박', color: '#6b7280' },
].map((l) => (
<span key={l.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
<span className="w-2 h-2 rounded-full" style={{ background: l.color }} />
{l.label}
</span>
))}
</div> </div>
</div> </div>
{/* 도넛 2개 */} {/* 위험도 분포 도넛 */}
<div className="flex flex-col items-center justify-center gap-3 w-24"> <div className="flex flex-col items-center justify-center gap-3 w-28">
<div className="relative w-[80px] h-[80px]"> <div className="relative w-[80px] h-[80px]">
<EcPieChart <EcPieChart
data={[ data={[
{ name: 'active', value: 70, color: '#22c55e' }, { name: 'CRITICAL', value: riskDistribution.critical || 1, color: '#ef4444' },
{ name: 'rest', value: 30, color: '#1e293b' }, { name: 'HIGH', value: riskDistribution.high || 1, color: '#f97316' },
{ name: 'MEDIUM', value: riskDistribution.medium || 1, color: '#eab308' },
{ name: 'LOW', value: riskDistribution.low || 1, color: '#3b82f6' },
]} ]}
height={80} height={80}
innerRadius={24} innerRadius={24}
outerRadius={34} outerRadius={34}
/> />
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-sm font-extrabold text-heading">356</span> <span className="text-sm font-extrabold text-heading">{riskDistribution.total}</span>
<span className="text-[7px] text-hint">TOTAL</span> <span className="text-[7px] text-hint"></span>
</div> </div>
</div> </div>
<div className="relative w-[80px] h-[80px]"> <div className="space-y-0.5 text-[8px]">
<EcPieChart <div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
data={[ <div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
{ name: 'active', value: 60, color: '#22c55e' }, <div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
{ name: 'rest', value: 40, color: '#1e293b' }, <div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
]}
height={80}
innerRadius={24}
outerRadius={34}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-sm font-extrabold text-heading">356</span>
<span className="text-[7px] text-hint">TOTAL</span>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* 다운로드 버튼 */} {/* 다운로드 버튼 */}
<div className="px-4 pb-3 flex justify-end"> <div className="px-4 pb-3 flex justify-end">
<button className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors"> <button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
</button> </button>
</div> </div>

파일 보기

@ -1,38 +1,77 @@
import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Eye, EyeOff, AlertTriangle, Ship, Radar, Radio, Target, Shield, Tag } from 'lucide-react'; import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { useVesselStore } from '@stores/vesselStore'; import {
import { RealDarkVessels, RealSpoofingVessels } from './RealVesselAnalysis'; fetchVesselAnalysis,
filterDarkVessels,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ /* 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; [key: string]: unknown; }
const FLAG_MAP: Record<string, string> = { CN: '중국', KR: '한국', UNKNOWN: '미상' }; const GAP_FULL_BLOCK_MIN = 1440;
const GAP_LONG_LOSS_MIN = 60;
const SPOOFING_THRESHOLD = 0.7;
function derivePattern(item: VesselAnalysisItem): string {
const { gapDurationMin } = item.algorithms.darkVessel;
const { spoofingScore } = item.algorithms.gpsSpoofing;
if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단';
if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심';
if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실';
return '신호 간헐송출';
}
function deriveStatus(item: VesselAnalysisItem): string {
const { score } = item.algorithms.riskScore;
if (score >= 80) return '추적중';
if (score >= 50) return '감시중';
if (score >= 30) return '확인중';
return '정상';
}
function deriveFlag(mmsi: string): string {
if (mmsi.startsWith('412')) return '중국';
if (mmsi.startsWith('440') || mmsi.startsWith('441')) return '한국';
return '미상';
}
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
const risk = item.algorithms.riskScore.score;
const status = deriveStatus(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),
risk,
lastAIS: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-',
status,
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
lat: 0,
lng: 0,
};
}
const PATTERN_COLORS: Record<string, string> = { const PATTERN_COLORS: Record<string, string> = {
'AIS 완전차단': '#ef4444', 'AIS 완전차단': '#ef4444',
'MMSI 3회 변경': '#f97316', 'MMSI 변조 의심': '#f97316',
'급격 속력변화': '#eab308', '장기소실': '#eab308',
'신호 간헐송출': '#a855f7', '신호 간헐송출': '#a855f7',
'비정기 신호': '#3b82f6',
'국적 위장 의심': '#ec4899',
};
const STATUS_COLORS: Record<string, string> = {
'추적중': '#ef4444',
'감시중': '#eab308',
'확인중': '#3b82f6',
'정상': '#22c55e',
}; };
const cols: DataColumn<Suspect>[] = [ const cols: DataColumn<Suspect>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> }, { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'name', label: '선박명', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> }, { key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'flag', label: '국적', width: '50px' }, { key: 'flag', label: '국적', width: '50px' },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
@ -46,33 +85,42 @@ const cols: DataColumn<Suspect>[] = [
export function DarkVesselDetection() { export function DarkVesselDetection() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { suspects, loaded, load } = useVesselStore(); const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
useEffect(() => { if (!loaded) load(); }, [loaded, load]); const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetchVesselAnalysis();
setServiceAvailable(res.serviceAvailable);
setDarkItems(filterDarkVessels(res.items));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// Map VesselData to local Suspect shape
const DATA: Suspect[] = useMemo( const DATA: Suspect[] = useMemo(
() => () => darkItems.map((item, i) => mapItemToSuspect(item, i)),
suspects.map((v) => ({ [darkItems],
id: v.id, );
mmsi: v.mmsi,
name: v.name, const avgRisk = useMemo(
flag: FLAG_MAP[v.flag] ?? v.flag, () => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0,
pattern: v.pattern ?? '-', [DATA],
risk: v.risk,
lastAIS: v.lastSignal ?? '-',
status: v.status,
label: v.risk >= 90 ? (v.status === '추적중' ? '불법' : '-') : v.status === '정상' ? '정상' : '-',
lat: v.lat,
lng: v.lng,
})),
[suspects],
); );
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...STATIC_LAYERS,
// 경보 반경 (고위험만)
createRadiusLayer( createRadiusLayer(
'dv-radius', 'dv-radius',
DATA.filter(d => d.risk > 80).map(d => ({ DATA.filter(d => d.risk > 80).map(d => ({
@ -83,7 +131,6 @@ export function DarkVesselDetection() {
})), })),
0.08, 0.08,
), ),
// 탐지 선박 마커
createMarkerLayer( createMarkerLayer(
'dv-markers', 'dv-markers',
DATA.map(d => ({ DATA.map(d => ({
@ -106,22 +153,36 @@ export function DarkVesselDetection() {
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p> <p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
</div> </div>
</div> </div>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - Dark Vessel </span>
</div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
{[{ l: '의심 선박', v: DATA.filter(d => d.risk > 50).length, c: 'text-red-400', i: AlertTriangle }, {[
{ l: 'Dark Vessel', v: DATA.filter(d => d.pattern.includes('차단')).length, c: 'text-orange-400', i: EyeOff }, { l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle },
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern.includes('MMSI')).length, c: 'text-yellow-400', i: Radio }, { l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff },
{ l: '라벨링 완료', v: DATA.filter(d => d.label !== '-').length + '/' + DATA.length, c: 'text-cyan-400', i: Tag }, { l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio },
{ l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag },
].map(k => ( ].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"> <div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span> <k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div> </div>
))} ))}
</div> </div>
{/* iran 백엔드 실시간 Dark Vessel + GPS 스푸핑 */}
<RealDarkVessels />
<RealSpoofingVessels />
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" /> <DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
{/* 탐지 위치 지도 */} {/* 탐지 위치 지도 */}
<Card> <Card>

파일 보기

@ -1,13 +1,12 @@
import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, MapPin, AlertTriangle, CheckCircle, Clock, Ship, Filter } from 'lucide-react'; import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { useGearStore } from '@stores/gearStore'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { RealGearGroups } from './RealGearGroups';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */ /* SFR-10: 불법 어망·어구 탐지 및 관리 */
@ -19,14 +18,36 @@ const RISK_COLORS: Record<string, string> = {
'안전': '#22c55e', '안전': '#22c55e',
}; };
const GEAR_ICONS: Record<string, string> = { function deriveRisk(g: GearGroupItem): string {
'저층트롤': '🔴', if (g.resolution?.status === 'REVIEW_REQUIRED') return '고위험';
'유자망': '🟠', if (g.resolution?.status === 'UNRESOLVED') return '중위험';
'유자망(대형)': '🔴', return '안전';
'통발': '🟢', }
'선망': '🟡',
'연승': '🔵', function deriveStatus(g: GearGroupItem): string {
if (g.resolution?.status === 'REVIEW_REQUIRED') return '불법 의심';
if (g.resolution?.status === 'UNRESOLVED') return '확인 중';
if (g.resolution?.status === 'MANUAL_CONFIRMED') return '정상';
return '확인 중';
}
function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
const risk = deriveRisk(g);
const status = deriveStatus(g);
return {
id: `G-${String(idx + 1).padStart(3, '0')}`,
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'),
owner: g.members[0]?.name || g.members[0]?.mmsi || '-',
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
status,
permit: 'NONE',
installed: g.snapshotTime ? new Date(g.snapshotTime).toLocaleDateString('ko-KR') : '-',
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
risk,
lat: g.centerLat,
lng: g.centerLon,
}; };
}
const cols: DataColumn<Gear>[] = [ const cols: DataColumn<Gear>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> }, { key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
@ -44,17 +65,39 @@ const cols: DataColumn<Gear>[] = [
export function GearDetection() { export function GearDetection() {
const { t } = useTranslation('detection'); const { t } = useTranslation('detection');
const { items, loaded, load } = useGearStore(); const [groups, setGroups] = useState<GearGroupItem[]>([]);
useEffect(() => { if (!loaded) load(); }, [loaded, load]); const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// GearRecord from the store matches the local Gear shape exactly const loadData = useCallback(async () => {
const DATA: Gear[] = items as unknown as Gear[]; setLoading(true);
setError('');
try {
const res = await fetchGroups();
setServiceAvailable(res.serviceAvailable);
setGroups(res.items.filter(
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const DATA: Gear[] = useMemo(
() => groups.map((g, i) => mapGroupToGear(g, i)),
[groups],
);
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...STATIC_LAYERS,
// 어구 설치 영역 (고위험만)
createRadiusLayer( createRadiusLayer(
'gear-radius', 'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({ DATA.filter(g => g.risk === '고위험').map(g => ({
@ -65,7 +108,6 @@ export function GearDetection() {
})), })),
0.1, 0.1,
), ),
// 어구 마커
createMarkerLayer( createMarkerLayer(
'gear-markers', 'gear-markers',
DATA.map(g => ({ DATA.map(g => ({
@ -86,15 +128,36 @@ export function GearDetection() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Anchor className="w-5 h-5 text-orange-400" />{t('gearDetection.title')}</h2> <h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Anchor className="w-5 h-5 text-orange-400" />{t('gearDetection.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('gearDetection.desc')}</p> <p className="text-[10px] text-hint mt-0.5">{t('gearDetection.desc')}</p>
</div> </div>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
</div>
)}
{error && (
<div className="text-xs text-red-400">: {error}</div>
)}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
{[{ l: '전체 어구', v: DATA.length, c: 'text-heading' }, { l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, { l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, { l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }].map(k => ( {[
{ l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' },
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' },
].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"> <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> <span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div> </div>
))} ))}
</div> </div>
{/* iran 백엔드 실시간 어구/선단 그룹 */}
<RealGearGroups />
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" /> <DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />

파일 보기

@ -1,17 +1,33 @@
import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Shield, AlertTriangle, Clock, MapPin, Ship, Bell, Plus, Target, Calendar, Users } from 'lucide-react'; import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { useEnforcementStore } from '@stores/enforcementStore'; import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */ /* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; } interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
/** API 응답 → 화면용 Plan 변환 */
function toPlan(p: EnforcementPlanApi): Plan {
return {
id: p.planUid,
zone: p.areaName ?? p.zoneCode ?? '-',
lat: p.lat ?? 0,
lng: p.lon ?? 0,
risk: p.riskScore ?? 0,
period: p.plannedDate,
ships: `${p.assignedShipCount}`,
crew: p.assignedCrew,
status: p.status,
alert: p.alertStatus ?? '-',
};
}
const cols: DataColumn<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: '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: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
@ -21,20 +37,38 @@ const cols: DataColumn<Plan>[] = [
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> }, { key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> }, { key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' ? 'bg-green-500/20 text-green-400' : s === '계획중' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } }, render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
{ key: 'alert', label: '경보', width: '80px', align: 'center', { key: 'alert', label: '경보', width: '80px', align: 'center',
render: v => { const a = v as string; return a === '경보 발령' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } }, render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
]; ];
export function EnforcementPlan() { export function EnforcementPlan() {
const { t } = useTranslation('enforcement'); const { t } = useTranslation('enforcement');
const { plans: storePlans, load } = useEnforcementStore();
useEffect(() => { load(); }, [load]);
const PLANS: Plan[] = useMemo( const [plans, setPlans] = useState<Plan[]>([]);
() => storePlans.map((p) => ({ ...p } as Plan)), const [loading, setLoading] = useState(false);
[storePlans], const [error, setError] = useState<string | null>(null);
);
useEffect(() => {
let cancelled = false;
setLoading(true);
getEnforcementPlans({ size: 100 })
.then((res) => {
if (!cancelled) {
setPlans(res.content.map(toPlan));
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const PLANS = plans;
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
@ -42,7 +76,7 @@ export function EnforcementPlan() {
...STATIC_LAYERS, ...STATIC_LAYERS,
createRadiusLayer( createRadiusLayer(
'ep-radius-confirmed', 'ep-radius-confirmed',
PLANS.filter(p => p.status === '확정').map(p => ({ PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
lat: p.lat, lat: p.lat,
lng: p.lng, lng: p.lng,
radius: 20000, radius: 20000,
@ -52,7 +86,7 @@ export function EnforcementPlan() {
), ),
createRadiusLayer( createRadiusLayer(
'ep-radius-planned', 'ep-radius-planned',
PLANS.filter(p => p.status !== '확정').map(p => ({ PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({
lat: p.lat, lat: p.lat,
lng: p.lng, lng: p.lng,
radius: 20000, radius: 20000,
@ -74,6 +108,15 @@ export function EnforcementPlan() {
useMapLayers(mapRef, buildLayers, [PLANS]); useMapLayers(mapRef, buildLayers, [PLANS]);
// 통계 요약값
const todayCount = PLANS.length;
const alertCount = PLANS.filter(p => p.alert === '경보 발령' || p.alert === 'ALERT').length;
const totalShips = PLANS.reduce((sum, p) => {
const num = parseInt(p.ships, 10);
return sum + (isNaN(num) ? 0 : num);
}, 0);
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
return ( return (
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -83,8 +126,22 @@ export function EnforcementPlan() {
</div> </div>
<button 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"><Plus className="w-3.5 h-3.5" /> </button> <button 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"><Plus className="w-3.5 h-3.5" /> </button>
</div> </div>
{/* 로딩/에러 상태 */}
{loading && (
<div className="text-center text-muted-foreground text-sm py-4"> ...</div>
)}
{error && (
<div className="text-center text-red-400 text-sm py-4"> : {error}</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
{[{ l: '오늘 계획', v: '3건', c: 'text-heading', i: Calendar }, { l: '경보 발령', v: '1건', c: 'text-red-400', i: AlertTriangle }, { l: '투입 함정', v: '4척', c: 'text-cyan-400', i: Ship }, { l: '투입 인력', v: '90명', c: 'text-green-400', i: Users }].map(k => ( {[
{ l: '오늘 계획', v: `${todayCount}`, c: 'text-heading', i: Calendar },
{ l: '경보 발령', v: `${alertCount}`, c: 'text-red-400', i: AlertTriangle },
{ l: '투입 함정', v: `${totalShips}`, c: 'text-cyan-400', i: Ship },
{ l: '투입 인력', v: `${totalCrew}`, c: 'text-green-400', i: Users },
].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"> <div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span> <k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div> </div>

파일 보기

@ -4,7 +4,8 @@ export { getEvents, getEventById, ackEvent, updateEventStatus, getEventStats } f
export type { PredictionEvent, EventPageResponse, EventStats } from './event'; export type { PredictionEvent, EventPageResponse, EventStats } from './event';
export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement'; export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement';
export type { EnforcementRecord, EnforcementPlan } from './enforcement'; export type { EnforcementRecord, EnforcementPlan } from './enforcement';
export { getPatrolShips } from './patrol'; export { getPatrolShips, updatePatrolShipStatus, toLegacyPatrolShip } from './patrol';
export type { PatrolShipApi } from './patrol';
export { export {
getKpiMetrics, getKpiMetrics,
getMonthlyStats, getMonthlyStats,

파일 보기

@ -1,10 +1,71 @@
/** /**
* <EFBFBD><EFBFBD><EFBFBD>/ API * / API --
*/ */
import type { PatrolShip } from '@data/mock/patrols'; import type { PatrolShip } from '@data/mock/patrols';
import { MOCK_PATROL_SHIPS } from '@data/mock/patrols';
/** TODO: GET /api/v1/patrols/ships */ const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export async function getPatrolShips(): Promise<PatrolShip[]> {
return MOCK_PATROL_SHIPS; // ─── 서버 응답 타입 ───────────────────────────────
export interface PatrolShipApi {
shipId: number;
shipCode: string;
shipName: string;
shipClass: string;
tonnage: number | null;
maxSpeedKn: number | null;
fuelCapacityL: number | null;
basePort: string | null;
currentStatus: string;
currentLat: number | null;
currentLon: number | null;
currentZoneCode: string | null;
fuelPct: number | null;
crewCount: number | null;
isActive: boolean;
}
// ─── API 호출 ─────────────────────────────────────
export async function getPatrolShips(): Promise<PatrolShipApi[]> {
const res = await fetch(`${API_BASE}/patrol-ships`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function updatePatrolShipStatus(
id: number,
data: {
status: string;
lat?: number;
lon?: number;
zoneCode?: string;
fuelPct?: number;
},
): Promise<PatrolShipApi> {
const res = await fetch(`${API_BASE}/patrol-ships/${id}/status`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 하위 호환 헬퍼 (기존 PatrolShip 형식 → API 응답 매핑) ──
/** PatrolShipApi → PatrolShip (레거시) 변환 */
export function toLegacyPatrolShip(s: PatrolShipApi): PatrolShip {
return {
id: s.shipCode,
name: s.shipName,
shipClass: s.shipClass,
speed: s.maxSpeedKn ?? 0,
status: s.currentStatus,
lat: s.currentLat ?? 0,
lng: s.currentLon ?? 0,
fuel: s.fuelPct ?? 0,
zone: s.currentZoneCode ?? undefined,
};
} }

파일 보기

@ -5,6 +5,7 @@ import type {
PatrolScenario, PatrolScenario,
CoverageZone, CoverageZone,
} from '@data/mock/patrols'; } from '@data/mock/patrols';
import { getPatrolShips, toLegacyPatrolShip } from '@/services/patrol';
interface PatrolStore { interface PatrolStore {
ships: PatrolShip[]; ships: PatrolShip[];
@ -14,7 +15,9 @@ interface PatrolStore {
fleetRoutes: Record<string, [number, number][]>; fleetRoutes: Record<string, [number, number][]>;
selectedShipId: string | null; selectedShipId: string | null;
loaded: boolean; loaded: boolean;
load: () => void; loading: boolean;
error: string | null;
load: () => Promise<void>;
selectShip: (id: string | null) => void; selectShip: (id: string | null) => void;
} }
@ -26,27 +29,39 @@ export const usePatrolStore = create<PatrolStore>((set, get) => ({
fleetRoutes: {}, fleetRoutes: {},
selectedShipId: null, selectedShipId: null,
loaded: false, loaded: false,
loading: false,
error: null,
load: async () => {
if (get().loaded && !get().error) return;
set({ loading: true, error: null });
try {
// 함정 목록은 API에서, 나머지(routes/scenarios/coverage)는 mock 유지
const [apiShips, mockModule] = await Promise.all([
getPatrolShips(),
get().routes && Object.keys(get().routes).length > 0
? Promise.resolve(null)
: import('@data/mock/patrols').then((m) => ({
routes: m.MOCK_PATROL_ROUTES,
scenarios: m.MOCK_PATROL_SCENARIOS,
coverage: m.MOCK_COVERAGE_ZONES,
fleetRoutes: m.MOCK_FLEET_ROUTES,
})),
]);
load: () => {
if (get().loaded) return;
import('@data/mock/patrols').then(
({
MOCK_PATROL_SHIPS,
MOCK_PATROL_ROUTES,
MOCK_PATROL_SCENARIOS,
MOCK_COVERAGE_ZONES,
MOCK_FLEET_ROUTES,
}) => {
set({ set({
ships: MOCK_PATROL_SHIPS, ships: apiShips.map(toLegacyPatrolShip),
routes: MOCK_PATROL_ROUTES, routes: mockModule?.routes ?? get().routes,
scenarios: MOCK_PATROL_SCENARIOS, scenarios: mockModule?.scenarios ?? get().scenarios,
coverage: MOCK_COVERAGE_ZONES, coverage: mockModule?.coverage ?? get().coverage,
fleetRoutes: MOCK_FLEET_ROUTES, fleetRoutes: mockModule?.fleetRoutes ?? get().fleetRoutes,
loaded: true, loaded: true,
loading: false,
}); });
}, } catch (err) {
); set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
}, },
selectShip: (id) => set({ selectedShipId: id }), selectShip: (id) => set({ selectedShipId: id }),