diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index ccd8568..508dae9 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -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[] = useMemo(() => [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, - { key: 'mmsi', label: 'MMSI', width: '100px', render: v => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => { + const mmsi = v as string; + return ( + + ); + } }, { key: 'flag', label: '국적', width: '50px' }, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, diff --git a/frontend/src/features/enforcement/EnforcementHistory.tsx b/frontend/src/features/enforcement/EnforcementHistory.tsx index 79838e9..95fc534 100644 --- a/frontend/src/features/enforcement/EnforcementHistory.tsx +++ b/frontend/src/features/enforcement/EnforcementHistory.tsx @@ -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[] = useMemo(() => [ { @@ -55,9 +58,20 @@ export function EnforcementHistory() { key: 'vessel', label: '대상 선박', sortable: true, - render: (v) => ( - {v as string} - ), + render: (_v, row) => { + const mmsi = row.mmsi; + const vessel = row.vessel as string; + if (mmsi && mmsi !== '-') { + return ( + + ); + } + return {vessel}; + }, }, { 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 ( diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 45e9dde..785a872 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -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(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[] = useMemo(() => [ { @@ -83,12 +129,24 @@ export function EventList() { render: (val) => {val as string}, }, { key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px', - render: (val) => {val as string}, + render: (_val, row) => { + const mmsi = row.mmsi; + if (!mmsi || mmsi === '-') return -; + return ( + + ); + }, }, { 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 ( +
+ {isNew && ( + + )} + + {isActionable && ( + <> + + + + )} +
+ ); + }, + }, + ], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]); const [levelFilter, setLevelFilter] = useState(''); 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, diff --git a/frontend/src/services/analysisApi.ts b/frontend/src/services/analysisApi.ts new file mode 100644 index 0000000..18691a3 --- /dev/null +++ b/frontend/src/services/analysisApi.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/services/event.ts b/frontend/src/services/event.ts index a406b8f..d11dcda 100644 --- a/frontend/src/services/event.ts +++ b/frontend/src/services/event.ts @@ -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 {