kcg-ai-monitoring/frontend/src/features/detection/components/GearDetailPanel.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00

643 lines
31 KiB
TypeScript

/**
* GearDetailPanel — 어구 그룹 판정 상세 사이드 패널
*
* 테이블 행 클릭 시 우측에 슬라이드 표시.
* G코드 위반 내역, 어구 그룹 정보, 모선 추론 결과 + correlation 상세,
* 궤적 리플레이 시작 버튼을 종합 표시.
*/
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import {
getGearViolationIntent,
getGearViolationLabel,
getGearViolationDesc,
GEAR_VIOLATION_CODES,
} from '@shared/constants/gearViolationCodes';
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
import { getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { X, Anchor, MapPin, ShieldAlert, Users, Ship, Play, TrendingUp, Loader2, CheckCircle, XCircle, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '@stores/settingsStore';
import { fetchGroupCorrelations, fetchVesselTracks, fetchCandidateMetrics, resolveParent, type CandidateMetricItem } from '@/services/vesselAnalysisApi';
import { useGearReplayStore } from '@stores/gearReplayStore';
interface CorrelationItem {
targetMmsi: string;
targetName: string;
targetType: string;
score: number;
streak: number;
freezeState: string;
proximityRatio: number;
visitScore: number;
headingCoherence: number;
modelName: string;
}
interface GearData {
id: string;
groupKey: string;
type: string;
owner: string;
zone: string;
status: string;
permit: string;
risk: string;
lat: number;
lng: number;
parentStatus: string;
parentMmsi: string;
confidence: string;
gCodes: string[];
gearViolationScore: number;
gearViolationEvidence: Record<string, Record<string, unknown>>;
pairTrawlDetected: boolean;
pairTrawlPairMmsi: string;
memberCount: number;
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
allowedGears: string[];
}
interface GearDetailPanelProps {
gear: GearData | null;
onClose: () => void;
}
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate();
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set());
const [selectedDetail, setSelectedDetail] = useState<string | null>(null); // 상세 보기 중인 후보 MMSI
const [detailMetrics, setDetailMetrics] = useState<CandidateMetricItem[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const [resolveLoading, setResolveLoading] = useState(false);
const [resolveMsg, setResolveMsg] = useState('');
const replayGroupKey = useGearReplayStore(s => s.groupKey);
const toggleCandidate = useCallback((mmsi: string) => {
setSelectedCandidates(prev => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
return next;
});
}, []);
// 후보 상세 메트릭 로드
const loadCandidateDetail = useCallback(async (mmsi: string) => {
if (!gear) return;
setSelectedDetail(mmsi);
setDetailLoading(true);
setResolveMsg('');
try {
const data = await fetchCandidateMetrics(gear.groupKey, mmsi);
setDetailMetrics(data.items ?? []);
} catch {
setDetailMetrics([]);
} finally {
setDetailLoading(false);
}
}, [gear]);
// 모선 확정/제외 처리
const handleResolve = useCallback(async (action: 'confirm' | 'reject') => {
if (!gear || !selectedDetail) return;
setResolveLoading(true);
setResolveMsg('');
try {
const result = await resolveParent(gear.groupKey, action, selectedDetail);
if (result.ok) {
setResolveMsg(action === 'confirm' ? '모선 확정 완료' : '후보 제외 완료');
} else {
setResolveMsg(result.message ?? '처리 실패');
}
} catch {
setResolveMsg('요청 실패');
} finally {
setResolveLoading(false);
}
}, [gear, selectedDetail]);
const loadCorrelations = useCallback(async (groupKey: string) => {
setCorrLoading(true);
try {
const data = await fetchGroupCorrelations(groupKey, 0.3);
// API 응답을 CorrelationItem 형태로 매핑
const items = Array.isArray(data) ? data : ((data as Record<string, unknown>)?.items as unknown[]) ?? [];
// 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째
const byMmsi = new Map<string, CorrelationItem>();
for (const item of items) {
const d = item as Record<string, unknown>;
const mmsi = String(d.targetMmsi ?? '');
const mapped: CorrelationItem = {
targetMmsi: mmsi,
targetName: String(d.target_name ?? d.targetName ?? ''),
targetType: String(d.target_type ?? d.targetType ?? ''),
score: Number(d.score ?? d.current_score ?? d.currentScore ?? 0),
streak: Number(d.streak ?? d.streak_count ?? d.streakCount ?? 0),
freezeState: String(d.freeze_state ?? d.freezeState ?? 'ACTIVE'),
proximityRatio: Number(d.proximity_ratio ?? d.proximityRatio ?? 0),
visitScore: Number(d.visit_score ?? d.visitScore ?? 0),
headingCoherence: Number(d.heading_coherence ?? d.headingCoherence ?? 0),
modelName: String(d.model_name ?? d.modelName ?? 'default'),
};
const existing = byMmsi.get(mmsi);
if (!existing || mapped.modelName === 'default' || mapped.score > existing.score) {
byMmsi.set(mmsi, mapped);
}
}
const sorted = Array.from(byMmsi.values()).sort((a, b) => b.score - a.score);
setCorrelations(sorted);
// 최고 일치율 후보 자동 선택
if (sorted.length > 0) {
loadCandidateDetail(sorted[0].targetMmsi);
}
} catch {
setCorrelations([]);
} finally {
setCorrLoading(false);
}
}, [loadCandidateDetail]);
useEffect(() => {
if (gear?.groupKey) {
loadCorrelations(gear.groupKey);
} else {
setCorrelations([]);
}
}, [gear?.groupKey, loadCorrelations]);
const [replayLoading, setReplayLoading] = useState(false);
const handleStartReplay = useCallback(async () => {
if (!gear) return;
setReplayLoading(true);
try {
// 자체 백엔드 API로 group history + correlation 조회
const groupKeyEnc = encodeURIComponent(gear.groupKey);
const [detailRes, tracksRes] = await Promise.all([
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/detail`)
.then(r => r.json()).catch(() => ({ history: [] })),
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/correlations?minScore=0.1`)
.then(r => r.json()).catch(() => ({ items: [] })),
]);
// detailRes.history → frames 변환
const historyRes = { frames: (detailRes.history ?? []) };
const frames = (historyRes.frames ?? []).map((f: Record<string, unknown>) => ({
snapshotTime: String(f.snapshotTime ?? ''),
centerLat: Number(f.centerLat ?? 0),
centerLon: Number(f.centerLon ?? 0),
memberCount: Number(f.memberCount ?? 0),
polygon: f.polygon ?? null,
members: Array.isArray(f.members) ? f.members : [],
}));
// correlation 응답 → loadHistory용 CorrelationItem 형태로 매핑
const corrItems = tracksRes.items ?? [];
const replayCorrelations = Array.isArray(corrItems) ? corrItems.map((v: Record<string, unknown>) => ({
targetMmsi: String(v.targetMmsi ?? ''),
targetName: String(v.targetName ?? ''),
score: Number(v.score ?? v.currentScore ?? 0),
freezeState: String(v.freezeState ?? 'ACTIVE'),
})) : [];
if (frames.length === 0) {
frames.push({
snapshotTime: new Date().toISOString(),
centerLat: gear.lat,
centerLon: gear.lng,
memberCount: gear.memberCount,
polygon: null,
members: gear.members ?? [],
});
}
// 선택된 후보 선박 항적 조회 (24시간)
let candidateTracks: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[] = [];
if (selectedCandidates.size > 0) {
const now = new Date();
const h24ago = new Date(now.getTime() - 24 * 3600_000);
try {
candidateTracks = await fetchVesselTracks(
[...selectedCandidates],
h24ago.toISOString(),
now.toISOString(),
);
} catch {
// 항적 조회 실패해도 리플레이는 진행
}
}
useGearReplayStore.getState().loadHistory(gear.groupKey, frames, replayCorrelations, candidateTracks);
useGearReplayStore.getState().play();
} catch {
// silent fallback
} finally {
setReplayLoading(false);
}
}, [gear, selectedCandidates]);
if (!gear) return null;
const allowedGears = gear.allowedGears.length > 0
? gear.allowedGears
: getZoneAllowedGears(gear.zone);
const parentStatusLabel =
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? '직접매칭' :
gear.parentStatus === 'AUTO_PROMOTED' ? '자동승격' :
gear.parentStatus === 'REVIEW_REQUIRED' ? '심사필요' :
gear.parentStatus === 'UNRESOLVED' ? '미결정' :
gear.parentStatus;
const parentStatusIntent: 'success' | 'info' | 'warning' | 'muted' =
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? 'success' :
gear.parentStatus === 'AUTO_PROMOTED' ? 'info' :
gear.parentStatus === 'REVIEW_REQUIRED' ? 'warning' :
'muted';
const hasPairTrawl = gear.pairTrawlDetected || gear.gCodes.includes('G-06');
const isReplayActive = replayGroupKey === gear.groupKey;
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<span className="font-bold text-heading text-sm"> </span>
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* G코드 위반 내역 */}
{gear.gCodes.length > 0 && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium">G코드 </span>
<span className="text-hint text-[10px]"> {gear.gearViolationScore}</span>
</div>
<div className="space-y-1.5">
{gear.gCodes.map((code) => {
const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES];
return (
<div key={code} className="flex items-start gap-2 py-1.5 border-b border-border last:border-0">
<Badge intent={getGearViolationIntent(code)} size="sm" className="shrink-0 mt-0.5">{code}</Badge>
<div className="flex-1 min-w-0">
<div className="text-xs text-label font-medium">{getGearViolationLabel(code, t, lang)}</div>
<div className="text-[10px] text-hint mt-0.5">{getGearViolationDesc(code, lang)}</div>
</div>
{meta && <span className="text-[10px] font-mono text-label shrink-0">+{meta.score}pt</span>}
</div>
);
})}
</div>
</div>
)}
{/* 어구 그룹 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">{gear.groupKey}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.type}</span>
<span className="text-hint">/</span>
<span className="text-label text-right font-mono">{gear.owner}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.memberCount > 0 ? `${gear.memberCount}` : '-'}</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">{getZoneCodeLabel(gear.zone, t, lang)}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getGearJudgmentIntent(gear.status)} size="sm">{gear.status}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-label text-right text-[10px]">{allowedGears.length > 0 ? allowedGears.join(', ') : '없음'}</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono text-[10px]">
{gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E
</span>
</div>
</div>
{/* 모선 추론 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
{gear.parentMmsi}
</button>
) : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.confidence}</span>
</div>
</div>
{/* 모선 추론 후보 상세 (Correlation) */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
<span className="text-hint text-[10px]">{correlations.length}</span>
</div>
{correlations.length > 0 ? (
<div className="space-y-1.5 max-h-[240px] overflow-y-auto">
{correlations.sort((a, b) => b.score - a.score).map((c, i) => (
<div key={`${i}-${c.targetMmsi}`}
onClick={(e) => {
// 체크박스, MMSI 링크 클릭은 전파 방지됨 → 나머지 영역 클릭 시 상세 로드
if ((e.target as HTMLElement).closest('input, a, button')) return;
loadCandidateDetail(c.targetMmsi);
}}
className={`py-1.5 px-2 rounded text-xs cursor-pointer transition-colors ${
selectedDetail === c.targetMmsi
? 'bg-purple-500/10 border border-purple-500/30'
: c.targetMmsi === gear.parentMmsi
? 'bg-cyan-500/10 border border-cyan-500/30'
: 'hover:bg-surface-overlay'
}`}>
<div className="flex items-center gap-2">
<input type="checkbox" checked={selectedCandidates.has(c.targetMmsi)}
onChange={() => toggleCandidate(c.targetMmsi)}
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
aria-label={`${c.targetMmsi} 리플레이 선택`} />
<button type="button"
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
{c.targetMmsi}
</button>
<span className="text-hint text-[9px] truncate max-w-[80px]">{c.targetName}</span>
<div className="flex-1" />
<div className="w-14 h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, c.score * 100)}%`,
backgroundColor: c.score >= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b',
}} />
</div>
<span className="font-mono text-[10px] text-label w-10 text-right">
{(c.score * 100).toFixed(0)}%
</span>
<Badge intent={c.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">
{c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 ml-6 text-[9px] text-hint">
<span>: {(c.proximityRatio * 100).toFixed(0)}%</span>
{c.visitScore > 0 && <span>: {(c.visitScore * 100).toFixed(0)}%</span>}
{c.headingCoherence > 0 && <span>: {(c.headingCoherence * 100).toFixed(0)}%</span>}
<span>: {c.streak}</span>
<Badge intent={c.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">
{c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType}
</Badge>
</div>
</div>
))}
</div>
) : !corrLoading ? (
<div className="text-hint text-[10px] text-center py-2"> </div>
) : null}
{correlations.length > 0 && (
<div className="flex items-center gap-3 pt-1.5 border-t border-border text-[9px] text-hint">
<span>근접: 어구- </span>
<span>방문: 어구 </span>
<span>방향: 침로 </span>
</div>
)}
</div>
{/* ── 후보 상세 검토 패널 ── */}
{selectedDetail && (() => {
const cand = correlations.find(c => c.targetMmsi === selectedDetail);
if (!cand) return null;
// 최근 메트릭 평균 계산
const avg = (arr: (number | null)[]): number | null => {
const valid = arr.filter((v): v is number => v != null && v > 0);
return valid.length > 0 ? valid.reduce((a, b) => a + b, 0) / valid.length : null;
};
const avgProximity = avg(detailMetrics.map(m => m.proximityRatio));
const avgVisit = avg(detailMetrics.map(m => m.visitScore));
const avgActivity = avg(detailMetrics.map(m => m.activitySync));
const avgDtw = avg(detailMetrics.map(m => m.dtwSimilarity));
const avgSpeed = avg(detailMetrics.map(m => m.speedCorrelation));
const avgHeading = avg(detailMetrics.map(m => m.headingCoherence));
const avgDrift = avg(detailMetrics.map(m => m.driftSimilarity));
const shadowStayCount = detailMetrics.filter(m => m.shadowStay).length;
const shadowReturnCount = detailMetrics.filter(m => m.shadowReturn).length;
const pct = (v: number | null) => v != null ? `${(v * 100).toFixed(1)}%` : '-';
const bar = (v: number | null, color: string) => (
<div className="w-full h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(100, (v ?? 0) * 100)}%`, backgroundColor: color }} />
</div>
);
return (
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
{/* 헤더 */}
<div className="flex items-center gap-2 text-xs">
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
</div>
{/* 종합 점수 */}
<div className="flex items-center gap-3 py-2 border-y border-border">
<div className="text-center flex-1">
<div className={`text-lg font-bold ${cand.score >= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}>
{(cand.score * 100).toFixed(1)}%
</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{cand.streak}</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{detailMetrics.length}</div>
<div className="text-[9px] text-hint">raw </div>
</div>
</div>
{/* 점수 근거 상세 */}
{detailLoading ? (
<div className="flex items-center justify-center py-4"><Loader2 className="w-4 h-4 animate-spin text-hint" /></div>
) : (
<div className="space-y-2 text-[10px]">
<div className="text-hint font-medium text-[9px]"> ( {detailMetrics.length} )</div>
{[
{ label: '근접도', value: avgProximity, color: '#06b6d4', desc: '어구-선박 거리 근접 비율' },
{ label: '방문 점수', value: avgVisit, color: '#8b5cf6', desc: '어구 구역 방문 빈도' },
{ label: '활동 동기화', value: avgActivity, color: '#f59e0b', desc: '어구-선박 활동 시간 일치' },
{ label: 'DTW 유사도', value: avgDtw, color: '#ec4899', desc: '궤적 형태 유사도' },
{ label: '속도 상관', value: avgSpeed, color: '#14b8a6', desc: '속도 변화 패턴 일치' },
{ label: '침로 일관성', value: avgHeading, color: '#3b82f6', desc: '방향 변화 일치' },
{ label: '드리프트 유사도', value: avgDrift, color: '#64748b', desc: '표류 패턴 유사' },
].map(({ label, value, color, desc }) => (
<div key={label} className="flex items-center gap-2">
<span className="w-[70px] text-label shrink-0">{label}</span>
<div className="flex-1">{bar(value, color)}</div>
<span className="w-10 text-right font-mono text-label">{pct(value)}</span>
<span className="w-[90px] text-hint text-[8px] truncate" title={desc}>{desc}</span>
</div>
))}
{/* 보정 지표 */}
<div className="text-hint font-medium text-[9px] pt-1 border-t border-border"> </div>
<div className="grid grid-cols-2 gap-y-1 gap-x-4">
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowStayCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowReturnCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">{cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState}</Badge></span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">{cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType}</Badge></span>
</div>
</div>
)}
{/* 확정/제외 버튼 */}
{resolveMsg && (
<div className={`text-[10px] text-center py-1 rounded ${resolveMsg.includes('완료') ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'}`}>
{resolveMsg}
</div>
)}
<div className="flex gap-2">
<Button variant="primary" size="sm" className="flex-1"
onClick={() => handleResolve('confirm')} disabled={resolveLoading}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
{resolveLoading ? '처리 중...' : '모선 확정'}
</Button>
<Button variant="outline" size="sm" className="flex-1"
onClick={() => handleResolve('reject')} disabled={resolveLoading}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
);
})()}
{/* 궤적 리플레이 버튼 */}
<Button
variant={isReplayActive ? 'secondary' : 'primary'}
size="sm"
className="w-full"
onClick={handleStartReplay}
disabled={replayLoading}
>
{replayLoading ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 mr-1" />
)}
{replayLoading ? '데이터 로딩...'
: isReplayActive ? '리플레이 재시작'
: selectedCandidates.size > 0
? `리플레이 (후보 ${selectedCandidates.size}척 포함)`
: '24시간 궤적 리플레이'}
</Button>
{/* 쌍끌이 감지 정보 */}
{hasPairTrawl && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium"> </span>
<Badge intent="critical" size="sm">G-06</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.pairTrawlPairMmsi ? (
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
{gear.pairTrawlPairMmsi}
</button>
) : '-'}
</span>
{gear.gearViolationEvidence['G-06'] && (() => {
const ev = gear.gearViolationEvidence['G-06'];
return (
<>
{ev.sync_duration_min != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{String(ev.sync_duration_min)}</span>
</>
)}
{ev.mean_separation_nm != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m</span>
</>
)}
</>
);
})()}
</div>
</div>
)}
{/* 위치 + 액션 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lat.toFixed(6)}°N</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lng.toFixed(6)}°E</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${gear.owner}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API */ }}>
<ShieldAlert className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}