kcg-ai-monitoring/frontend/src/features/enforcement/EventList.tsx
htlee 2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에
  intent 필드 추가 + getXxxIntent() 헬퍼 신규
- statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑
  + getRiskIntent(0-100) 점수 기반 매핑
- 모든 Badge className="..." 패턴을 intent prop으로 치환:
  - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub)
  - ai-operations (AIModelManagement/MLOpsPage)
  - enforcement (EventList/EnforcementHistory)
  - field-ops (AIAlert)
  - detection (GearIdentification)
  - patrol (PatrolRoute/FleetOptimization)
  - parent-inference (ParentExclusion)
  - statistics (ExternalService/ReportManagement)
  - surveillance (MapControl)
  - risk-assessment (EnforcementPlan)
  - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토)
  - dashboard (Dashboard PatrolStatusBadge)

이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며,
쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00

253 lines
9.3 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
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,
} from 'lucide-react';
import { useEventStore } from '@stores/eventStore';
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';
/*
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
* DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload
* 실제 백엔드 API 연동
*/
type AlertLevel = AlertLevelType;
interface EventRow {
id: string;
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 {
events: storeEvents,
stats,
loading,
error,
load,
loadStats,
} = useEventStore();
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) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
},
{ 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,
render: (val) => {
const s = val as string;
return (
<Badge intent={getEventStatusIntent(s)} size="xs">
{getEventStatusLabel(s, tc, lang)}
</Badge>
);
},
},
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
], [tc, lang]);
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]);
// store events -> EventRow 변환
const EVENTS: EventRow[] = storeEvents.map((e) => ({
id: e.id,
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>
);
}