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'; import { Select } from '@shared/components/ui/select'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; 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'; import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; import { useSettingsStore } from '@stores/settingsStore'; import { useAuth } from '@/app/auth/AuthContext'; /* * 이벤트 목록 — SFR-02 공통컴포넌트 적용 * DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload * 실제 백엔드 API 연동 */ type AlertLevel = AlertLevelType; interface EventRow { id: string; _eventId: number; time: string; level: AlertLevel; type: string; vesselName: string; mmsi: string; area: string; lat: string; lng: string; speed: string; status: string; assignee: string; [key: string]: unknown; } export function EventList() { const { t } = useTranslation('enforcement'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canAck = hasPermission('enforcement:event-list', 'UPDATE'); const canCreateEnforcement = hasPermission('enforcement:enforcement-history', 'CREATE'); const { events: storeEvents, rawEvents, stats, loading, error, load, silentRefresh, 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(() => [ { key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true, render: (val) => { const lv = val as AlertLevel; return ( {getAlertLevelLabel(lv, tc, lang)} ); }, }, { key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true, render: (val) => {formatDateTime(val as string)}, }, { key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true, render: (val) => { const code = val as string; return ( {getViolationLabel(code, tc, lang)} ); }, }, { key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true, render: (val) => {val as string}, }, { key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px', 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: '70px', maxWidth: '100px', sortable: true, render: (val) => { const s = val as string; return ( {getEventStatusLabel(s, 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, canAck, canCreateEnforcement]); const [levelFilter, setLevelFilter] = useState(''); const [showUpload, setShowUpload] = useState(false); const fetchData = useCallback(() => { const params = levelFilter ? { level: levelFilter } : undefined; load(params); loadStats(); }, [levelFilter, load, loadStats]); useEffect(() => { fetchData(); }, [fetchData]); // 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용) useEffect(() => { const params = levelFilter ? { level: levelFilter } : undefined; const timer = setInterval(() => { silentRefresh(params); loadStats(); }, 30_000); return () => clearInterval(timer); }, [levelFilter, silentRefresh, loadStats]); // 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, vesselName: e.vesselName ?? '-', mmsi: e.mmsi ?? '-', area: e.area ?? '-', lat: e.lat != null ? String(e.lat) : '-', lng: e.lng != null ? String(e.lng) : '-', speed: e.speed != null ? `${e.speed}kt` : '미상', status: e.status ?? '-', assignee: e.assignee ?? '-', })); // KPI 카운트: stats API가 있으면 사용, 없으면 클라이언트 계산 const kpiCritical = stats['CRITICAL'] ?? EVENTS.filter((e) => e.level === 'CRITICAL').length; const kpiHigh = stats['HIGH'] ?? EVENTS.filter((e) => e.level === 'HIGH').length; const kpiMedium = stats['MEDIUM'] ?? EVENTS.filter((e) => e.level === 'MEDIUM').length; const kpiLow = stats['LOW'] ?? EVENTS.filter((e) => e.level === 'LOW').length; const kpiTotal = (stats['TOTAL'] as number | undefined) ?? EVENTS.length; return (
} /> {/* KPI 요약 */}
{[ { label: '전체', count: kpiTotal, icon: Radar, color: 'text-label', bg: 'bg-muted', filterVal: '' }, { label: 'CRITICAL', count: kpiCritical, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10', filterVal: 'CRITICAL' }, { label: 'HIGH', count: kpiHigh, icon: Eye, color: 'text-orange-400', bg: 'bg-orange-500/10', filterVal: 'HIGH' }, { label: 'MEDIUM', count: kpiMedium, icon: Anchor, color: 'text-yellow-400', bg: 'bg-yellow-500/10', filterVal: 'MEDIUM' }, { label: 'LOW', count: kpiLow, icon: Crosshair, color: 'text-blue-400', bg: 'bg-blue-500/10', filterVal: 'LOW' }, ].map((kpi) => (
setLevelFilter(kpi.filterVal)} className={`flex items-center gap-2.5 p-3 rounded-xl border cursor-pointer transition-colors ${ (kpi.filterVal === '' && !levelFilter) || kpi.filterVal === levelFilter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border' }`} >
{kpi.count}
{kpi.label}
))}
{/* 에러 표시 */} {error && (
데이터 로딩 실패: {error}
)} {/* 파일 업로드 영역 */} {showUpload && (
이벤트 데이터 업로드
console.log('Uploaded:', files)} />
)} {/* 로딩 인디케이터 */} {loading && (
로딩 중...
)} {/* DataTable — 검색+정렬+페이징+엑셀+출력 */} {!loading && ( )}
); }