import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ArrowLeftRight, RefreshCw } from 'lucide-react'; import { PageContainer, PageHeader, Section } from '@shared/components/layout'; import { Badge } from '@shared/components/ui/badge'; import { Button } from '@shared/components/ui/button'; import { Input } from '@shared/components/ui/input'; import { Select } from '@shared/components/ui/select'; import { Card, CardContent } from '@shared/components/ui/card'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi'; import { useSettingsStore } from '@stores/settingsStore'; /** * 환적(Transshipment) 의심 선박 탐지 페이지. * * prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과 * (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인. * * features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단. * 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는 * 전체 목록·통계 수준의 운영 대시보드. */ const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const; const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const; const DEFAULT_HOURS = 6; const DEFAULT_SIZE = 200; type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string; interface TransshipFeatures { transship_tier?: TransshipTier; transship_score?: number; dark_tier?: string; [key: string]: unknown; } function readTier(row: VesselAnalysis): string { const f = (row.features ?? {}) as TransshipFeatures; return f.transship_tier ?? '-'; } function readScore(row: VesselAnalysis): number | null { const f = (row.features ?? {}) as TransshipFeatures; return typeof f.transship_score === 'number' ? f.transship_score : null; } export function TransshipmentDetection() { const { t } = useTranslation('detection'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language) as 'ko' | 'en'; const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [hours, setHours] = useState(DEFAULT_HOURS); const [levelFilter, setLevelFilter] = useState(''); const [mmsiFilter, setMmsiFilter] = useState(''); const [selected, setSelected] = useState(null); const loadData = useCallback(async () => { setLoading(true); setError(''); try { const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE }); setRows(resp.content); } catch (e: unknown) { setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed')); } finally { setLoading(false); } }, [hours, t]); useEffect(() => { loadData(); }, [loadData]); const filteredRows = useMemo(() => { return rows.filter((r) => { if (levelFilter && r.riskLevel !== levelFilter) return false; if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false; return true; }); }, [rows, levelFilter, mmsiFilter]); const stats = useMemo(() => { const byLevel: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; const byTier: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 }; for (const r of rows) { if (r.riskLevel && byLevel[r.riskLevel] !== undefined) { byLevel[r.riskLevel]++; } const tier = readTier(r); if (byTier[tier] !== undefined) { byTier[tier]++; } } return { byLevel, byTier }; }, [rows]); const cols: DataColumn>[] = useMemo( () => [ { key: 'analyzedAt', label: t('transshipment.columns.analyzedAt'), width: '140px', sortable: true, render: (v) => ( {formatDateTime(v as string)} ), }, { key: 'mmsi', label: t('transshipment.columns.mmsi'), width: '120px', sortable: true, render: (v) => ( {v as string} ), }, { key: 'transshipPairMmsi', label: t('transshipment.columns.pairMmsi'), width: '120px', render: (v) => ( {(v as string) || '-'} ), }, { key: 'transshipDurationMin', label: t('transshipment.columns.durationMin'), width: '90px', align: 'right', sortable: true, render: (v) => { const n = typeof v === 'number' ? v : Number(v ?? 0); return {n.toFixed(0)}; }, }, { key: 'features', label: t('transshipment.columns.tier'), width: '90px', align: 'center', render: (_, row) => { const tier = readTier(row); const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier); return ( {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier} ); }, }, { key: 'riskScore', label: t('transshipment.columns.riskScore'), width: '80px', align: 'right', sortable: true, render: (v) => {(v as number) ?? 0}, }, { key: 'riskLevel', label: t('transshipment.columns.riskLevel'), width: '90px', align: 'center', sortable: true, render: (v) => ( {getAlertLevelLabel(v as string, tc, lang)} ), }, { key: 'zoneCode', label: t('transshipment.columns.zone'), width: '130px', render: (v) => {(v as string) || '-'}, }, ], [t, tc, lang], ); return ( } > {t('transshipment.refresh')} } /> {error && ( {error} )}
setMmsiFilter(e.target.value)} />
{filteredRows.length} / {rows.length}
{filteredRows.length === 0 && !loading ? (

{t('transshipment.list.empty', { hours })}

) : ( )[]} columns={cols} pageSize={20} showSearch={false} showExport={false} showPrint={false} onRowClick={(row) => setSelected(row as VesselAnalysis)} /> )}
{selected && (
{t('transshipment.columns.tier')}: {(() => { const tier = readTier(selected); const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier); return ( {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier} ); })()} {t('transshipment.detail.transshipScore')}: {readScore(selected)?.toFixed(1) ?? '-'}
{selected.features && Object.keys(selected.features).length > 0 && (
{t('transshipment.detail.features')}
                    {JSON.stringify(selected.features, null, 2)}
                  
)}
)}
); } // ─── 내부 컴포넌트 ───────────── interface StatCardProps { label: string; value: number; intent?: 'warning' | 'info' | 'critical' | 'muted'; } function StatCard({ label, value, intent }: StatCardProps) { return ( {label} {intent ? ( {value} ) : ( {value} )} ); } interface DetailRowProps { label: string; value: string; mono?: boolean; } function DetailRow({ label, value, mono }: DetailRowProps) { return (
{label} {value}
); } export default TransshipmentDetection;