kcg-ai-monitoring/frontend/src/features/detection/GearIdentification.tsx
htlee 9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
iran 백엔드 프록시 잔재 제거:
- IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거
- Frontend UI 라벨/주석/system-flow manifest deprecated 마킹
- CLAUDE.md 시스템 구성 다이어그램 최신화

백엔드 계층 분리:
- AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거
- AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true)
- Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합

감사 로그 보강:
- EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가
- VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록

카탈로그 정합성:
- performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출)
- alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder
- LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출
- GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00

1252 lines
54 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
ChevronRight, Info, Shield, Radar, Target, Waves,
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
// ─── 판별 기준 데이터 ─────────────────
type GearType = 'trawl' | 'gillnet' | 'purseSeine' | 'setNet' | 'trap' | 'unknown';
type Origin = 'china' | 'korea' | 'uncertain';
type Confidence = 'high' | 'medium' | 'low';
interface IdentificationResult {
origin: Origin;
confidence: Confidence;
gearType: GearType;
gearSubType: string;
gbCode: string;
koreaName: string;
reasons: string[];
warnings: string[];
actionRequired: string;
alertLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
}
// ─── 판별 입력 폼 상태 ────────────────
interface GearInput {
// 어구 물리적 특성
gearCategory: GearType;
meshSize: number | null; // mm
netLength: number | null; // m
netWidth: number | null; // m
hasChineseMarkings: boolean;
hasBuoys: boolean;
buoyCount: number | null;
hasFrame: boolean; // 프레임 구조 (정치망)
hasDrawstring: boolean; // 죔줄 (선망)
// AIS/선박 정보
aisActive: boolean;
aisGapHours: number | null;
mmsiPrefix: string;
permitCode: string; // C21, C22, C23, C25, C40 등
vesselTonnage: number | null;
vesselCount: number;
hasCompanionVessel: boolean; // 부속선 동반
// 행동 패턴
speedKnots: number | null;
trajectoryPattern: 'lawnmowing' | 'stationary' | 'circular' | 'drifting' | 'linear' | 'unknown';
operatingHours: number | null;
isNightOperation: boolean;
hasFishLight: boolean; // 집어등
// 위치/시기
seaArea: string; // 수역 I~IV
discoveryDate: string;
hasVesselNearby: boolean;
}
const DEFAULT_INPUT: GearInput = {
gearCategory: 'unknown',
meshSize: null,
netLength: null,
netWidth: null,
hasChineseMarkings: false,
hasBuoys: false,
buoyCount: null,
hasFrame: false,
hasDrawstring: false,
aisActive: true,
aisGapHours: null,
mmsiPrefix: '',
permitCode: '',
vesselTonnage: null,
vesselCount: 1,
hasCompanionVessel: false,
speedKnots: null,
trajectoryPattern: 'unknown',
operatingHours: null,
isNightOperation: false,
hasFishLight: false,
seaArea: '',
discoveryDate: '',
hasVesselNearby: true,
};
// ─── 판별 엔진 ────────────────────────
function identifyGear(input: GearInput): IdentificationResult {
const reasons: string[] = [];
const warnings: string[] = [];
let chinaScore = 0;
let koreaScore = 0;
let gearType: GearType = input.gearCategory;
let gearSubType = '';
let gbCode = '';
let koreaName = '';
let alertLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' = 'LOW';
// ── 1단계: 허가코드 기반 즉시 판별 ──
const permitPrefix = input.permitCode.toUpperCase();
if (permitPrefix.startsWith('C21')) {
chinaScore += 50;
gearType = 'trawl';
gearSubType = '2척식저인망(PT) 본선/부속선';
gbCode = 'TDS (쌍선저층트롤)';
koreaName = '쌍끌이기선저인망';
reasons.push('허가코드 C21-xxxxx → 중국 2척식저인망(PT) 확정');
} else if (permitPrefix.startsWith('C22')) {
chinaScore += 50;
gearType = 'trawl';
gearSubType = '1척식저인망(OT)';
gbCode = 'TDD (단선저층트롤)';
koreaName = '기선저인망';
reasons.push('허가코드 C22-xxxxx → 중국 1척식저인망(OT) 확정');
} else if (permitPrefix.startsWith('C23')) {
chinaScore += 50;
gearType = 'purseSeine';
gearSubType = '위망(PS) — 宁波海裕 선단';
gbCode = 'WDD (집어등단선선망)';
koreaName = '대형선망/근해선망';
reasons.push('허가코드 C23-xxxxx → 중국 위망(PS), 宁波海裕渔业 소속 확정');
warnings.push('위망 16척은 연중 전수역 허가 — 시기/장소 기반 자동 위반 판별 불가');
} else if (permitPrefix.startsWith('C25')) {
chinaScore += 50;
gearType = 'gillnet';
gearSubType = '유망(GN) — 유자망';
gbCode = 'CLD (단선유자망)';
koreaName = '유자망/연안유자망';
reasons.push('허가코드 C25-xxxxx → 중국 유망(GN) 확정');
warnings.push('유망은 AIS 차단 최다 업종 — 다크베셀 주의');
} else if (permitPrefix.startsWith('C40')) {
chinaScore += 50;
gearSubType = '운반선(FC)';
gbCode = '해당없음 (운반 전용)';
koreaName = '운반선';
reasons.push('허가코드 C40-xxxxx → 중국 운반선(FC) 확정, 할당량 0톤');
warnings.push('조업선 0.5NM 이내 접근 시 환적 의심 알람 발동');
}
// ── 2단계: 중국어 표기 확인 ──
if (input.hasChineseMarkings) {
chinaScore += 30;
reasons.push('어구/부표에 중국어 표기 확인 → 중국 어구 강력 추정');
}
// ── 3단계: 어구 유형별 물리적 특성 판별 ──
// ■ 트롤(저인망) 판별
if (gearType === 'trawl' || input.trajectoryPattern === 'lawnmowing') {
if (!gearType || gearType === 'unknown') gearType = 'trawl';
// 속도 패턴
if (input.speedKnots !== null && input.speedKnots >= 2 && input.speedKnots <= 5) {
reasons.push(`속도 ${input.speedKnots}kt → 트롤 조업 속도 범위(2~5kt) 일치`);
}
// 쌍선 운용 여부
if (input.hasCompanionVessel || input.vesselCount === 2) {
chinaScore += 15;
gearSubType = gearSubType || '2척식저인망(PT) 추정';
gbCode = gbCode || 'TDS';
reasons.push('2척 편대 운용 확인 → 중국 PT(쌍선저인망) 구조와 일치');
if (input.vesselTonnage !== null) {
if (input.vesselTonnage >= 68 && input.vesselTonnage <= 297) {
chinaScore += 10;
reasons.push(`선박 톤수 ${input.vesselTonnage}톤 → 중국 PT 평균 범위(68~297톤) 일치`);
}
}
} else if (input.vesselCount === 1 && input.vesselTonnage !== null && input.vesselTonnage >= 180) {
chinaScore += 8;
gearSubType = gearSubType || '1척식저인망(OT) 추정';
gbCode = gbCode || 'TDD';
reasons.push(`단선 운용 + ${input.vesselTonnage}톤 → 중국 OT(1척식저인망) 추정`);
}
// 망목 규격
if (input.meshSize !== null) {
if (input.meshSize < 54) {
chinaScore += 10;
warnings.push(`망목 ${input.meshSize}mm — 한국 기준 54mm 미만 → 금지 어구 사용 위반`);
alertLevel = 'HIGH';
} else {
reasons.push(`망목 ${input.meshSize}mm — 한국 기준(54mm) 충족`);
}
}
koreaName = koreaName || (input.vesselCount >= 2 ? '쌍끌이기선저인망' : '기선저인망');
}
// ■ 자망(유자망) 판별
if (gearType === 'gillnet' || input.trajectoryPattern === 'stationary' || input.trajectoryPattern === 'drifting') {
if (!gearType || gearType === 'unknown') gearType = 'gillnet';
// AIS 소실 — 중국 자망의 핵심 지표
if (!input.aisActive || (input.aisGapHours !== null && input.aisGapHours >= 6)) {
chinaScore += 25;
reasons.push(`AIS ${input.aisGapHours || '?'}시간 소실 → 중국 유망(GN) 다크베셀 전술 패턴`);
warnings.push('자망 탐지 핵심: AIS 공백 구간을 SAR 영상과 교차 검증 필수');
alertLevel = alertLevel === 'LOW' ? 'HIGH' : alertLevel;
} else if (input.aisActive) {
koreaScore += 10;
reasons.push('AIS 지속 송출 → 한국 유자망어선은 V-PASS + AIS 이중 추적 의무');
}
// 그물 규모
if (input.netLength !== null && input.netLength > 500) {
chinaScore += 15;
reasons.push(`그물 길이 ${input.netLength}m → 중국식 광폭·장형 대형 자망 특성`);
} else if (input.netLength !== null && input.netLength <= 500) {
koreaScore += 5;
reasons.push(`그물 길이 ${input.netLength}m → 한국 규격 범위 내`);
}
// 망목
if (input.meshSize !== null) {
if (input.meshSize >= 50 && input.meshSize <= 100) {
reasons.push(`망목 ${input.meshSize}mm — 한국 유자망 기준(50~100mm) 범위 내`);
} else if (input.meshSize < 50) {
chinaScore += 10;
warnings.push(`망목 ${input.meshSize}mm — 한국 기준 미달, 불법 어구 의심`);
}
}
// 정지 패턴
if (input.speedKnots !== null && input.speedKnots < 2) {
reasons.push(`속도 ${input.speedKnots}kt → 자망 조업 속도(0~2kt) 범위 일치`);
}
// 부표 다수 발견
if (input.hasBuoys && input.buoyCount !== null && input.buoyCount >= 10) {
reasons.push(`부표 ${input.buoyCount}개 발견 → SAR 고해상도에서 자망 설치 식별 지표`);
}
gbCode = gbCode || 'CLD/CLS';
koreaName = koreaName || '유자망어업';
gearSubType = gearSubType || '유자망(Gillnet)';
}
// ■ 선망(위망) 판별
if (gearType === 'purseSeine' || input.trajectoryPattern === 'circular') {
if (!gearType || gearType === 'unknown') gearType = 'purseSeine';
// 원형 궤적 + 고→저속 급변
if (input.trajectoryPattern === 'circular') {
reasons.push('원형/타원형 궤적 탐지 → 선망(Purse Seine) 패턴 확정 (신뢰도 94~97%)');
}
if (input.speedKnots !== null && input.speedKnots >= 8) {
reasons.push(`고속 ${input.speedKnots}kt 원형 이동 → 선망 포획 단계(8~10kt) 일치`);
}
// 선단 구조 — 중국 위망 핵심
if (input.vesselCount >= 3) {
chinaScore += 25;
reasons.push(`${input.vesselCount}척 클러스터 → 중국 위망 선단(모선+운반선+조명선) 구조`);
gearSubType = gearSubType || '위망 선단(Fleet)';
} else if (input.vesselCount <= 2) {
koreaScore += 10;
reasons.push('소규모 선박 구성 → 한국 선망어업 가능성');
}
// 야간 집어등
if (input.hasFishLight || input.isNightOperation) {
chinaScore += 20;
reasons.push('야간 집어등(灯光) 사용 → 중국 WDD(집어등선망) 코드, 위반 건수 1위');
gbCode = gbCode || 'WDD';
warnings.push('야간 EO 위성에서 최우선 탐지 대상');
}
// 초대형 선박
if (input.vesselTonnage !== null && input.vesselTonnage >= 400) {
chinaScore += 15;
reasons.push(`${input.vesselTonnage}톤 → 宁波海裕 초대형 모선급(宁渔22·23, 490~541톤) 의심`);
warnings.push('어획 할당량 0톤 등록 선박 — 해상 냉동·집하 기지 역할 확인 필요');
}
gbCode = gbCode || 'WDD/WDW';
koreaName = koreaName || '선망어업';
gearSubType = gearSubType || '선망(Purse Seine)';
if (input.hasDrawstring) {
reasons.push('죔줄(Purseline) 구조 확인 → 선망 어구 확정');
}
}
// ■ 정치망 판별
if (gearType === 'setNet' || input.hasFrame) {
gearType = 'setNet';
chinaScore += 30; // 한중 EEZ 내 정치망은 중국 미허가
gbCode = 'ZD/ZS (張網)';
koreaName = '연안정치망/대형정치망';
gearSubType = '정치망(張網) — 단묘/복묘장망';
alertLevel = 'CRITICAL';
reasons.push('정치망(張網) 구조 확인 → 한중어업협정 특정어업수역 내 중국 미허가 어구');
warnings.push('정치망은 EEZ 내 중국어선 사용 절대 금지 — 발견 즉시 수거 및 CRITICAL 처리');
if (!input.hasVesselNearby) {
reasons.push('선박 없이 어구만 발견 → 중국어선 투기 정치망 전형적 패턴(이어도·서남해 다수)');
}
}
// ■ 통발 판별
if (gearType === 'trap') {
gbCode = 'L (笼壶)';
koreaName = '근해통발/연안통발';
gearSubType = '통발(笼壶)';
reasons.push('통발 형태 어구 — 한중어업협정 비포함 어구, 적발 사례 드묾');
}
// ── 4단계: 위치/시기 기반 보정 ──
if (input.seaArea) {
const area = input.seaArea.toUpperCase();
// 수역 IV에 PT/OT 선박이면 수역 이탈
if ((area.includes('IV') || area.includes('4') || area.includes('서해')) &&
(permitPrefix.startsWith('C21') || permitPrefix.startsWith('C22'))) {
warnings.push('수역Ⅳ(서해)에서 저인망(PT/OT) 탐지 → 허가 수역 이탈 위반');
alertLevel = 'CRITICAL';
}
// 수역 I에 GN 선박이면 수역 이탈
if ((area.includes('I') || area.includes('1') || area.includes('동해')) && area.indexOf('V') === -1 &&
permitPrefix.startsWith('C25')) {
warnings.push('수역Ⅰ(동해)에서 유망(GN) 탐지 → 허가 수역 이탈 위반');
alertLevel = 'CRITICAL';
}
}
// 날짜 기반 휴어기 판별
if (input.discoveryDate) {
const date = new Date(input.discoveryDate);
const month = date.getMonth() + 1;
const day = date.getDate();
// PT/OT 휴어기: 4/16 ~ 10/15
if (permitPrefix.startsWith('C21') || permitPrefix.startsWith('C22')) {
if ((month === 4 && day >= 16) || (month > 4 && month < 10) || (month === 10 && day <= 15)) {
warnings.push(`${month}${day}일 — 저인망(PT/OT) 휴어기(4/16~10/15) 위반`);
alertLevel = 'CRITICAL';
}
}
// GN 휴어기: 6/2 ~ 8/31
if (permitPrefix.startsWith('C25')) {
if ((month === 6 && day >= 2) || (month === 7) || (month === 8)) {
warnings.push(`${month}${day}일 — 유망(GN) 휴어기(6/2~8/31) 위반`);
alertLevel = 'CRITICAL';
}
}
// 7~8월: PS 16척 외 모든 중국어선 비허가
if (month === 7 || month === 8) {
if (!permitPrefix.startsWith('C23') && !permitPrefix.startsWith('C40') && chinaScore > 20) {
warnings.push('7~8월 최대 감시 기간 — 위망(PS) 16척 외 전 중국어선 비허가');
}
}
}
// ── 5단계: 최종 판정 ──
let origin: Origin;
let confidence: Confidence;
if (chinaScore >= 50) {
origin = 'china';
confidence = chinaScore >= 80 ? 'high' : chinaScore >= 60 ? 'medium' : 'low';
} else if (koreaScore >= 30 && chinaScore < 20) {
origin = 'korea';
confidence = koreaScore >= 50 ? 'high' : 'medium';
} else if (chinaScore > koreaScore) {
origin = 'china';
confidence = 'low';
} else if (koreaScore > chinaScore) {
origin = 'korea';
confidence = 'low';
} else {
origin = 'uncertain';
confidence = 'low';
reasons.push('판별 근거 부족 — 추가 현장 확인 필요 (SAR/RF 교차 검증 권고)');
}
// 알람 등급 보정
if (origin === 'china' && alertLevel === 'LOW') {
alertLevel = gearType === 'setNet' ? 'CRITICAL' : 'MEDIUM';
}
const actionMap: Record<string, string> = {
'china-CRITICAL': '즉시 나포 대상 — 정선 명령 후 승선 검사 실시',
'china-HIGH': '우선 추적 — SAR/RF 교차 검증 후 임검 준비',
'china-MEDIUM': '지속 감시 — 허가증 확인 대상 선정',
'china-LOW': '일반 감시 — 추가 정보 수집 후 재판별',
'korea-CRITICAL': '한국 어선이나 금지 어구 사용 — 관할 해경서 통보',
'korea-HIGH': '한국 어선 확인 — 허가 조건 이행 점검',
'korea-MEDIUM': '한국 어선 추정 — 일반 관리',
'korea-LOW': '추가 확인 필요',
'uncertain-LOW': '판별 불가 — 현장 승선 검사 또는 SAR·RF 교차 확인 필수',
};
return {
origin,
confidence,
gearType: gearType || 'unknown',
gearSubType: gearSubType || '미분류',
gbCode: gbCode || '미확인',
koreaName: koreaName || '미확인',
reasons,
warnings,
actionRequired: actionMap[`${origin}-${alertLevel}`] || actionMap[`${origin}-LOW`] || '추가 확인 필요',
alertLevel,
};
}
// ─── 서브 컴포넌트 ────────────────────
function SectionHeader({ icon: Icon, title, color }: { icon: React.ElementType; title: string; color: string }) {
return (
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4" style={{ color }} />
<h3 className="text-sm font-bold text-foreground">{title}</h3>
</div>
);
}
function FormField({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
return (
<div className="space-y-1">
<label className="text-[11px] text-muted-foreground font-medium">{label}</label>
{children}
{hint && <p className="text-[9px] text-hint">{hint}</p>}
</div>
);
}
function InputField({ value, onChange, placeholder, type = 'text', className = '', label }: {
value: string | number | null;
onChange: (v: string) => void;
placeholder: string;
type?: string;
className?: string;
label?: string;
}) {
return (
<input
aria-label={label ?? placeholder}
type={type}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading placeholder:text-hint focus:border-blue-500/50 focus:outline-none ${className}`}
/>
);
}
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<label className="flex items-center gap-2 cursor-pointer group">
<div
className={`w-8 h-4 rounded-full transition-colors relative ${checked ? 'bg-blue-600' : 'bg-switch-background'}`}
onClick={() => onChange(!checked)}
>
<div className={`w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${checked ? 'translate-x-4.5 left-0.5' : 'left-0.5'}`}
style={{ transform: checked ? 'translateX(16px)' : 'translateX(0)' }}
/>
</div>
<span className="text-[11px] text-muted-foreground group-hover:text-label">{label}</span>
</label>
);
}
function SelectField({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading focus:border-blue-500/50 focus:outline-none"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
function ResultBadge({ origin, confidence }: { origin: Origin; confidence: Confidence }) {
const intents: Record<Origin, import('@lib/theme/variants').BadgeIntent> = {
china: 'critical',
korea: 'info',
uncertain: 'warning',
};
const labels: Record<Origin, string> = { china: '중국어선 어구', korea: '한국어선 어구', uncertain: '판별 불가' };
const confLabels: Record<Confidence, string> = { high: '높음', medium: '보통', low: '낮음' };
return (
<div className="flex items-center gap-2">
<Badge intent={intents[origin]} size="md">{labels[origin]}</Badge>
<span className="text-[10px] text-hint">: {confLabels[confidence]}</span>
</div>
);
}
function AlertBadge({ level }: { level: string }) {
const intents: Record<string, import('@lib/theme/variants').BadgeIntent> = {
CRITICAL: 'critical',
HIGH: 'high',
MEDIUM: 'warning',
LOW: 'info',
};
return <Badge intent={intents[level] ?? 'muted'} size="xs">{level}</Badge>;
}
// ─── 어구 비교 레퍼런스 테이블 ──────────
function GearComparisonTable() {
const data = [
{
type: '트롤 (저인망)',
gbCode: 'T__ (TDD/TDS)',
chinaFeatures: ['2척 편대 필수(PT)', '68~297톤', '망목 GB/T 기준', '수역 II·III 한정'],
koreaFeatures: ['단독/2척 자율', '규모 다양', '망목 54mm+', '국내 허가수역'],
aiPattern: 'Lawn-mowing (지그재그 반복)',
speed: '2~5 kt',
confidence: '89~92%',
risk: 'HIGH',
},
{
type: '자망 (유자망)',
gbCode: 'C__ (CLD/CLS)',
chinaFeatures: ['AIS OFF 빈번', '광폭·장형 대형망', '은밀 설치', '수역 II·III·IV'],
koreaFeatures: ['V-PASS+AIS 의무', '50~100mm 망목', '기계화 투·양망', '국내 수역'],
aiPattern: '정지·재방문 (투→대기→양)',
speed: '0~2 kt',
confidence: '74~80%',
risk: 'HIGH',
},
{
type: '선망 (위망)',
gbCode: 'W__ (WDD/WDW)',
chinaFeatures: ['3척+ 선단 체계', '야간 집어등 상시', '113~541톤', '전 수역 연중'],
koreaFeatures: ['소형~중형', '집어등 일부 허가', '소수 Fleet', '국내 수역'],
aiPattern: '원형 포획 (Circle Pattern)',
speed: '8~10→3kt 급변',
confidence: '94~97%',
risk: 'CRITICAL',
},
{
type: '정치망',
gbCode: 'Z__ (ZD/ZS)',
chinaFeatures: ['EEZ 내 미허가', '선박 없이 어구만 투기', '이어도·서남해 발견'],
koreaFeatures: ['연안정치망 합법', '위치 등록 의무', '어업권 면허'],
aiPattern: '고정 설치 (이동 없음)',
speed: '0 kt',
confidence: 'SAR/드론 필수',
risk: 'CRITICAL',
},
];
return (
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 text-blue-400" />
· (GB/T 5147-2003 )
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-3">
<div className="space-y-3">
{data.map((row) => (
<div key={row.type} className="bg-surface-overlay rounded-lg p-3 border border-slate-700/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-heading">{row.type}</span>
<span className="text-[9px] text-hint bg-switch-background/50 px-1.5 py-0.5 rounded">{row.gbCode}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground">AI : {row.confidence}</span>
<AlertBadge level={row.risk} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-[9px] text-red-400 font-medium mb-1"> </div>
{row.chinaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
<div>
<div className="text-[9px] text-blue-400 font-medium mb-1"> </div>
{row.koreaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
</div>
<div className="mt-2 flex items-center gap-4 text-[9px] text-hint">
<span>: {row.aiPattern}</span>
<span>: {row.speed}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// ─── 메인 페이지 ──────────────────────
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
C40: 'unknown', FC: 'unknown',
};
const ZONE_CODE_SEA_AREA: Record<string, string> = {
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
};
export function GearIdentification() {
const { t } = useTranslation('detection');
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
const [result, setResult] = useState<IdentificationResult | null>(null);
const [showReference, setShowReference] = useState(false);
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
setInput((prev) => ({ ...prev, [key]: value }));
};
const runIdentification = () => {
setResult(identifyGear(input));
};
const resetForm = () => {
setInput(DEFAULT_INPUT);
setResult(null);
setAutoSelected(null);
};
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
const applyAutoDetection = (v: GearDetection) => {
const code = (v.gearCode || '').toUpperCase();
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
setInput({
...DEFAULT_INPUT,
gearCategory: category,
permitCode: code,
mmsiPrefix: v.mmsi.slice(0, 3),
seaArea,
discoveryDate: v.analyzedAt.slice(0, 10),
});
setAutoSelected(v);
// 자동탐지 근거를 결과 패널에 프리셋
const reasons: string[] = [];
const warnings: string[] = [];
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
if (v.permitStatus) {
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
}
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
gearType: category,
gearSubType: code,
gbCode: '',
koreaName: '',
reasons,
warnings,
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
: '추가 정보 입력 후 판별 실행',
alertLevel,
});
// 입력 폼 영역으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Search className="w-5 h-5 text-cyan-500" />
{t('gearId.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('gearId.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setShowReference(!showReference)}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
>
<Info className="w-3 h-3" />
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
</button>
</div>
</div>
{/* 레퍼런스 테이블 (토글) */}
{showReference && <GearComparisonTable />}
{/* 자동탐지 선택 힌트 */}
{autoSelected && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
<div className="flex items-center gap-2 flex-wrap">
<Badge intent="info" size="sm"> </Badge>
<span className="text-hint">MMSI</span>
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
<span className="text-hint">·</span>
<span className="text-hint"></span>
<span className="font-mono text-label">{autoSelected.gearCode}</span>
<span className="text-hint">·</span>
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<button type="button"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="text-[10px] text-hint hover:text-heading shrink-0"
>
</button>
</div>
)}
<div className="grid grid-cols-12 gap-4">
{/* ── 좌측: 입력 폼 ── */}
<div className="col-span-5 space-y-3">
{/* 허가코드 / 선박 정보 */}
<Card>
<CardContent className="p-4 space-y-3">
<SectionHeader icon={Ship} title="선박/허가 정보" color="#06b6d4" />
<div className="grid grid-cols-2 gap-3">
<FormField label="허가코드" hint="C21·C22·C23·C25·C40">
<InputField value={input.permitCode} onChange={(v) => update('permitCode', v)} placeholder="예: C21-13558" />
</FormField>
<FormField label="MMSI 앞자리">
<InputField value={input.mmsiPrefix} onChange={(v) => update('mmsiPrefix', v)} placeholder="예: 412" />
</FormField>
<FormField label="선박 톤수 (톤)">
<InputField value={input.vesselTonnage} onChange={(v) => update('vesselTonnage', v ? Number(v) : null)} placeholder="예: 127" type="number" />
</FormField>
<FormField label="선박 수 (척)">
<InputField value={input.vesselCount} onChange={(v) => update('vesselCount', Number(v) || 1)} placeholder="1" type="number" />
</FormField>
</div>
<Toggle checked={input.hasCompanionVessel} onChange={(v) => update('hasCompanionVessel', v)} label="부속선 동반 (PT 쌍선)" />
</CardContent>
</Card>
{/* 어구 물리적 특성 */}
<Card>
<CardContent className="p-4 space-y-3">
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
<FormField label="어구 유형 (추정)">
<SelectField label="어구 유형 (추정)" value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
{ value: 'unknown', label: '미확인 / 모름' },
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
{ value: 'purseSeine', label: '선망 (위망) — 둘러싸는 어망' },
{ value: 'setNet', label: '정치망 — 고정 설치 어망' },
{ value: 'trap', label: '통발 — 함정형 어구' },
]} />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="망목 (mm)">
<InputField value={input.meshSize} onChange={(v) => update('meshSize', v ? Number(v) : null)} placeholder="mm" type="number" />
</FormField>
<FormField label="그물 길이 (m)">
<InputField value={input.netLength} onChange={(v) => update('netLength', v ? Number(v) : null)} placeholder="m" type="number" />
</FormField>
<FormField label="그물 폭 (m)">
<InputField value={input.netWidth} onChange={(v) => update('netWidth', v ? Number(v) : null)} placeholder="m" type="number" />
</FormField>
</div>
<div className="space-y-2">
<Toggle checked={input.hasChineseMarkings} onChange={(v) => update('hasChineseMarkings', v)} label="중국어 표기 확인 (부표/어구)" />
<Toggle checked={input.hasBuoys} onChange={(v) => update('hasBuoys', v)} label="부표(浮標) 발견" />
{input.hasBuoys && (
<FormField label="부표 수량">
<InputField value={input.buoyCount} onChange={(v) => update('buoyCount', v ? Number(v) : null)} placeholder="개수" type="number" />
</FormField>
)}
<Toggle checked={input.hasFrame} onChange={(v) => update('hasFrame', v)} label="프레임/고정 구조 (정치망)" />
<Toggle checked={input.hasDrawstring} onChange={(v) => update('hasDrawstring', v)} label="죔줄(Purseline) 구조 (선망)" />
<Toggle checked={input.hasVesselNearby} onChange={(v) => update('hasVesselNearby', v)} label="인근 선박 존재" />
</div>
</CardContent>
</Card>
{/* AIS / 행동 패턴 */}
<Card>
<CardContent className="p-4 space-y-3">
<SectionHeader icon={Radar} title="AIS / 행동 패턴" color="#f97316" />
<Toggle checked={input.aisActive} onChange={(v) => update('aisActive', v)} label="AIS 송출 중" />
{!input.aisActive && (
<FormField label="AIS 소실 시간 (시간)">
<InputField value={input.aisGapHours} onChange={(v) => update('aisGapHours', v ? Number(v) : null)} placeholder="시간" type="number" />
</FormField>
)}
<div className="grid grid-cols-2 gap-3">
<FormField label="속도 (knots)">
<InputField value={input.speedKnots} onChange={(v) => update('speedKnots', v ? Number(v) : null)} placeholder="kt" type="number" />
</FormField>
<FormField label="조업 지속시간 (시간)">
<InputField value={input.operatingHours} onChange={(v) => update('operatingHours', v ? Number(v) : null)} placeholder="h" type="number" />
</FormField>
</div>
<FormField label="궤적 패턴">
<SelectField label="궤적 패턴" value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
{ value: 'unknown', label: '미확인' },
{ value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' },
{ value: 'stationary', label: '정지/극저속 (Stationary) → 자망' },
{ value: 'circular', label: '원형/타원형 (Circle) → 선망' },
{ value: 'drifting', label: '표류 (Drifting) → 유자망' },
{ value: 'linear', label: '직선 이동 (Linear) → 이동/통과' },
]} />
</FormField>
<div className="space-y-2">
<Toggle checked={input.isNightOperation} onChange={(v) => update('isNightOperation', v)} label="야간 조업" />
<Toggle checked={input.hasFishLight} onChange={(v) => update('hasFishLight', v)} label="집어등(灯光) 사용" />
</div>
</CardContent>
</Card>
{/* 위치/시기 */}
<Card>
<CardContent className="p-4 space-y-3">
<SectionHeader icon={Target} title="발견 위치 / 시기" color="#10b981" />
<div className="grid grid-cols-2 gap-3">
<FormField label="발견 수역" hint="수역 I~IV 또는 해역명">
<InputField value={input.seaArea} onChange={(v) => update('seaArea', v)} placeholder="예: 수역II, 서해 NLL" />
</FormField>
<FormField label="발견 일자">
<InputField value={input.discoveryDate} onChange={(v) => update('discoveryDate', v)} placeholder="YYYY-MM-DD" type="date" />
</FormField>
</div>
</CardContent>
</Card>
{/* 판별 버튼 */}
<div className="flex gap-2">
<button type="button"
onClick={runIdentification}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
</button>
<button type="button"
onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
>
</button>
</div>
</div>
{/* ── 우측: 결과 패널 ── */}
<div className="col-span-7 space-y-3">
{!result ? (
<Card className="bg-surface-raised border-border h-full flex items-center justify-center">
<CardContent className="text-center py-20">
<HelpCircle className="w-12 h-12 text-hint mx-auto mb-3" />
<p className="text-sm text-hint mb-1"> </p>
<p className="text-sm text-hint">"어구 국적 판별 실행" </p>
<p className="text-[10px] text-hint mt-3">
(C21~C40)
</p>
</CardContent>
</Card>
) : (
<>
{/* 판별 결과 헤더 */}
<Card className={`border ${
result.origin === 'china' ? 'bg-red-950/20 border-red-500/30'
: result.origin === 'korea' ? 'bg-blue-950/20 border-blue-500/30'
: 'bg-yellow-950/20 border-yellow-500/30'
}`}>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div>
<div className="text-[10px] text-hint mb-1"> </div>
<ResultBadge origin={result.origin} confidence={result.confidence} />
</div>
<AlertBadge level={result.alertLevel} />
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<div className="text-[9px] text-hint"> </div>
<div className="text-xs text-heading font-bold">{result.gearSubType}</div>
</div>
<div>
<div className="text-[9px] text-hint">GB/T </div>
<div className="text-xs text-heading font-bold">{result.gbCode}</div>
</div>
<div>
<div className="text-[9px] text-hint"> </div>
<div className="text-xs text-heading font-bold">{result.koreaName}</div>
</div>
</div>
<div className="bg-surface-raised rounded-lg p-3 border border-slate-700/30">
<div className="text-[9px] text-hint mb-1"> </div>
<div className="text-xs text-heading font-medium">{result.actionRequired}</div>
</div>
</CardContent>
</Card>
{/* 판별 근거 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
({result.reasons.length})
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
<div className="space-y-1.5">
{result.reasons.map((reason, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-label">{reason}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 경고/위반 사항 */}
{result.warnings.length > 0 && (
<Card className="bg-surface-raised border-orange-500/20">
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
/ ({result.warnings.length})
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
<div className="space-y-1.5">
{result.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-300">{warning}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* AI 탐지 Rule 참조 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-purple-500" />
AI Rule ( )
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{result.gearType === 'trawl' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 트롤 탐지 조건 (Trawl Detection Rule)
if speed in range(2.0, 5.0) # knots
and trajectory == 'parallel_sweep' # 반복 평행선
and turn_angle < 30 # 최대 회전각 (°)
and duration > 120 # 최소 지속 시간 (min)
and speed_std < 0.8 # 속도 분산 낮음
→ gear = 'TRAWL' confidence >= 89%
# 쌍선 트롤 추가 조건 (Pair Trawl — TDS)
and vessel_pair_distance in range(300, 800) # m
and speed_sync > 0.92 # 2선 속도 동기화`}
</pre>
)}
{result.gearType === 'gillnet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 자망 탐지 조건 (Gillnet Detection Rule)
if speed < 2.0 # knots
and stop_duration > 30 # min
and repeated_loc == True # 동일 위치 재방문
→ gear = 'GILLNET' confidence >= 74%
# AIS 소실 연계 다크베셀 탐지
if ais_gap_duration > 20 # min (AIS 공백)
and sar_vessel_detect == True # SAR 위치 확인
→ dark_vessel_flag = True
gear_estimate = 'GILLNET (추정)'`}
</pre>
)}
{result.gearType === 'purseSeine' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
if trajectory == 'circular' # 원형 궤적
and speed_change > 5.0 # kt (고→저 급변)
and speed_high_phase in range(8, 12) # 초기 고속
and speed_low_phase < 3.0 # 이후 저속
→ gear = 'PURSE_SEINE' confidence >= 94%
# Fleet 추가 확인
and multi_vessel_cluster == True # 3척 이상
and vessel_spacing < 1000 # m
→ fleet_type = 'PURSE_SEINE_FLEET'
confidence += 0.03 # 97%+`}
</pre>
)}
{result.gearType === 'setNet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 정치망 — EEZ 내 중국어선 미허가 어구
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
#
# 판별 기준:
# - 사각 프레임 또는 자루형 구조
# - 해저 고정 (묘박)
# - 선박 없이 어구만 발견 시 중국 투기 어구 추정
#
# → 발견 즉시 CRITICAL 처리
# → 즉시 수거 및 GPS 위치 기록
# → 반복 발견 위치는 집중 감시 구역 지정`}
</pre>
)}
{(result.gearType === 'trap' || result.gearType === 'unknown') && (
<div className="bg-surface-overlay rounded-lg p-3 text-[11px] text-muted-foreground">
AI Rule이 . .
</div>
)}
</CardContent>
</Card>
{/* 다중 센서 교차 검증 흐름 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-cyan-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
<div className="flex items-center gap-1 flex-wrap">
{[
{ step: 'RF 탐지', desc: '다크 신호 탐지 (HawkEye360)', color: '#f97316' },
{ step: 'SAR 확인', desc: '위성 SAR 위치 확인', color: '#a855f7' },
{ step: 'AIS 분석', desc: '속도·궤적·패턴 분류', color: '#3b82f6' },
{ step: 'EO 검증', desc: '광학 위성 어구 확인', color: '#10b981' },
].map((s, i) => (
<div key={i} className="flex items-center gap-1">
<div className="bg-surface-overlay border border-slate-700/40 rounded-md px-2.5 py-1.5 text-center">
<div className="text-[10px] font-bold" style={{ color: s.color }}>{s.step}</div>
<div className="text-[8px] text-hint">{s.desc}</div>
</div>
{i < 3 && <ArrowRight className="w-3 h-3 text-hint shrink-0" />}
</div>
))}
</div>
</CardContent>
</Card>
</>
)}
</div>
</div>
{/* 최근 자동탐지 결과 (prediction 기반) */}
<AutoGearDetectionSection
onSelect={applyAutoDetection}
selectedId={autoSelected?.id ?? null}
/>
</div>
);
}
// ─── 자동탐지 결과 섹션 ─────────────────
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
CLOSED_SEASON_FISHING: '금어기 조업',
UNREGISTERED_GEAR: '미등록 어구',
GEAR_MISMATCH: '어구 불일치',
MULTIPLE_VIOLATION: '복합 위반',
NORMAL: '정상',
};
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
CLOSED_SEASON_FISHING: 'critical',
UNREGISTERED_GEAR: 'warning',
GEAR_MISMATCH: 'warning',
MULTIPLE_VIOLATION: 'critical',
NORMAL: 'success',
};
const PERMIT_STATUS_LABEL: Record<string, string> = {
PERMITTED: '허가',
UNPERMITTED: '미허가',
UNKNOWN: '확인불가',
};
function AutoGearDetectionSection({
onSelect,
selectedId,
}: {
onSelect: (v: GearDetection) => void;
selectedId: number | null;
}) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
const [items, setItems] = useState<GearDetection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
setItems(page.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '조회 실패');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Radar className="w-4 h-4 text-cyan-500" />
(prediction, 1 )
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL ·
</div>
</div>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-2 py-1.5 text-left">MMSI</th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-right"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{items.map((v) => {
const selected = v.id === selectedId;
return (
<tr
key={v.id}
onClick={() => onSelect(v)}
className={`border-t border-border cursor-pointer transition-colors ${
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
</Badge>
</td>
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
</td>
<td className="px-2 py-1.5 text-center">
{v.riskLevel ? (
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
{(v.violationCategories ?? []).join(', ') || '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}