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:
부모
5c804aa38f
커밋
0679c04bfe
@ -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,
|
||||
|
||||
113
frontend/src/services/analysisApi.ts
Normal file
113
frontend/src/services/analysisApi.ts
Normal file
@ -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 {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user