import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertOctagon, 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 { Textarea } from '@shared/components/ui/textarea'; 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 { GEAR_COLLISION_STATUS_ORDER, getGearCollisionStatusIntent, getGearCollisionStatusLabel, } from '@shared/constants/gearCollisionStatuses'; import { getGearCollisionStats, listGearCollisions, resolveGearCollision, type GearCollision, type GearCollisionResolveAction, type GearCollisionStats, } from '@/services/gearCollisionApi'; import { useSettingsStore } from '@stores/settingsStore'; /** * 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지. * * 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고 * 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다. */ type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const DEFAULT_HOURS = 48; export function GearCollisionDetection() { const { t } = useTranslation('detection'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language) as 'ko' | 'en'; const [rows, setRows] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [severityFilter, setSeverityFilter] = useState(''); const [nameFilter, setNameFilter] = useState(''); const [selected, setSelected] = useState(null); const [resolveAction, setResolveAction] = useState('REVIEWED'); const [resolveNote, setResolveNote] = useState(''); const [resolving, setResolving] = useState(false); const loadData = useCallback(async () => { setLoading(true); setError(''); try { const [page, summary] = await Promise.all([ listGearCollisions({ status: statusFilter || undefined, severity: severityFilter || undefined, name: nameFilter || undefined, hours: DEFAULT_HOURS, size: 200, }), getGearCollisionStats(DEFAULT_HOURS), ]); setRows(page.content); setStats(summary); } catch (e: unknown) { setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed')); } finally { setLoading(false); } }, [statusFilter, severityFilter, nameFilter, t]); useEffect(() => { loadData(); }, [loadData]); // 선택된 row 와 현재 목록의 동기화 const syncedSelected = useMemo( () => selected ? rows.find((r) => r.id === selected.id) ?? selected : null, [rows, selected], ); const cols: DataColumn>[] = useMemo(() => [ { key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true, render: (v) => {v as string}, }, { key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px', render: (_, row) => ( {row.mmsiLo} ↔ {row.mmsiHi} ), }, { key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px', render: (v) => {(v as string) || '-'}, }, { key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'), width: '90px', align: 'center', sortable: true, render: (v) => {v as number}, }, { key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'), width: '110px', align: 'right', sortable: true, render: (v) => { const n = typeof v === 'number' ? v : Number(v ?? 0); return {n.toFixed(2)}; }, }, { key: 'severity', label: t('gearCollision.columns.severity'), width: '90px', align: 'center', sortable: true, render: (v) => ( {getAlertLevelLabel(v as string, tc, lang)} ), }, { key: 'status', label: t('gearCollision.columns.status'), width: '110px', align: 'center', sortable: true, render: (v) => ( {getGearCollisionStatusLabel(v as string, t, lang)} ), }, { key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'), width: '130px', sortable: true, render: (v) => ( {formatDateTime(v as string)} ), }, ], [t, tc, lang]); const handleResolve = useCallback(async () => { if (!syncedSelected) return; const ok = window.confirm(t('gearCollision.resolve.confirmPrompt')); if (!ok) return; setResolving(true); try { const updated = await resolveGearCollision(syncedSelected.id, { action: resolveAction, note: resolveNote || undefined, }); setSelected(updated); setResolveNote(''); await loadData(); } catch (e: unknown) { setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed')); } finally { setResolving(false); } }, [syncedSelected, resolveAction, resolveNote, loadData, t]); const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0; return ( } > {t('gearCollision.list.refresh')} } /> {error && ( {error} )}
setNameFilter(e.target.value)} />
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
{rows.length === 0 && !loading ? (

{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}

) : ( )[]} columns={cols} pageSize={20} showSearch={false} showExport={false} showPrint={false} onRowClick={(row) => setSelected(row as GearCollision)} /> )}
{syncedSelected && (
{t('gearCollision.columns.severity')}: {getAlertLevelLabel(syncedSelected.severity, tc, lang)} {t('gearCollision.columns.status')}: {getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
{syncedSelected.resolutionNote && (

{syncedSelected.resolutionNote}

)}