feat(frontend): 워크플로우 연결 Step 2 — EventList 워크플로우 + MMSI 링크

- EventList 인라인 액션 버튼 4종 추가 (확인/선박상세/단속등록/오탐)
  - 확인(ACK): NEW 상태 이벤트만 활성, ackEvent API 연동
  - 선박 상세: /vessel/{mmsi} 네비게이션
  - 단속 등록: createEnforcementRecord API → 이벤트 RESOLVED 자동 전환
  - 오탐 처리: updateEventStatus(FALSE_POSITIVE) 연동
- MMSI → VesselDetail 링크 3개 화면 적용
  - EventList: MMSI 컬럼 클릭 → /vessel/{mmsi}
  - DarkVesselDetection: MMSI 컬럼 클릭 → /vessel/{mmsi}
  - EnforcementHistory: 대상 선박 컬럼 클릭 → /vessel/{mmsi}
- PredictionEvent 타입에 features 필드 추가 (dark_tier, transship_score 등)
- analysisApi.ts 서비스 신규 생성 (직접 조회 API 5개 연동)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 10:50:31 +09:00
부모 5c804aa38f
커밋 0679c04bfe
5개의 변경된 파일258개의 추가작업 그리고 12개의 파일을 삭제

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
@ -70,13 +71,22 @@ export function DarkVesselDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi}
</button>
);
} },
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -18,6 +19,7 @@ interface Record {
date: string;
zone: string;
vessel: string;
mmsi: string;
violation: string;
action: string;
aiMatch: string;
@ -29,7 +31,8 @@ export function EnforcementHistory() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { records, loading, error, load } = useEnforcementStore();
const navigate = useNavigate();
const { records, rawRecords, loading, error, load } = useEnforcementStore();
const cols: DataColumn<Record>[] = useMemo(() => [
{
@ -55,9 +58,20 @@ export function EnforcementHistory() {
key: 'vessel',
label: '대상 선박',
sortable: true,
render: (v) => (
<span className="text-cyan-400 font-medium">{v as string}</span>
),
render: (_v, row) => {
const mmsi = row.mmsi;
const vessel = row.vessel as string;
if (mmsi && mmsi !== '-') {
return (
<button type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-medium"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{vessel}
</button>
);
}
return <span className="text-cyan-400 font-medium">{vessel}</span>;
},
},
{
key: 'violation',
@ -119,7 +133,10 @@ export function EnforcementHistory() {
load();
}, [load]);
const DATA: Record[] = records as Record[];
const DATA: Record[] = records.map((r, idx) => ({
...r,
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
})) as Record[];
return (
<PageContainer>

파일 보기

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } 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';
@ -9,8 +10,11 @@ import { FileUpload } from '@shared/components/common/FileUpload';
import {
AlertTriangle, Eye, Anchor, Radar, Crosshair,
Filter, Upload, X, Loader2,
CheckCircle, Ship, Shield, Ban,
} from 'lucide-react';
import { useEventStore } from '@stores/eventStore';
import { ackEvent, updateEventStatus } from '@/services/event';
import { createEnforcementRecord } from '@/services/enforcement';
import { formatDateTime } from '@shared/utils/dateFormat';
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
@ -27,6 +31,7 @@ type AlertLevel = AlertLevelType;
interface EventRow {
id: string;
_eventId: number;
time: string;
level: AlertLevel;
type: string;
@ -45,14 +50,55 @@ export function EventList() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const {
events: storeEvents,
rawEvents,
stats,
loading,
error,
load,
loadStats,
} = useEventStore();
const [actionLoading, setActionLoading] = useState<number | null>(null);
const handleAck = useCallback(async (eventId: number) => {
setActionLoading(eventId);
try {
await ackEvent(eventId);
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const handleFalsePositive = useCallback(async (eventId: number) => {
setActionLoading(eventId);
try {
await updateEventStatus(eventId, 'FALSE_POSITIVE', '오탐 처리');
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const handleCreateEnforcement = useCallback(async (row: EventRow) => {
setActionLoading(row._eventId);
try {
await createEnforcementRecord({
eventId: row._eventId,
enforcedAt: new Date().toISOString(),
vesselMmsi: row.mmsi !== '-' ? row.mmsi : undefined,
vesselName: row.vesselName !== '-' ? row.vesselName : undefined,
zoneCode: row.area !== '-' ? row.area : undefined,
violationType: row.type,
action: 'PATROL_DISPATCH',
});
load({ level: '' });
} finally {
setActionLoading(null);
}
}, [load]);
const columns: DataColumn<EventRow>[] = useMemo(() => [
{
@ -83,12 +129,24 @@ export function EventList() {
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
render: (_val, row) => {
const mmsi = row.mmsi;
if (!mmsi || mmsi === '-') return <span className="text-hint">-</span>;
return (
<button
type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
>
{mmsi}
</button>
);
},
},
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
{
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
key: 'status', label: '처리상태', minWidth: '70px', maxWidth: '100px', sortable: true,
render: (val) => {
const s = val as string;
return (
@ -98,8 +156,46 @@ export function EventList() {
);
},
},
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
], [tc, lang]);
{
key: '_eventId', label: '액션', minWidth: '120px', maxWidth: '180px',
render: (_val, row) => {
const eid = row._eventId;
const isNew = row.status === 'NEW';
const isActionable = row.status !== 'RESOLVED' && row.status !== 'FALSE_POSITIVE';
const busy = actionLoading === eid;
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label="확인" title="확인(ACK)"
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
<button type="button" aria-label="선박 상세" title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{isActionable && (
<>
<button type="button" aria-label="단속 등록" title="단속 등록"
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label="오탐 처리" title="오탐 처리"
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
);
},
},
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]);
const [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false);
@ -114,9 +210,10 @@ export function EventList() {
fetchData();
}, [fetchData]);
// store events -> EventRow 변환
const EVENTS: EventRow[] = storeEvents.map((e) => ({
// store events -> EventRow 변환 (rawEvents에서 numeric id 참조)
const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({
id: e.id,
_eventId: rawEvents[idx]?.id ?? 0,
time: e.time,
level: e.level as AlertLevel,
type: e.type,

파일 보기

@ -0,0 +1,113 @@
/**
* vessel_analysis_results API .
* /api/analysis/* .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface VesselAnalysis {
id: number;
mmsi: string;
analyzedAt: string;
vesselType: string | null;
confidence: number | null;
fishingPct: number | null;
season: string | null;
lat: number | null;
lon: number | null;
zoneCode: string | null;
distToBaselineNm: number | null;
activityState: string | null;
isDark: boolean | null;
gapDurationMin: number | null;
darkPattern: string | null;
spoofingScore: number | null;
speedJumpCount: number | null;
transshipSuspect: boolean | null;
transshipPairMmsi: string | null;
transshipDurationMin: number | null;
fleetClusterId: number | null;
fleetRole: string | null;
fleetIsLeader: boolean | null;
riskScore: number | null;
riskLevel: string | null;
gearCode: string | null;
gearJudgment: string | null;
permitStatus: string | null;
features: Record<string, unknown> | null;
}
export interface AnalysisPageResponse {
content: VesselAnalysis[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
/** 분석 결과 목록 조회 */
export async function getAnalysisVessels(params?: {
mmsi?: string;
zoneCode?: string;
riskLevel?: string;
isDark?: boolean;
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
if (params?.mmsi) query.set('mmsi', params.mmsi);
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
if (params?.isDark != null) query.set('isDark', String(params.isDark));
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 특정 선박 최신 분석 결과 */
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 특정 선박 분석 이력 */
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 다크 베셀 목록 */
export async function getDarkVessels(params?: {
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/** 환적 의심 목록 */
export async function getTransshipSuspects(params?: {
hours?: number;
page?: number;
size?: number;
}): Promise<AnalysisPageResponse> {
const query = new URLSearchParams();
query.set('hours', String(params?.hours ?? 1));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 50));
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}

파일 보기

@ -30,6 +30,15 @@ export interface PredictionEvent {
resolvedAt: string | null;
resolutionNote: string | null;
createdAt: string;
features?: {
dark_suspicion_score?: number;
dark_tier?: string;
dark_patterns?: string[];
dark_history_7d?: number;
transship_tier?: string;
transship_score?: number;
[key: string]: unknown;
} | null;
}
export interface EventPageResponse {