kcg-ai-monitoring/frontend/src/features/detection/TransshipmentDetection.tsx
htlee cbfed23823 feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
prediction algorithms/transshipment.py 5단계 필터 파이프라인 결과를 전체 목록·
집계·상세 수준으로 조회하는 READ 전용 대시보드.

### 배경
기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준(특정 MMSI 의 환적
이력)이고, 환적 의심 선박 전체 목록을 보려면 ChinaFishing 의 탭 중 하나를 거쳐야
했다. /api/analysis/transship 엔드포인트는 이미 존재하나 전용 페이지가 없었음.

### 변경
- frontend/src/features/detection/TransshipmentDetection.tsx 신설 (405 라인)
  - PageContainer + PageHeader(ArrowLeftRight) + KPI 5장
    (Total / Transship tier CRITICAL/HIGH/MEDIUM / Risk CRITICAL)
  - DataTable 8컬럼 (analyzedAt / mmsi / pairMmsi / duration / tier / risk / zone)
  - features.transship_tier 읽어 Badge 로 심각도 표시
  - 필터: hours(1/6/12/24/48) / riskLevel / mmsi 검색
  - 상세 패널: 분석 피처 JSON 원본 + 좌표 + transship_score
  - 기존 analysisApi.getTransshipSuspects 재사용 — backend 변경 없음
- index.ts + componentRegistry.ts 등록
- detection.json (ko/en) transshipment.* 네임스페이스 추가 (각 44키)
- common.json (ko/en) nav.transshipment 추가
- V033__menu_transshipment_detection.sql
  - auth_perm_tree(detection:transshipment, nav_sort=910)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ

### 권한 주의
/api/analysis/transship 의 @RequirePermission 은 현재 detection:dark-vessel.
이 메뉴 READ 만으로는 API 호출 불가. 현행 운영자 역할(OPERATOR/ANALYST/FIELD)
은 dark-vessel READ 도 보유하므로 실용 동작.
향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
detection:transshipment 로 교체하는 권한 일관화는 별도 MR (후속).

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc + ESLint 통과 예정
- Flyway V033 자동 적용 (백엔드 재배포 필요)
2026-04-20 05:51:06 +09:00

406 lines
14 KiB
TypeScript

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<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
const [levelFilter, setLevelFilter] = useState('');
const [mmsiFilter, setMmsiFilter] = useState('');
const [selected, setSelected] = useState<VesselAnalysis | null>(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<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
const byTier: Record<string, number> = { 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<VesselAnalysis & Record<string, unknown>>[] = useMemo(
() => [
{
key: 'analyzedAt',
label: t('transshipment.columns.analyzedAt'),
width: '140px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
{
key: 'mmsi',
label: t('transshipment.columns.mmsi'),
width: '120px',
sortable: true,
render: (v) => (
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
{v as string}
</span>
),
},
{
key: 'transshipPairMmsi',
label: t('transshipment.columns.pairMmsi'),
width: '120px',
render: (v) => (
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
),
},
{
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 <span className="font-mono text-label">{n.toFixed(0)}</span>;
},
},
{
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 (
<Badge
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
size="sm"
>
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
},
},
{
key: 'riskScore',
label: t('transshipment.columns.riskScore'),
width: '80px',
align: 'right',
sortable: true,
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
},
{
key: 'riskLevel',
label: t('transshipment.columns.riskLevel'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'zoneCode',
label: t('transshipment.columns.zone'),
width: '130px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
],
[t, tc, lang],
);
return (
<PageContainer>
<PageHeader
icon={ArrowLeftRight}
iconColor="text-purple-600 dark:text-purple-400"
title={t('transshipment.title')}
description={t('transshipment.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('transshipment.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('transshipment.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
</div>
</Section>
<Section title={t('transshipment.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('transshipment.filters.hours')}
value={String(hours)}
onChange={(e) => setHours(Number(e.target.value))}
>
{HOUR_OPTIONS.map((h) => (
<option key={h} value={h}>
{t('transshipment.filters.hoursValue', { h })}
</option>
))}
</Select>
<Select
aria-label={t('transshipment.filters.level')}
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
>
<option value="">{t('transshipment.filters.allLevel')}</option>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{getAlertLevelLabel(l, tc, lang)}
</option>
))}
</Select>
<Input
aria-label={t('transshipment.filters.mmsi')}
placeholder={t('transshipment.filters.mmsi')}
value={mmsiFilter}
onChange={(e) => setMmsiFilter(e.target.value)}
/>
<div className="flex items-center justify-end gap-1.5">
<Badge intent="info" size="sm">
{filteredRows.length} / {rows.length}
</Badge>
</div>
</div>
{filteredRows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('transshipment.list.empty', { hours })}
</p>
) : (
<DataTable
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as VesselAnalysis)}
/>
)}
</Section>
{selected && (
<Section title={t('transshipment.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
<DetailRow
label={t('transshipment.columns.analyzedAt')}
value={formatDateTime(selected.analyzedAt)}
/>
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
<DetailRow
label={t('transshipment.columns.pairMmsi')}
value={selected.transshipPairMmsi ?? '-'}
mono
/>
<DetailRow
label={t('transshipment.columns.durationMin')}
value={`${selected.transshipDurationMin ?? 0}`}
/>
<DetailRow
label={t('transshipment.columns.riskScore')}
value={String(selected.riskScore ?? 0)}
/>
<DetailRow
label={t('transshipment.columns.zone')}
value={selected.zoneCode ?? '-'}
/>
<DetailRow
label={t('transshipment.detail.location')}
value={
selected.lat != null && selected.lon != null
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('transshipment.columns.tier')}:
</span>
{(() => {
const tier = readTier(selected);
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
return (
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
})()}
<span className="text-xs text-label ml-3">
{t('transshipment.detail.transshipScore')}:
</span>
<span className="text-xs font-mono text-heading">
{readScore(selected)?.toFixed(1) ?? '-'}
</span>
</div>
{selected.features && Object.keys(selected.features).length > 0 && (
<div>
<div className="text-[10px] text-hint mb-1">
{t('transshipment.detail.features')}
</div>
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
{JSON.stringify(selected.features, null, 2)}
</pre>
</div>
)}
<div className="flex justify-end pt-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
{t('transshipment.detail.close')}
</Button>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint text-center">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-start gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default TransshipmentDetection;