공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)
alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl
aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)
MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
194 lines
9.0 KiB
TypeScript
194 lines
9.0 KiB
TypeScript
/**
|
|
* DarkDetailPanel — Dark Vessel 판정 상세 사이드 패널
|
|
*
|
|
* 테이블 행 클릭 시 우측에 슬라이드 표시.
|
|
* 점수 산출 내역, 선박 정보, GAP 상세, 과거 이력을 종합 표시.
|
|
*/
|
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { Button } from '@shared/components/ui/button';
|
|
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
|
|
import { buildScoreBreakdown } from '@shared/constants/darkVesselPatterns';
|
|
import { getRiskIntent } from '@shared/constants/statusIntent';
|
|
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
import { getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
|
|
import { X, Ship, MapPin, Clock, AlertTriangle, TrendingUp, ExternalLink, ShieldAlert } from 'lucide-react';
|
|
import { BarChart as EcBarChart } from '@lib/charts';
|
|
|
|
interface DarkDetailPanelProps {
|
|
vessel: VesselAnalysis | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
|
const navigate = useNavigate();
|
|
const { t: tc } = useTranslation('common');
|
|
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
|
|
|
const features = vessel?.features ?? {};
|
|
const darkTier = (features.dark_tier as string) ?? 'NONE';
|
|
const darkScore = (features.dark_suspicion_score as number) ?? 0;
|
|
const darkPatterns = (features.dark_patterns as string[]) ?? [];
|
|
const darkHistory7d = (features.dark_history_7d as number) ?? 0;
|
|
const darkHistory24h = (features.dark_history_24h as number) ?? 0;
|
|
const gapStartLat = features.gap_start_lat as number | undefined;
|
|
const gapStartLon = features.gap_start_lon as number | undefined;
|
|
const gapStartSog = features.gap_start_sog as number | undefined;
|
|
const gapStartState = features.gap_start_state as string | undefined;
|
|
|
|
// 점수 산출 내역
|
|
const breakdown = useMemo(() => buildScoreBreakdown(darkPatterns), [darkPatterns]);
|
|
|
|
// 7일 이력 조회
|
|
const loadHistory = useCallback(async () => {
|
|
if (!vessel?.mmsi) return;
|
|
try {
|
|
const res = await getAnalysisHistory(vessel.mmsi, 168); // 7일
|
|
setHistory(res);
|
|
} catch { setHistory([]); }
|
|
}, [vessel?.mmsi]);
|
|
|
|
useEffect(() => { loadHistory(); }, [loadHistory]);
|
|
|
|
// 일별 dark 건수 집계 (차트용)
|
|
const dailyDarkData = useMemo(() => {
|
|
const dayMap: Record<string, number> = {};
|
|
for (const h of history) {
|
|
if (!h.isDark) continue;
|
|
const day = (h.analyzedAt ?? '').slice(0, 10);
|
|
if (day) dayMap[day] = (dayMap[day] || 0) + 1;
|
|
}
|
|
return Object.entries(dayMap)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([day, count]) => ({ name: day.slice(5), value: count }));
|
|
}, [history]);
|
|
|
|
if (!vessel) return null;
|
|
|
|
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-red-400" />
|
|
<span className="font-bold text-heading text-sm">판정 상세</span>
|
|
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
|
|
<span className="text-xs font-mono font-bold text-heading">{darkScore}점</span>
|
|
</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">
|
|
{/* 선박 기본 정보 */}
|
|
<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-400" />
|
|
<span className="text-label font-medium">선박 정보</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">MMSI</span>
|
|
<button type="button" className="text-cyan-400 hover:underline text-right font-mono"
|
|
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
|
|
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
|
|
</button>
|
|
<span className="text-hint">선종</span>
|
|
<span className="text-label text-right">{vessel.vesselType || 'UNKNOWN'}</span>
|
|
<span className="text-hint">국적</span>
|
|
<span className="text-label text-right">{vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)}</span>
|
|
<span className="text-hint">해역</span>
|
|
<span className="text-label text-right">{vessel.zoneCode || '-'}</span>
|
|
<span className="text-hint">활동상태</span>
|
|
<span className="text-label text-right">{vessel.activityState || '-'}</span>
|
|
<span className="text-hint">위험도</span>
|
|
<span className="text-right">
|
|
<Badge intent={getRiskIntent(vessel.riskScore ?? 0)} size="sm">
|
|
{vessel.riskLevel} ({vessel.riskScore})
|
|
</Badge>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 점수 산출 내역 */}
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
|
<span className="text-label font-medium">점수 산출 내역</span>
|
|
<span className="text-hint text-[10px]">({breakdown.items.length}개 패턴 적용)</span>
|
|
</div>
|
|
<ScoreBreakdown items={breakdown.items} totalScore={darkScore} />
|
|
</div>
|
|
|
|
{/* GAP 상세 */}
|
|
<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-400" />
|
|
<span className="text-label font-medium">GAP 상세</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">GAP 길이</span>
|
|
<span className="text-label text-right font-mono">
|
|
{vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'}
|
|
</span>
|
|
<span className="text-hint">시작 위치</span>
|
|
<span className="text-label text-right font-mono text-[10px]">
|
|
{gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'}
|
|
</span>
|
|
<span className="text-hint">시작 SOG</span>
|
|
<span className="text-label text-right font-mono">
|
|
{gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'}
|
|
</span>
|
|
<span className="text-hint">시작 상태</span>
|
|
<span className="text-label text-right">{gapStartState || '-'}</span>
|
|
<span className="text-hint">분석시각</span>
|
|
<span className="text-label text-right text-[10px]">{formatDateTime(vessel.analyzedAt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 과거 이력 */}
|
|
<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-400" />
|
|
<span className="text-label font-medium">과거 이력 (7일)</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">7일 dark 일수</span>
|
|
<span className="text-label text-right font-bold">{darkHistory7d}일</span>
|
|
<span className="text-hint">24시간 dark</span>
|
|
<span className="text-label text-right">{darkHistory24h}일</span>
|
|
</div>
|
|
{dailyDarkData.length > 0 && (
|
|
<div className="h-24 mt-2">
|
|
<EcBarChart
|
|
data={dailyDarkData}
|
|
xKey="name"
|
|
series={[{ key: 'value', name: 'Dark 건수', color: '#ef4444' }]}
|
|
height={96}
|
|
/>
|
|
</div>
|
|
)}
|
|
{dailyDarkData.length === 0 && (
|
|
<div className="text-hint text-[10px] text-center py-2">이력 없음</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 액션 버튼 */}
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" className="flex-1"
|
|
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
|
|
<Ship className="w-3.5 h-3.5 mr-1" /> 선박 상세
|
|
</Button>
|
|
<Button variant="primary" size="sm" className="flex-1"
|
|
onClick={() => { /* TODO: 단속 대상 등록 API 연동 */ }}>
|
|
<Clock className="w-3.5 h-3.5 mr-1" /> 단속 대상 등록
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|