kcg-ai-monitoring/frontend/src/features/enforcement/EnforcementHistory.tsx
htlee 6887a2b4fc feat(frontend): 워크플로우 연결 Step 4 — Enforcement 연계 + admin 서브그룹
EnforcementHistory:
- eventId 역추적 컬럼 추가 (#{eventId} 클릭 → EventList 이동)
- Record 인터페이스에 eventId 필드 추가

EnforcementPlan:
- 미배정 CRITICAL 이벤트 패널 신설 (NEW 상태 CRITICAL 이벤트 표시)
- getEvents(level=CRITICAL, status=NEW) 연동

MainLayout:
- admin 메뉴 4개 서브그룹 분리 (AI 플랫폼/시스템 운영/사용자 관리/감사·보안)
- NavDivider 타입 도입으로 그룹 내 소제목 라벨 렌더링
- 기존 RBAC 필터링 + collapsed 모드 호환 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:32:03 +09:00

230 lines
6.8 KiB
TypeScript

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';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useEnforcementStore } from '@stores/enforcementStore';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { getEnforcementActionLabel } from '@shared/constants/enforcementActions';
import { getEnforcementResultLabel, getEnforcementResultIntent } from '@shared/constants/enforcementResults';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */
interface Record {
id: string;
date: string;
zone: string;
vessel: string;
mmsi: string;
eventId: number | null;
violation: string;
action: string;
aiMatch: string;
result: string;
[key: string]: unknown;
}
export function EnforcementHistory() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate();
const { records, rawRecords, loading, error, load } = useEnforcementStore();
const cols: DataColumn<Record>[] = useMemo(() => [
{
key: 'id',
label: 'ID',
width: '80px',
render: (v) => (
<span className="text-hint font-mono text-[10px]">{v as string}</span>
),
},
{
key: 'date',
label: '일시',
sortable: true,
render: (v) => (
<span className="text-muted-foreground font-mono text-[10px]">
{formatDateTime(v as string)}
</span>
),
},
{ key: 'zone', label: '해역', width: '90px', sortable: true },
{
key: 'vessel',
label: '대상 선박',
sortable: true,
render: (_v, row) => {
const mmsi = row.mmsi;
const vessel = row.vessel as string;
if (mmsi && mmsi !== '-') {
return (
<button type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-medium"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{vessel}
</button>
);
}
return <span className="text-cyan-400 font-medium">{vessel}</span>;
},
},
{
key: 'eventId',
label: '이벤트',
width: '70px',
render: (_v, row) => {
const eid = row.eventId;
if (!eid) return <span className="text-hint">-</span>;
return (
<button type="button"
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/events?id=${eid}`); }}
title={`이벤트 #${eid}`}>
#{eid}
</button>
);
},
},
{
key: 'violation',
label: '위반 내용',
minWidth: '90px',
maxWidth: '160px',
sortable: true,
render: (v) => {
const code = v as string;
return (
<Badge intent={getViolationIntent(code)} size="sm">
{getViolationLabel(code, tc, lang)}
</Badge>
);
},
},
{
key: 'action',
label: '조치',
minWidth: '70px',
maxWidth: '110px',
render: (v) => (
<span className="text-label">{getEnforcementActionLabel(v as string, tc, lang)}</span>
),
},
{
key: 'aiMatch',
label: 'AI 매칭',
width: '70px',
align: 'center',
render: (v) => {
const m = v as string;
return m === '일치' || m === 'MATCH' ? (
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
) : (
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
);
},
},
{
key: 'result',
label: '결과',
minWidth: '80px',
maxWidth: '120px',
align: 'center',
sortable: true,
render: (v) => {
const code = v as string;
return (
<Badge intent={getEnforcementResultIntent(code)} size="xs">
{getEnforcementResultLabel(code, tc, lang)}
</Badge>
);
},
},
], [tc, lang]);
useEffect(() => {
load();
}, [load]);
const DATA: Record[] = records.map((r, idx) => ({
...r,
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
eventId: rawRecords[idx]?.eventId ?? null,
})) as Record[];
return (
<PageContainer>
<PageHeader
icon={FileText}
iconColor="text-blue-400"
title={t('history.title')}
description={t('history.desc')}
/>
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
<div className="flex gap-2">
{[
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
{
l: '처벌·수사',
v: DATA.filter((d) => d.result === 'PUNISHED' || d.result === 'REFERRED').length,
c: 'text-red-400',
},
{
l: 'AI 일치',
v: DATA.filter((d) => d.aiMatch === '일치' || d.aiMatch === 'MATCH').length,
c: 'text-green-400',
},
{
l: '오탐',
v: DATA.filter((d) => d.result === 'FALSE_POSITIVE').length,
c: 'text-yellow-400',
},
].map((k) => (
<div
key={k.l}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"
>
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
<span className="text-[9px] text-hint">{k.l}</span>
</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>
)}
{/* 로딩 인디케이터 */}
{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={DATA}
columns={cols}
pageSize={10}
searchPlaceholder="선박명, 해역, 위반내용 검색..."
searchKeys={['vessel', 'zone', 'violation', 'result']}
exportFilename="단속이력"
/>
)}
</PageContainer>
);
}