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>
230 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|