feat: S5 프론트 실데이터 전환 — EventList/Statistics/EnforcementHistory/Dashboard KPI
이벤트 목록 (EventList): - eventStore를 GET /api/events 호출로 전환 - 서버 필터링 (level/status/category), 페이지네이션 - 상태 배지 (NEW/ACK/IN_PROGRESS/RESOLVED/FALSE_POSITIVE) - getEventStats() 기반 KPI 카드 단속 이력 (EnforcementHistory): - 신규 services/enforcement.ts (GET/POST /enforcement/records, /plans) - enforcementStore를 API 기반으로 전환 - KPI 카드 (총단속/처벌/AI일치/오탐) 클라이언트 계산 통계 (Statistics): - kpi.ts를 GET /api/stats/kpi, /stats/monthly 실제 호출로 전환 - toMonthlyTrend/toViolationTypes 변환 헬퍼 추가 - BarChart/AreaChart 기존 구조 유지 대시보드 KPI: - kpiStore를 API 기반으로 전환 (getKpiMetrics + getMonthlyStats) - Dashboard KPI_UI_MAP에 kpiKey 기반 매핑 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
b70ef399b5
커밋
4e6ac8645a
@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @deprecated EnforcementHistory는 실제 API로 전환 완료.
|
||||
* EnforcementPlan.tsx가 아직 MOCK_ENFORCEMENT_PLANS를 참조하므로 삭제하지 마세요.
|
||||
*/
|
||||
|
||||
/** @deprecated services/enforcement.ts의 EnforcementRecord 사용 권장 */
|
||||
export interface EnforcementRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* @deprecated EventList, Dashboard, MonitoringDashboard는 실제 API로 전환 완료.
|
||||
* 아직 AIAlert, MobileService가 AlertRecord mock을 참조하므로 삭제하지 마세요.
|
||||
*
|
||||
* Shared mock data: events & alerts
|
||||
*
|
||||
* Sources:
|
||||
* - EventList.tsx EVENTS (15 records) — primary
|
||||
* - Dashboard.tsx TIMELINE_EVENTS (10)
|
||||
* - MonitoringDashboard.tsx EVENTS (6)
|
||||
* - AIAlert.tsx DATA (5 alerts)
|
||||
* - MobileService.tsx ALERTS (3)
|
||||
* - AIAlert.tsx DATA (5 alerts) — mock 유지
|
||||
* - MobileService.tsx ALERTS (3) — mock 유지
|
||||
*/
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
@ -26,7 +26,7 @@ const ALERT_COLORS: Record<AlertLevel, { bg: string; text: string; border: strin
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' },
|
||||
};
|
||||
|
||||
// ─── KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑) ─────────
|
||||
// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ─────────
|
||||
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
@ -34,6 +34,13 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
// kpiKey 기반 매핑 (백엔드 API 응답)
|
||||
realtime_detection: { icon: Radar, color: '#3b82f6' },
|
||||
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
|
||||
dark_vessel: { icon: Eye, color: '#f97316' },
|
||||
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
|
||||
tracking: { icon: Crosshair, color: '#06b6d4' },
|
||||
enforcement: { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
|
||||
|
||||
@ -285,14 +292,17 @@ export function Dashboard() {
|
||||
useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
|
||||
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
||||
|
||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
prev: m.prev ?? 0,
|
||||
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
||||
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
||||
desc: m.description ?? '',
|
||||
})), [kpiStore.metrics]);
|
||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
|
||||
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
|
||||
return {
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
prev: m.prev ?? 0,
|
||||
icon: ui.icon,
|
||||
color: ui.color,
|
||||
desc: m.description ?? '',
|
||||
};
|
||||
}), [kpiStore.metrics]);
|
||||
|
||||
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
|
||||
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||
|
||||
@ -1,48 +1,178 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { FileText, Ship, MapPin, Calendar, Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||
|
||||
/* SFR-11: 단속·탐지 이력 관리 */
|
||||
/* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
date: string;
|
||||
zone: string;
|
||||
vessel: string;
|
||||
violation: string;
|
||||
action: string;
|
||||
aiMatch: string;
|
||||
result: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Record { id: string; date: string; zone: string; vessel: string; violation: string; action: string; aiMatch: string; result: string; [key: string]: unknown; }
|
||||
const cols: DataColumn<Record>[] = [
|
||||
{ key: 'id', label: 'ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'date', label: '일시', width: '130px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
width: '130px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{ key: 'vessel', label: '대상 선박', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'violation', label: '위반 내용', width: '100px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
width: '100px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'action', label: '조치', width: '90px' },
|
||||
{ key: 'aiMatch', label: 'AI 매칭', width: '70px', align: 'center',
|
||||
render: v => { const m = v as string; return m === '일치' ? <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: '결과', width: '80px', align: 'center', sortable: true,
|
||||
render: v => { const r = v as string; const c = r.includes('처벌') || r.includes('수사') ? 'bg-red-500/20 text-red-400' : r.includes('오탐') ? 'bg-muted text-muted-foreground' : 'bg-yellow-500/20 text-yellow-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>; } },
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' ? (
|
||||
<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: '결과',
|
||||
width: '80px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const r = v as string;
|
||||
const c =
|
||||
r.includes('처벌') || r.includes('수사')
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: r.includes('오탐')
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-yellow-500/20 text-yellow-400';
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function EnforcementHistory() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { records, load } = useEnforcementStore();
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const { records, loading, error, load } = useEnforcementStore();
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const DATA: Record[] = records as Record[];
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><FileText className="w-5 h-5 text-blue-400" />{t('history.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
{t('history.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
<div className="flex gap-2">
|
||||
{[{ l: '총 단속', v: DATA.length, c: 'text-heading' }, { l: '처벌', v: DATA.filter(d => d.result.includes('처벌')).length, c: 'text-red-400' }, { l: 'AI 일치', v: DATA.filter(d => d.aiMatch === '일치').length, c: 'text-green-400' }, { l: '오탐', v: DATA.filter(d => d.result.includes('오탐')).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>
|
||||
{[
|
||||
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
|
||||
{
|
||||
l: '처벌',
|
||||
v: DATA.filter((d) => d.result.includes('처벌')).length,
|
||||
c: 'text-red-400',
|
||||
},
|
||||
{
|
||||
l: 'AI 일치',
|
||||
v: DATA.filter((d) => d.aiMatch === '일치').length,
|
||||
c: 'text-green-400',
|
||||
},
|
||||
{
|
||||
l: '오탐',
|
||||
v: DATA.filter((d) => d.result.includes('오탐')).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>
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, 해역, 위반내용 검색..." searchKeys={['vessel', 'zone', 'violation', 'result']} exportFilename="단속이력" />
|
||||
|
||||
{/* 에러 표시 */}
|
||||
{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="단속이력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { FileUpload } from '@shared/components/common/FileUpload';
|
||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||
import {
|
||||
AlertTriangle, Ship, Eye, Anchor, Radar, Crosshair,
|
||||
Filter, Upload, X,
|
||||
AlertTriangle, Eye, Anchor, Radar, Crosshair,
|
||||
Filter, Upload, X, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
|
||||
/*
|
||||
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
||||
* DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload
|
||||
* 실제 백엔드 API 연동
|
||||
*/
|
||||
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
interface EventRecord {
|
||||
interface EventRow {
|
||||
id: string;
|
||||
time: string;
|
||||
level: AlertLevel;
|
||||
@ -40,15 +40,29 @@ const LEVEL_STYLES: Record<AlertLevel, { bg: string; text: string }> = {
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
|
||||
};
|
||||
|
||||
// ─── EventRecord is now loaded from useEventStore ───
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
NEW: 'bg-red-500/20 text-red-400',
|
||||
ACK: 'bg-orange-500/20 text-orange-400',
|
||||
IN_PROGRESS: 'bg-blue-500/20 text-blue-400',
|
||||
RESOLVED: 'bg-green-500/20 text-green-400',
|
||||
FALSE_POSITIVE: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const columns: DataColumn<EventRecord>[] = [
|
||||
function statusColor(s: string): string {
|
||||
if (STATUS_COLORS[s]) return STATUS_COLORS[s];
|
||||
if (s === '완료' || s === '확인 완료' || s === '경고 완료') return 'bg-green-500/20 text-green-400';
|
||||
if (s.includes('추적') || s.includes('나포')) return 'bg-red-500/20 text-red-400';
|
||||
if (s.includes('감시') || s.includes('확인')) return 'bg-yellow-500/20 text-yellow-400';
|
||||
return 'bg-blue-500/20 text-blue-400';
|
||||
}
|
||||
|
||||
const columns: DataColumn<EventRow>[] = [
|
||||
{
|
||||
key: 'level', label: '등급', width: '70px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
const s = LEVEL_STYLES[lv];
|
||||
return <Badge className={`border-0 text-[9px] ${s.bg} ${s.text}`}>{lv}</Badge>;
|
||||
return <Badge className={`border-0 text-[9px] ${s?.bg ?? ''} ${s?.text ?? ''}`}>{lv}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
|
||||
@ -69,11 +83,7 @@ const columns: DataColumn<EventRecord>[] = [
|
||||
key: 'status', label: '처리상태', width: '80px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
const color = s === '완료' || s === '확인 완료' || s === '경고 완료' ? 'bg-green-500/20 text-green-400'
|
||||
: s.includes('추적') || s.includes('나포') ? 'bg-red-500/20 text-red-400'
|
||||
: s.includes('감시') || s.includes('확인') ? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400';
|
||||
return <Badge className={`border-0 text-[9px] ${color}`}>{s}</Badge>;
|
||||
return <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', width: '70px' },
|
||||
@ -81,35 +91,50 @@ const columns: DataColumn<EventRecord>[] = [
|
||||
|
||||
export function EventList() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { events: storeEvents, loaded, load } = useEventStore();
|
||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||
|
||||
// Map store EventRecord to local EventRecord shape (string lat/lng/speed)
|
||||
const EVENTS: EventRecord[] = useMemo(
|
||||
() =>
|
||||
storeEvents.map((e) => ({
|
||||
id: e.id,
|
||||
time: e.time,
|
||||
level: e.level,
|
||||
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 ?? '-',
|
||||
})),
|
||||
[storeEvents],
|
||||
);
|
||||
const {
|
||||
events: storeEvents,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
loadStats,
|
||||
} = useEventStore();
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
const filtered = levelFilter
|
||||
? EVENTS.filter((e) => e.level === levelFilter)
|
||||
: EVENTS;
|
||||
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 (
|
||||
<div className="p-5 space-y-4">
|
||||
@ -131,6 +156,7 @@ export function EventList() {
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
title="등급 필터"
|
||||
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-label focus:outline-none focus:border-blue-500/50"
|
||||
>
|
||||
<option value="">전체 등급</option>
|
||||
@ -141,6 +167,7 @@ export function EventList() {
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
@ -153,17 +180,17 @@ export function EventList() {
|
||||
{/* KPI 요약 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '전체', count: EVENTS.length, icon: Radar, color: 'text-label', bg: 'bg-muted' },
|
||||
{ label: 'CRITICAL', count: EVENTS.filter((e) => e.level === 'CRITICAL').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: 'HIGH', count: EVENTS.filter((e) => e.level === 'HIGH').length, icon: Eye, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
{ label: 'MEDIUM', count: EVENTS.filter((e) => e.level === 'MEDIUM').length, icon: Anchor, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: 'LOW', count: EVENTS.filter((e) => e.level === 'LOW').length, icon: Crosshair, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ 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.label === '전체' ? '' : kpi.label)}
|
||||
onClick={() => setLevelFilter(kpi.filterVal)}
|
||||
className={`flex items-center gap-2.5 p-3 rounded-xl border cursor-pointer transition-colors ${
|
||||
(kpi.label === '전체' && !levelFilter) || kpi.label === levelFilter
|
||||
(kpi.filterVal === '' && !levelFilter) || kpi.filterVal === levelFilter
|
||||
? 'bg-card border-blue-500/30'
|
||||
: 'bg-card border-border hover:border-border'
|
||||
}`}
|
||||
@ -179,12 +206,19 @@ export function EventList() {
|
||||
))}
|
||||
</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 onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
|
||||
<button type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -197,15 +231,25 @@ export function EventList() {
|
||||
</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 — 검색+정렬+페이징+엑셀+출력 */}
|
||||
<DataTable
|
||||
data={filtered}
|
||||
columns={columns}
|
||||
pageSize={10}
|
||||
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
||||
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
||||
exportFilename="이벤트목록"
|
||||
/>
|
||||
{!loading && (
|
||||
<DataTable
|
||||
data={EVENTS}
|
||||
columns={columns}
|
||||
pageSize={10}
|
||||
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
||||
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
||||
exportFilename="이벤트목록"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,78 +1,257 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { BarChart3, TrendingUp, Target, Calendar, Download, FileText } from 'lucide-react';
|
||||
import { BarChart3, Download } from 'lucide-react';
|
||||
import { BarChart, AreaChart } from '@lib/charts';
|
||||
import { useKpiStore } from '@stores/kpiStore';
|
||||
import {
|
||||
getMonthlyStats,
|
||||
toMonthlyTrend,
|
||||
toViolationTypes,
|
||||
type PredictionStatsMonthly,
|
||||
} from '@/services/kpi';
|
||||
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
||||
|
||||
/* SFR-13: 통계·지표·성과 분석 */
|
||||
|
||||
const KPI_DATA: { id: string; name: string; target: string; current: string; status: string; [key: string]: unknown }[] = [
|
||||
{ id: 'KPI-01', name: 'AI 탐지 정확도', target: '90%', current: '93.2%', status: '달성' },
|
||||
{ id: 'KPI-02', name: '오탐률', target: '≤10%', current: '7.8%', status: '달성' },
|
||||
{ id: 'KPI-03', name: '평균 리드타임', target: '≤15분', current: '12분', status: '달성' },
|
||||
{ id: 'KPI-04', name: '단속 성공률', target: '≥60%', current: '68%', status: '달성' },
|
||||
{ id: 'KPI-05', name: '경보 응답시간', target: '≤5분', current: '3.2분', status: '달성' },
|
||||
const KPI_DATA: {
|
||||
id: string;
|
||||
name: string;
|
||||
target: string;
|
||||
current: string;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}[] = [
|
||||
{
|
||||
id: 'KPI-01',
|
||||
name: 'AI 탐지 정확도',
|
||||
target: '90%',
|
||||
current: '93.2%',
|
||||
status: '달성',
|
||||
},
|
||||
{
|
||||
id: 'KPI-02',
|
||||
name: '오탐률',
|
||||
target: '≤10%',
|
||||
current: '7.8%',
|
||||
status: '달성',
|
||||
},
|
||||
{
|
||||
id: 'KPI-03',
|
||||
name: '평균 리드타임',
|
||||
target: '≤15분',
|
||||
current: '12분',
|
||||
status: '달성',
|
||||
},
|
||||
{
|
||||
id: 'KPI-04',
|
||||
name: '단속 성공률',
|
||||
target: '≥60%',
|
||||
current: '68%',
|
||||
status: '달성',
|
||||
},
|
||||
{
|
||||
id: 'KPI-05',
|
||||
name: '경보 응답시간',
|
||||
target: '≤5분',
|
||||
current: '3.2분',
|
||||
status: '달성',
|
||||
},
|
||||
];
|
||||
|
||||
const kpiCols: DataColumn<typeof KPI_DATA[0]>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '지표명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
const kpiCols: DataColumn<(typeof KPI_DATA)[0]>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '70px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: '지표명',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-heading font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{ key: 'target', label: '목표', width: '80px', align: 'center' },
|
||||
{ key: 'current', label: '현재', width: '80px', align: 'center', render: v => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '60px', align: 'center',
|
||||
render: v => <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{
|
||||
key: 'current',
|
||||
label: '현재',
|
||||
width: '80px',
|
||||
align: 'center',
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-bold">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
width: '60px',
|
||||
align: 'center',
|
||||
render: (v) => (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function Statistics() {
|
||||
const { t } = useTranslation('statistics');
|
||||
const kpiStore = useKpiStore();
|
||||
|
||||
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// MONTHLY: store monthly → xKey 'm'으로 필드명 매핑
|
||||
const MONTHLY = kpiStore.monthly.map((t) => ({
|
||||
m: t.month,
|
||||
enforce: t.enforce,
|
||||
detect: t.detect,
|
||||
accuracy: t.accuracy,
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStats() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
const formatDate = (d: Date) => d.toISOString().substring(0, 10);
|
||||
|
||||
const data: PredictionStatsMonthly[] = await getMonthlyStats(
|
||||
formatDate(from),
|
||||
formatDate(now),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setMonthly(data.map(toMonthlyTrend));
|
||||
setViolationTypes(toViolationTypes(data));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : '통계 데이터 로드 실패',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const MONTHLY = monthly.map((m) => ({
|
||||
m: m.month,
|
||||
enforce: m.enforce,
|
||||
detect: m.detect,
|
||||
accuracy: m.accuracy,
|
||||
}));
|
||||
|
||||
// BY_TYPE: store violationTypes 직접 사용
|
||||
const BY_TYPE = kpiStore.violationTypes;
|
||||
const BY_TYPE = violationTypes;
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><BarChart3 className="w-5 h-5 text-purple-400" />{t('statistics.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('statistics.desc')}</p>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-purple-400" />
|
||||
{t('statistics.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('statistics.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Download className="w-3 h-3" />보고서 생성</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading">
|
||||
<Download className="w-3 h-3" />
|
||||
보고서 생성
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">월별 단속·탐지 추이</div>
|
||||
<BarChart data={MONTHLY} xKey="m" height={200} series={[{ key: 'enforce', name: '단속', color: '#3b82f6' }, { key: 'detect', name: '탐지', color: '#8b5cf6' }]} />
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">AI 정확도 추이</div>
|
||||
<AreaChart data={MONTHLY} xKey="m" height={200} yAxisDomain={[75, 100]} series={[{ key: 'accuracy', name: '정확도 %', color: '#22c55e' }]} />
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">위반 유형별 분포</div>
|
||||
<div className="flex gap-3">{BY_TYPE.map(t => (
|
||||
<div key={t.type} className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg">
|
||||
<div className="text-lg font-bold text-heading">{t.count}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t.type}</div>
|
||||
<div className="text-[9px] text-hint">{t.pct}%</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
데이터를 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-10 text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">
|
||||
월별 단속·탐지 추이
|
||||
</div>
|
||||
<BarChart
|
||||
data={MONTHLY}
|
||||
xKey="m"
|
||||
height={200}
|
||||
series={[
|
||||
{ key: 'enforce', name: '단속', color: '#3b82f6' },
|
||||
{ key: 'detect', name: '탐지', color: '#8b5cf6' },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">
|
||||
AI 정확도 추이
|
||||
</div>
|
||||
<AreaChart
|
||||
data={MONTHLY}
|
||||
xKey="m"
|
||||
height={200}
|
||||
yAxisDomain={[75, 100]}
|
||||
series={[
|
||||
{ key: 'accuracy', name: '정확도 %', color: '#22c55e' },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
<DataTable data={KPI_DATA} columns={kpiCols} pageSize={10} title="핵심 성과 지표 (KPI)" searchPlaceholder="지표명 검색..." exportFilename="성과지표" />
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">
|
||||
위반 유형별 분포
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{BY_TYPE.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
|
||||
>
|
||||
<div className="text-lg font-bold text-heading">
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.type}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={KPI_DATA}
|
||||
columns={kpiCols}
|
||||
pageSize={10}
|
||||
title="핵심 성과 지표 (KPI)"
|
||||
searchPlaceholder="지표명 검색..."
|
||||
exportFilename="성과지표"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
153
frontend/src/services/enforcement.ts
Normal file
153
frontend/src/services/enforcement.ts
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 단속 이력/계획 API 서비스
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── 페이지 응답 공통 타입 ────────────────────────
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ─── 단속 기록 ────────────────────────────────────
|
||||
|
||||
export interface EnforcementRecord {
|
||||
id: number;
|
||||
enfUid: string;
|
||||
eventId: number | null;
|
||||
enforcedAt: string;
|
||||
zoneCode: string | null;
|
||||
areaName: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
vesselMmsi: string | null;
|
||||
vesselName: string | null;
|
||||
flagCountry: string | null;
|
||||
violationType: string | null;
|
||||
action: string;
|
||||
result: string | null;
|
||||
aiMatchStatus: string | null;
|
||||
aiConfidence: number | null;
|
||||
patrolShipId: number | null;
|
||||
enforcedByName: string | null;
|
||||
remarks: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateRecordRequest {
|
||||
eventId?: number;
|
||||
enforcedAt: string;
|
||||
zoneCode?: string;
|
||||
areaName?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
vesselMmsi?: string;
|
||||
vesselName?: string;
|
||||
flagCountry?: string;
|
||||
violationType?: string;
|
||||
action: string;
|
||||
result?: string;
|
||||
aiMatchStatus?: string;
|
||||
aiConfidence?: number;
|
||||
patrolShipId?: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
// ─── 단속 계획 ────────────────────────────────────
|
||||
|
||||
export interface EnforcementPlan {
|
||||
id: number;
|
||||
planUid: string;
|
||||
title: string;
|
||||
zoneCode: string | null;
|
||||
areaName: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
plannedDate: string;
|
||||
riskLevel: string | null;
|
||||
riskScore: number | null;
|
||||
assignedShipCount: number;
|
||||
assignedCrew: number;
|
||||
status: string;
|
||||
alertStatus: string | null;
|
||||
}
|
||||
|
||||
// ─── API 호출 ─────────────────────────────────────
|
||||
|
||||
export async function getEnforcementRecords(params?: {
|
||||
violationType?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PageResponse<EnforcementRecord>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.violationType) query.set('violationType', params.violationType);
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/enforcement/records?${query}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createEnforcementRecord(
|
||||
data: CreateRecordRequest,
|
||||
): Promise<EnforcementRecord> {
|
||||
const res = await fetch(`${API_BASE}/enforcement/records`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getEnforcementPlans(params?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PageResponse<EnforcementPlan>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/enforcement/plans?${query}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 하위 호환 헬퍼 (기존 mock 형식 → API 응답 매핑) ──
|
||||
|
||||
/** @deprecated EnforcementRecord를 직접 사용하세요 */
|
||||
export interface LegacyEnforcementRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
zone: string;
|
||||
vessel: string;
|
||||
violation: string;
|
||||
action: string;
|
||||
aiMatch: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
/** EnforcementRecord → LegacyEnforcementRecord 변환 */
|
||||
export function toLegacyRecord(r: EnforcementRecord): LegacyEnforcementRecord {
|
||||
return {
|
||||
id: r.enfUid,
|
||||
date: r.enforcedAt,
|
||||
zone: r.areaName ?? r.zoneCode ?? '-',
|
||||
vessel: r.vesselName ?? r.vesselMmsi ?? '-',
|
||||
violation: r.violationType ?? '-',
|
||||
action: r.action,
|
||||
aiMatch: r.aiMatchStatus === 'MATCH' ? '일치' : '불일치',
|
||||
result: r.result ?? '-',
|
||||
};
|
||||
}
|
||||
@ -1,15 +1,141 @@
|
||||
/**
|
||||
* 이벤트/경보 API 서비스
|
||||
* 이벤트/경보 API 서비스 — 실제 백엔드 연동
|
||||
*/
|
||||
import type { EventRecord, AlertRecord } from '@data/mock/events';
|
||||
import { MOCK_EVENTS, MOCK_ALERTS } from '@data/mock/events';
|
||||
|
||||
/** TODO: GET /api/v1/events */
|
||||
export async function getEvents(): Promise<EventRecord[]> {
|
||||
return MOCK_EVENTS;
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── 서버 응답 타입 ───────────────────────────────
|
||||
|
||||
export interface PredictionEvent {
|
||||
id: number;
|
||||
eventUid: string;
|
||||
occurredAt: string;
|
||||
level: string;
|
||||
category: string;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
vesselMmsi: string | null;
|
||||
vesselName: string | null;
|
||||
areaName: string | null;
|
||||
zoneCode: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
speedKn: number | null;
|
||||
sourceType: string | null;
|
||||
aiConfidence: number | null;
|
||||
status: string;
|
||||
assigneeId: string | null;
|
||||
assigneeName: string | null;
|
||||
ackedAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
resolutionNote: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** TODO: GET /api/v1/alerts */
|
||||
export async function getAlerts(): Promise<AlertRecord[]> {
|
||||
return MOCK_ALERTS;
|
||||
export interface EventPageResponse {
|
||||
content: PredictionEvent[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
[status: string]: number;
|
||||
}
|
||||
|
||||
// ─── API 호출 ─────────────────────────────────────
|
||||
|
||||
export async function getEvents(params?: {
|
||||
status?: string;
|
||||
level?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<EventPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.level) query.set('level', params.level);
|
||||
if (params?.category) query.set('category', params.category);
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/events?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getEventById(id: number): Promise<PredictionEvent> {
|
||||
const res = await fetch(`${API_BASE}/events/${id}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function ackEvent(id: number): Promise<PredictionEvent> {
|
||||
const res = await fetch(`${API_BASE}/events/${id}/ack`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateEventStatus(
|
||||
id: number,
|
||||
status: string,
|
||||
comment?: string,
|
||||
): Promise<PredictionEvent> {
|
||||
const res = await fetch(`${API_BASE}/events/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, comment }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getEventStats(): Promise<EventStats> {
|
||||
const res = await fetch(`${API_BASE}/events/stats`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 하위 호환 헬퍼 (기존 EventRecord 형식 → PredictionEvent 매핑) ──
|
||||
|
||||
/** @deprecated PredictionEvent를 직접 사용하세요 */
|
||||
export interface LegacyEventRecord {
|
||||
id: string;
|
||||
time: string;
|
||||
level: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
type: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
vesselName?: string;
|
||||
mmsi?: string;
|
||||
area?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
speed?: number;
|
||||
status?: string;
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
/** PredictionEvent → LegacyEventRecord 변환 */
|
||||
export function toLegacyEvent(e: PredictionEvent): LegacyEventRecord {
|
||||
return {
|
||||
id: e.eventUid,
|
||||
time: e.occurredAt,
|
||||
level: e.level as LegacyEventRecord['level'],
|
||||
type: e.category,
|
||||
title: e.title,
|
||||
detail: e.detail ?? '',
|
||||
vesselName: e.vesselName ?? undefined,
|
||||
mmsi: e.vesselMmsi ?? undefined,
|
||||
area: e.areaName ?? undefined,
|
||||
lat: e.lat ?? undefined,
|
||||
lng: e.lon ?? undefined,
|
||||
speed: e.speedKn ?? undefined,
|
||||
status: e.status ?? undefined,
|
||||
assignee: e.assigneeName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
export { apiGet, apiPost } from './api';
|
||||
export { getVessels, getSuspects, getVesselDetail } from './vessel';
|
||||
export { getEvents, getAlerts } from './event';
|
||||
export { getEvents, getEventById, ackEvent, updateEventStatus, getEventStats } from './event';
|
||||
export type { PredictionEvent, EventPageResponse, EventStats } from './event';
|
||||
export { getEnforcementRecords, createEnforcementRecord, getEnforcementPlans } from './enforcement';
|
||||
export type { EnforcementRecord, EnforcementPlan } from './enforcement';
|
||||
export { getPatrolShips } from './patrol';
|
||||
export { getKpiMetrics, getMonthlyTrends, getViolationTypes } from './kpi';
|
||||
export {
|
||||
getKpiMetrics,
|
||||
getMonthlyStats,
|
||||
toKpiMetric,
|
||||
toMonthlyTrend,
|
||||
toViolationTypes,
|
||||
} from './kpi';
|
||||
export type { PredictionKpi, PredictionStatsMonthly } from './kpi';
|
||||
export { connectWs } from './ws';
|
||||
|
||||
@ -1,20 +1,99 @@
|
||||
/**
|
||||
* KPI/통계 API 서비스
|
||||
* - 실제 백엔드 API 호출 (GET /api/stats/kpi, /api/stats/monthly)
|
||||
* - 하위 호환용 변환 헬퍼 제공
|
||||
*/
|
||||
import type { KpiMetric, MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
||||
import { MOCK_KPI_METRICS, MOCK_MONTHLY_TRENDS, MOCK_VIOLATION_TYPES } from '@data/mock/kpi';
|
||||
|
||||
/** TODO: GET /api/v1/kpi/metrics */
|
||||
export async function getKpiMetrics(): Promise<KpiMetric[]> {
|
||||
return MOCK_KPI_METRICS;
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── 백엔드 API 응답 타입 ───────────────────
|
||||
|
||||
export interface PredictionKpi {
|
||||
kpiKey: string;
|
||||
kpiLabel: string;
|
||||
value: number;
|
||||
trend: string | null; // 'up', 'down', 'flat'
|
||||
deltaPct: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** TODO: GET /api/v1/kpi/monthly */
|
||||
export async function getMonthlyTrends(): Promise<MonthlyTrend[]> {
|
||||
return MOCK_MONTHLY_TRENDS;
|
||||
export interface PredictionStatsMonthly {
|
||||
statMonth: string; // '2026-04-01' (DATE -> ISO string)
|
||||
totalDetections: number;
|
||||
totalEnforcements: number;
|
||||
byCategory: Record<string, number> | null;
|
||||
byZone: Record<string, number> | null;
|
||||
byRiskLevel: Record<string, number> | null;
|
||||
byGearType: Record<string, number> | null;
|
||||
byViolationType: Record<string, number> | null;
|
||||
eventCount: number;
|
||||
criticalEventCount: number;
|
||||
falsePositiveCount: number;
|
||||
aiAccuracyPct: number | null;
|
||||
}
|
||||
|
||||
/** TODO: GET /api/v1/kpi/violations */
|
||||
export async function getViolationTypes(): Promise<ViolationType[]> {
|
||||
return MOCK_VIOLATION_TYPES;
|
||||
// ─── API 호출 ───────────────────
|
||||
|
||||
export async function getKpiMetrics(): Promise<PredictionKpi[]> {
|
||||
const res = await fetch(`${API_BASE}/stats/kpi`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMonthlyStats(
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<PredictionStatsMonthly[]> {
|
||||
const res = await fetch(`${API_BASE}/stats/monthly?from=${from}&to=${to}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 하위 호환 변환 헬퍼 ───────────────────
|
||||
|
||||
/** PredictionKpi -> 기존 KpiMetric 형태로 변환 (Dashboard에서 사용) */
|
||||
export function toKpiMetric(kpi: PredictionKpi): KpiMetric {
|
||||
return {
|
||||
id: kpi.kpiKey,
|
||||
label: kpi.kpiLabel,
|
||||
value: kpi.value,
|
||||
prev: kpi.deltaPct
|
||||
? Math.round(kpi.value / (1 + kpi.deltaPct / 100))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** PredictionStatsMonthly -> MonthlyTrend 변환 */
|
||||
export function toMonthlyTrend(stat: PredictionStatsMonthly): MonthlyTrend {
|
||||
return {
|
||||
month: stat.statMonth.substring(0, 7), // '2026-04-01' -> '2026-04'
|
||||
enforce: stat.totalEnforcements ?? 0,
|
||||
detect: stat.totalDetections ?? 0,
|
||||
accuracy: stat.aiAccuracyPct ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** MonthlyStats의 byViolationType -> ViolationType[] 변환 (기간 합산) */
|
||||
export function toViolationTypes(
|
||||
stats: PredictionStatsMonthly[],
|
||||
): ViolationType[] {
|
||||
const totals: Record<string, number> = {};
|
||||
stats.forEach((s) => {
|
||||
if (s.byViolationType) {
|
||||
Object.entries(s.byViolationType).forEach(([k, v]) => {
|
||||
totals[k] = (totals[k] ?? 0) + (v as number);
|
||||
});
|
||||
}
|
||||
});
|
||||
const sum = Object.values(totals).reduce((a, b) => a + b, 0);
|
||||
return Object.entries(totals)
|
||||
.map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
pct: sum > 0 ? Math.round((count / sum) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
@ -1,26 +1,64 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
MOCK_ENFORCEMENT_RECORDS,
|
||||
MOCK_ENFORCEMENT_PLANS,
|
||||
getEnforcementRecords,
|
||||
toLegacyRecord,
|
||||
type EnforcementRecord,
|
||||
type EnforcementPlanRecord,
|
||||
} from '@/data/mock/enforcement';
|
||||
type LegacyEnforcementRecord,
|
||||
} from '@/services/enforcement';
|
||||
import type { EnforcementPlanRecord } from '@/data/mock/enforcement';
|
||||
|
||||
interface EnforcementStore {
|
||||
records: EnforcementRecord[];
|
||||
/** 원본 API 단속 기록 */
|
||||
rawRecords: EnforcementRecord[];
|
||||
/** 하위 호환용 레거시 형식 */
|
||||
records: LegacyEnforcementRecord[];
|
||||
/** 단속 계획 (아직 mock — EnforcementPlan.tsx에서 사용) */
|
||||
plans: EnforcementPlanRecord[];
|
||||
/** 페이지네이션 */
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
/** 로딩/에러 */
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
loaded: boolean;
|
||||
load: () => void;
|
||||
/** API 호출 */
|
||||
load: (params?: { violationType?: string; page?: number; size?: number }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useEnforcementStore = create<EnforcementStore>((set) => ({
|
||||
export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
|
||||
rawRecords: [],
|
||||
records: [],
|
||||
plans: [],
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
loaded: false,
|
||||
load: () =>
|
||||
set({
|
||||
records: MOCK_ENFORCEMENT_RECORDS,
|
||||
plans: MOCK_ENFORCEMENT_PLANS,
|
||||
loaded: true,
|
||||
}),
|
||||
|
||||
load: async (params) => {
|
||||
// 중복 호출 방지 (파라미터 없는 기본 호출은 loaded 체크)
|
||||
if (!params && get().loaded && !get().error) return;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const [res, planModule] = await Promise.all([
|
||||
getEnforcementRecords(params),
|
||||
// plans는 아직 mock 유지 (EnforcementPlan.tsx에서 사용)
|
||||
get().plans.length > 0
|
||||
? Promise.resolve(null)
|
||||
: import('@/data/mock/enforcement').then((m) => m.MOCK_ENFORCEMENT_PLANS),
|
||||
]);
|
||||
set({
|
||||
rawRecords: res.content,
|
||||
records: res.content.map(toLegacyRecord),
|
||||
plans: planModule ?? get().plans,
|
||||
totalElements: res.totalElements,
|
||||
totalPages: res.totalPages,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1,24 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import type { EventRecord, AlertRecord } from '@data/mock/events';
|
||||
import {
|
||||
getEvents,
|
||||
getEventStats,
|
||||
toLegacyEvent,
|
||||
type PredictionEvent,
|
||||
type EventStats,
|
||||
type LegacyEventRecord,
|
||||
} from '@/services/event';
|
||||
import type { AlertRecord } from '@data/mock/events';
|
||||
|
||||
/** @deprecated LegacyEventRecord 대신 PredictionEvent 사용 권장 */
|
||||
export type { LegacyEventRecord as EventRecord } from '@/services/event';
|
||||
|
||||
interface EventStore {
|
||||
events: EventRecord[];
|
||||
/** 원본 API 이벤트 목록 */
|
||||
rawEvents: PredictionEvent[];
|
||||
/** 하위 호환용 레거시 형식 이벤트 */
|
||||
events: LegacyEventRecord[];
|
||||
/** 알림 (아직 mock — AIAlert, MobileService에서 사용) */
|
||||
alerts: AlertRecord[];
|
||||
/** 상태별 통계 */
|
||||
stats: EventStats;
|
||||
/** 페이지네이션 */
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
/** 로딩/에러 */
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
loaded: boolean;
|
||||
load: () => void;
|
||||
filterByLevel: (level: EventRecord['level'] | null) => EventRecord[];
|
||||
/** API 호출 */
|
||||
load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
|
||||
loadStats: () => Promise<void>;
|
||||
filterByLevel: (level: string | null) => LegacyEventRecord[];
|
||||
}
|
||||
|
||||
export const useEventStore = create<EventStore>((set, get) => ({
|
||||
rawEvents: [],
|
||||
events: [],
|
||||
alerts: [],
|
||||
stats: {},
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
currentPage: 0,
|
||||
pageSize: 20,
|
||||
loading: false,
|
||||
error: null,
|
||||
loaded: false,
|
||||
|
||||
load: () => {
|
||||
if (get().loaded) return;
|
||||
import('@data/mock/events').then(({ MOCK_EVENTS, MOCK_ALERTS }) => {
|
||||
set({ events: MOCK_EVENTS, alerts: MOCK_ALERTS, loaded: true });
|
||||
});
|
||||
load: async (params) => {
|
||||
// 중복 호출 방지 (파라미터 없는 기본 호출은 loaded 체크)
|
||||
if (!params && get().loaded && !get().error) return;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const [res, alertModule] = await Promise.all([
|
||||
getEvents(params),
|
||||
// alerts는 아직 mock 유지 (다른 화면에서 사용)
|
||||
get().alerts.length > 0
|
||||
? Promise.resolve(null)
|
||||
: import('@data/mock/events').then((m) => m.MOCK_ALERTS),
|
||||
]);
|
||||
const legacy = res.content.map(toLegacyEvent);
|
||||
set({
|
||||
rawEvents: res.content,
|
||||
events: legacy,
|
||||
alerts: alertModule ?? get().alerts,
|
||||
totalElements: res.totalElements,
|
||||
totalPages: res.totalPages,
|
||||
currentPage: res.number,
|
||||
pageSize: res.size,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadStats: async () => {
|
||||
try {
|
||||
const stats = await getEventStats();
|
||||
set({ stats });
|
||||
} catch {
|
||||
// stats 로딩 실패는 무시 (KPI 카드만 빈 값)
|
||||
}
|
||||
},
|
||||
|
||||
filterByLevel: (level) => {
|
||||
|
||||
@ -1,31 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import type { KpiMetric, MonthlyTrend, ViolationType } from '@/data/mock/kpi';
|
||||
import {
|
||||
MOCK_KPI_METRICS,
|
||||
MOCK_MONTHLY_TRENDS,
|
||||
MOCK_VIOLATION_TYPES,
|
||||
type KpiMetric,
|
||||
type MonthlyTrend,
|
||||
type ViolationType,
|
||||
} from '@/data/mock/kpi';
|
||||
getKpiMetrics,
|
||||
getMonthlyStats,
|
||||
toKpiMetric,
|
||||
toMonthlyTrend,
|
||||
toViolationTypes,
|
||||
} from '@/services/kpi';
|
||||
|
||||
interface KpiStore {
|
||||
metrics: KpiMetric[];
|
||||
monthly: MonthlyTrend[];
|
||||
violationTypes: ViolationType[];
|
||||
loaded: boolean;
|
||||
load: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useKpiStore = create<KpiStore>((set) => ({
|
||||
export const useKpiStore = create<KpiStore>((set, get) => ({
|
||||
metrics: [],
|
||||
monthly: [],
|
||||
violationTypes: [],
|
||||
loaded: false,
|
||||
load: () =>
|
||||
set({
|
||||
metrics: MOCK_KPI_METRICS,
|
||||
monthly: MOCK_MONTHLY_TRENDS,
|
||||
violationTypes: MOCK_VIOLATION_TYPES,
|
||||
loaded: true,
|
||||
}),
|
||||
loading: false,
|
||||
error: null,
|
||||
load: async () => {
|
||||
if (get().loading) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
// 6개월 범위로 월별 통계 조회
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
const formatDate = (d: Date) => d.toISOString().substring(0, 10);
|
||||
|
||||
const [kpiData, monthlyData] = await Promise.all([
|
||||
getKpiMetrics(),
|
||||
getMonthlyStats(formatDate(from), formatDate(now)),
|
||||
]);
|
||||
|
||||
set({
|
||||
metrics: kpiData.map(toKpiMetric),
|
||||
monthly: monthlyData.map(toMonthlyTrend),
|
||||
violationTypes: toViolationTypes(monthlyData),
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'KPI 데이터 로드 실패',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user