공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)
alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl
aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)
MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
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<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(() => [
|
|
{
|
|
key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true,
|
|
render: (val) => {
|
|
const lv = val as AlertLevel;
|
|
return (
|
|
<Badge intent={getAlertLevelIntent(lv)} size="sm">
|
|
{getAlertLevelLabel(lv, tc, lang)}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{ key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true,
|
|
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(val as string)}</span>,
|
|
},
|
|
{ key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true,
|
|
render: (val) => {
|
|
const code = val as string;
|
|
return (
|
|
<Badge intent={getViolationIntent(code)} size="sm">
|
|
{getViolationLabel(code, tc, lang)}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
|
|
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
|
},
|
|
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
|
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: '70px', maxWidth: '100px', sortable: true,
|
|
render: (val) => {
|
|
const s = val as string;
|
|
return (
|
|
<Badge intent={getEventStatusIntent(s)} size="xs">
|
|
{getEventStatusLabel(s, tc, lang)}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
|
|
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
<button type="button" aria-label={tc('aria.vesselDetail')} 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={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
|
|
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
|
<Shield className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
|
|
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
|
<Ban className="w-3.5 h-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate, canAck, canCreateEnforcement]);
|
|
|
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
|
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 (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Radar}
|
|
iconColor="text-blue-400"
|
|
title={t('eventList.title')}
|
|
description={t('eventList.desc')}
|
|
actions={
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<Filter className="w-3.5 h-3.5 text-hint" />
|
|
<Select
|
|
size="sm"
|
|
value={levelFilter}
|
|
onChange={(e) => setLevelFilter(e.target.value)}
|
|
title="등급 필터"
|
|
className="w-32"
|
|
>
|
|
<option value="">전체 등급</option>
|
|
<option value="CRITICAL">CRITICAL</option>
|
|
<option value="HIGH">HIGH</option>
|
|
<option value="MEDIUM">MEDIUM</option>
|
|
<option value="LOW">LOW</option>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowUpload(!showUpload)}
|
|
icon={<Upload className="w-3 h-3" />}
|
|
>
|
|
파일 업로드
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* KPI 요약 */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
{[
|
|
{ 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) => (
|
|
<div
|
|
key={kpi.label}
|
|
onClick={() => 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'
|
|
}`}
|
|
>
|
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
|
<kpi.icon className={`w-4 h-4 ${kpi.color}`} />
|
|
</div>
|
|
<div>
|
|
<div className={`text-lg font-bold ${kpi.color}`}>{kpi.count}</div>
|
|
<div className="text-[9px] text-hint">{kpi.label}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 에러 표시 */}
|
|
{error && (
|
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400">
|
|
데이터 로딩 실패: {error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 파일 업로드 영역 */}
|
|
{showUpload && (
|
|
<div className="rounded-xl border border-border bg-card p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] text-label font-bold">이벤트 데이터 업로드</span>
|
|
<button type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<FileUpload
|
|
accept=".xlsx,.csv"
|
|
multiple
|
|
maxSizeMB={20}
|
|
onFilesSelected={(files) => console.log('Uploaded:', files)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 로딩 인디케이터 */}
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
|
<span className="ml-2 text-[11px] text-muted-foreground">로딩 중...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* DataTable — 검색+정렬+페이징+엑셀+출력 */}
|
|
{!loading && (
|
|
<DataTable
|
|
data={EVENTS}
|
|
columns={columns}
|
|
pageSize={10}
|
|
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
|
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
|
exportFilename="이벤트목록"
|
|
/>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|