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 {
|
export interface EnforcementRecord {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
|
* @deprecated EventList, Dashboard, MonitoringDashboard는 실제 API로 전환 완료.
|
||||||
|
* 아직 AIAlert, MobileService가 AlertRecord mock을 참조하므로 삭제하지 마세요.
|
||||||
|
*
|
||||||
* Shared mock data: events & alerts
|
* Shared mock data: events & alerts
|
||||||
*
|
*
|
||||||
* Sources:
|
* Sources:
|
||||||
* - EventList.tsx EVENTS (15 records) — primary
|
* - AIAlert.tsx DATA (5 alerts) — mock 유지
|
||||||
* - Dashboard.tsx TIMELINE_EVENTS (10)
|
* - MobileService.tsx ALERTS (3) — mock 유지
|
||||||
* - MonitoringDashboard.tsx EVENTS (6)
|
|
||||||
* - AIAlert.tsx DATA (5 alerts)
|
|
||||||
* - MobileService.tsx ALERTS (3)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ────────────────────────────────────────────
|
// ────────────────────────────────────────────
|
||||||
|
|||||||
@ -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' },
|
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 }> = {
|
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||||
@ -34,6 +34,13 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
|||||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
'나포/검문': { 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 (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
|
||||||
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
||||||
|
|
||||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => ({
|
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
|
||||||
label: m.label,
|
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
|
||||||
value: m.value,
|
return {
|
||||||
prev: m.prev ?? 0,
|
label: m.label,
|
||||||
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
value: m.value,
|
||||||
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
prev: m.prev ?? 0,
|
||||||
desc: m.description ?? '',
|
icon: ui.icon,
|
||||||
})), [kpiStore.metrics]);
|
color: ui.color,
|
||||||
|
desc: m.description ?? '',
|
||||||
|
};
|
||||||
|
}), [kpiStore.metrics]);
|
||||||
|
|
||||||
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
|
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,
|
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||||
|
|||||||
@ -1,48 +1,178 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
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';
|
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>[] = [
|
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: '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: '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: 'aiMatch',
|
||||||
{ key: 'result', label: '결과', width: '80px', align: 'center', sortable: true,
|
label: 'AI 매칭',
|
||||||
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>; } },
|
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() {
|
export function EnforcementHistory() {
|
||||||
const { t } = useTranslation('enforcement');
|
const { t } = useTranslation('enforcement');
|
||||||
const { records, load } = useEnforcementStore();
|
const { records, loading, error, load } = useEnforcementStore();
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const DATA: Record[] = records as Record[];
|
const DATA: Record[] = records as Record[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 카드 */}
|
||||||
<div className="flex gap-2">
|
<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">
|
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
|
||||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
{
|
||||||
|
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>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { FileUpload } from '@shared/components/common/FileUpload';
|
import { FileUpload } from '@shared/components/common/FileUpload';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Ship, Eye, Anchor, Radar, Crosshair,
|
AlertTriangle, Eye, Anchor, Radar, Crosshair,
|
||||||
Filter, Upload, X,
|
Filter, Upload, X, Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEventStore } from '@stores/eventStore';
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
||||||
* DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload
|
* DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload
|
||||||
|
* 실제 백엔드 API 연동
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
|
||||||
interface EventRecord {
|
interface EventRow {
|
||||||
id: string;
|
id: string;
|
||||||
time: string;
|
time: string;
|
||||||
level: AlertLevel;
|
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' },
|
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,
|
key: 'level', label: '등급', width: '70px', sortable: true,
|
||||||
render: (val) => {
|
render: (val) => {
|
||||||
const lv = val as AlertLevel;
|
const lv = val as AlertLevel;
|
||||||
const s = LEVEL_STYLES[lv];
|
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,
|
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
|
||||||
@ -69,11 +83,7 @@ const columns: DataColumn<EventRecord>[] = [
|
|||||||
key: 'status', label: '처리상태', width: '80px', sortable: true,
|
key: 'status', label: '처리상태', width: '80px', sortable: true,
|
||||||
render: (val) => {
|
render: (val) => {
|
||||||
const s = val as string;
|
const s = val as string;
|
||||||
const color = s === '완료' || s === '확인 완료' || s === '경고 완료' ? 'bg-green-500/20 text-green-400'
|
return <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
|
||||||
: 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>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'assignee', label: '담당', width: '70px' },
|
{ key: 'assignee', label: '담당', width: '70px' },
|
||||||
@ -81,35 +91,50 @@ const columns: DataColumn<EventRecord>[] = [
|
|||||||
|
|
||||||
export function EventList() {
|
export function EventList() {
|
||||||
const { t } = useTranslation('enforcement');
|
const { t } = useTranslation('enforcement');
|
||||||
const { events: storeEvents, loaded, load } = useEventStore();
|
const {
|
||||||
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
events: storeEvents,
|
||||||
|
stats,
|
||||||
// Map store EventRecord to local EventRecord shape (string lat/lng/speed)
|
loading,
|
||||||
const EVENTS: EventRecord[] = useMemo(
|
error,
|
||||||
() =>
|
load,
|
||||||
storeEvents.map((e) => ({
|
loadStats,
|
||||||
id: e.id,
|
} = useEventStore();
|
||||||
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 [levelFilter, setLevelFilter] = useState<string>('');
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
const filtered = levelFilter
|
const fetchData = useCallback(() => {
|
||||||
? EVENTS.filter((e) => e.level === levelFilter)
|
const params = levelFilter ? { level: levelFilter } : undefined;
|
||||||
: EVENTS;
|
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 (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
@ -131,6 +156,7 @@ export function EventList() {
|
|||||||
<select
|
<select
|
||||||
value={levelFilter}
|
value={levelFilter}
|
||||||
onChange={(e) => setLevelFilter(e.target.value)}
|
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"
|
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>
|
<option value="">전체 등급</option>
|
||||||
@ -141,6 +167,7 @@ export function EventList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowUpload(!showUpload)}
|
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"
|
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 요약 */}
|
{/* KPI 요약 */}
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: '전체', count: EVENTS.length, icon: Radar, color: 'text-label', bg: 'bg-muted' },
|
{ label: '전체', count: kpiTotal, icon: Radar, color: 'text-label', bg: 'bg-muted', filterVal: '' },
|
||||||
{ label: 'CRITICAL', count: EVENTS.filter((e) => e.level === 'CRITICAL').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: 'CRITICAL', count: kpiCritical, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10', filterVal: 'CRITICAL' },
|
||||||
{ label: 'HIGH', count: EVENTS.filter((e) => e.level === 'HIGH').length, icon: Eye, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
{ label: 'HIGH', count: kpiHigh, icon: Eye, color: 'text-orange-400', bg: 'bg-orange-500/10', filterVal: 'HIGH' },
|
||||||
{ label: 'MEDIUM', count: EVENTS.filter((e) => e.level === 'MEDIUM').length, icon: Anchor, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
{ label: 'MEDIUM', count: kpiMedium, icon: Anchor, color: 'text-yellow-400', bg: 'bg-yellow-500/10', filterVal: 'MEDIUM' },
|
||||||
{ label: 'LOW', count: EVENTS.filter((e) => e.level === 'LOW').length, icon: Crosshair, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: 'LOW', count: kpiLow, icon: Crosshair, color: 'text-blue-400', bg: 'bg-blue-500/10', filterVal: 'LOW' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<div
|
<div
|
||||||
key={kpi.label}
|
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 ${
|
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-blue-500/30'
|
||||||
: 'bg-card border-border hover:border-border'
|
: 'bg-card border-border hover:border-border'
|
||||||
}`}
|
}`}
|
||||||
@ -179,12 +206,19 @@ export function EventList() {
|
|||||||
))}
|
))}
|
||||||
</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 && (
|
{showUpload && (
|
||||||
<div className="rounded-xl border border-border bg-card p-4">
|
<div className="rounded-xl border border-border bg-card p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[11px] text-label font-bold">이벤트 데이터 업로드</span>
|
<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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -197,15 +231,25 @@ export function EventList() {
|
|||||||
</div>
|
</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 — 검색+정렬+페이징+엑셀+출력 */}
|
||||||
<DataTable
|
{!loading && (
|
||||||
data={filtered}
|
<DataTable
|
||||||
columns={columns}
|
data={EVENTS}
|
||||||
pageSize={10}
|
columns={columns}
|
||||||
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
pageSize={10}
|
||||||
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
||||||
exportFilename="이벤트목록"
|
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
||||||
/>
|
exportFilename="이벤트목록"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,78 +1,257 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
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 { 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: 통계·지표·성과 분석 */
|
/* SFR-13: 통계·지표·성과 분석 */
|
||||||
|
|
||||||
const KPI_DATA: { id: string; name: string; target: string; current: string; status: string; [key: string]: unknown }[] = [
|
const KPI_DATA: {
|
||||||
{ id: 'KPI-01', name: 'AI 탐지 정확도', target: '90%', current: '93.2%', status: '달성' },
|
id: string;
|
||||||
{ id: 'KPI-02', name: '오탐률', target: '≤10%', current: '7.8%', status: '달성' },
|
name: string;
|
||||||
{ id: 'KPI-03', name: '평균 리드타임', target: '≤15분', current: '12분', status: '달성' },
|
target: string;
|
||||||
{ id: 'KPI-04', name: '단속 성공률', target: '≥60%', current: '68%', status: '달성' },
|
current: string;
|
||||||
{ id: 'KPI-05', name: '경보 응답시간', target: '≤5분', current: '3.2분', status: '달성' },
|
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]>[] = [
|
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: '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: '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',
|
key: 'current',
|
||||||
render: v => <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">{v as string}</Badge> },
|
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() {
|
export function Statistics() {
|
||||||
const { t } = useTranslation('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'으로 필드명 매핑
|
useEffect(() => {
|
||||||
const MONTHLY = kpiStore.monthly.map((t) => ({
|
let cancelled = false;
|
||||||
m: t.month,
|
|
||||||
enforce: t.enforce,
|
async function loadStats() {
|
||||||
detect: t.detect,
|
setLoading(true);
|
||||||
accuracy: t.accuracy,
|
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 = violationTypes;
|
||||||
const BY_TYPE = kpiStore.violationTypes;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<p className="text-[10px] text-hint mt-0.5">{t('statistics.desc')}</p>
|
<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>
|
</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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Card><CardContent className="p-4">
|
{loading && (
|
||||||
<div className="text-[12px] font-bold text-label mb-3">월별 단속·탐지 추이</div>
|
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||||
<BarChart data={MONTHLY} xKey="m" height={200} series={[{ key: 'enforce', name: '단속', color: '#3b82f6' }, { key: 'detect', name: '탐지', color: '#8b5cf6' }]} />
|
데이터를 불러오는 중...
|
||||||
</CardContent></Card>
|
</div>
|
||||||
<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' }]} />
|
{error && (
|
||||||
</CardContent></Card>
|
<div className="text-center py-10 text-red-400 text-sm">{error}</div>
|
||||||
</div>
|
)}
|
||||||
<Card><CardContent className="p-4">
|
|
||||||
<div className="text-[12px] font-bold text-label mb-3">위반 유형별 분포</div>
|
{!loading && !error && (
|
||||||
<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="grid grid-cols-2 gap-3">
|
||||||
<div className="text-lg font-bold text-heading">{t.count}</div>
|
<Card>
|
||||||
<div className="text-[10px] text-muted-foreground">{t.type}</div>
|
<CardContent className="p-4">
|
||||||
<div className="text-[9px] text-hint">{t.pct}%</div>
|
<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>
|
||||||
))}</div>
|
<Card>
|
||||||
</CardContent></Card>
|
<CardContent className="p-4">
|
||||||
<DataTable data={KPI_DATA} columns={kpiCols} pageSize={10} title="핵심 성과 지표 (KPI)" searchPlaceholder="지표명 검색..." exportFilename="성과지표" />
|
<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>
|
</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 */
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
export async function getEvents(): Promise<EventRecord[]> {
|
|
||||||
return MOCK_EVENTS;
|
// ─── 서버 응답 타입 ───────────────────────────────
|
||||||
|
|
||||||
|
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 interface EventPageResponse {
|
||||||
export async function getAlerts(): Promise<AlertRecord[]> {
|
content: PredictionEvent[];
|
||||||
return MOCK_ALERTS;
|
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 { apiGet, apiPost } from './api';
|
||||||
export { getVessels, getSuspects, getVesselDetail } from './vessel';
|
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 { 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';
|
export { connectWs } from './ws';
|
||||||
|
|||||||
@ -1,20 +1,99 @@
|
|||||||
/**
|
/**
|
||||||
* KPI/통계 API 서비스
|
* KPI/통계 API 서비스
|
||||||
|
* - 실제 백엔드 API 호출 (GET /api/stats/kpi, /api/stats/monthly)
|
||||||
|
* - 하위 호환용 변환 헬퍼 제공
|
||||||
*/
|
*/
|
||||||
import type { KpiMetric, MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
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 */
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
export async function getKpiMetrics(): Promise<KpiMetric[]> {
|
|
||||||
return MOCK_KPI_METRICS;
|
// ─── 백엔드 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 interface PredictionStatsMonthly {
|
||||||
export async function getMonthlyTrends(): Promise<MonthlyTrend[]> {
|
statMonth: string; // '2026-04-01' (DATE -> ISO string)
|
||||||
return MOCK_MONTHLY_TRENDS;
|
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 */
|
// ─── API 호출 ───────────────────
|
||||||
export async function getViolationTypes(): Promise<ViolationType[]> {
|
|
||||||
return MOCK_VIOLATION_TYPES;
|
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 { create } from 'zustand';
|
||||||
import {
|
import {
|
||||||
MOCK_ENFORCEMENT_RECORDS,
|
getEnforcementRecords,
|
||||||
MOCK_ENFORCEMENT_PLANS,
|
toLegacyRecord,
|
||||||
type EnforcementRecord,
|
type EnforcementRecord,
|
||||||
type EnforcementPlanRecord,
|
type LegacyEnforcementRecord,
|
||||||
} from '@/data/mock/enforcement';
|
} from '@/services/enforcement';
|
||||||
|
import type { EnforcementPlanRecord } from '@/data/mock/enforcement';
|
||||||
|
|
||||||
interface EnforcementStore {
|
interface EnforcementStore {
|
||||||
records: EnforcementRecord[];
|
/** 원본 API 단속 기록 */
|
||||||
|
rawRecords: EnforcementRecord[];
|
||||||
|
/** 하위 호환용 레거시 형식 */
|
||||||
|
records: LegacyEnforcementRecord[];
|
||||||
|
/** 단속 계획 (아직 mock — EnforcementPlan.tsx에서 사용) */
|
||||||
plans: EnforcementPlanRecord[];
|
plans: EnforcementPlanRecord[];
|
||||||
|
/** 페이지네이션 */
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
/** 로딩/에러 */
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
loaded: boolean;
|
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: [],
|
records: [],
|
||||||
plans: [],
|
plans: [],
|
||||||
|
totalElements: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: () =>
|
|
||||||
set({
|
load: async (params) => {
|
||||||
records: MOCK_ENFORCEMENT_RECORDS,
|
// 중복 호출 방지 (파라미터 없는 기본 호출은 loaded 체크)
|
||||||
plans: MOCK_ENFORCEMENT_PLANS,
|
if (!params && get().loaded && !get().error) return;
|
||||||
loaded: true,
|
|
||||||
}),
|
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 { 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 {
|
interface EventStore {
|
||||||
events: EventRecord[];
|
/** 원본 API 이벤트 목록 */
|
||||||
|
rawEvents: PredictionEvent[];
|
||||||
|
/** 하위 호환용 레거시 형식 이벤트 */
|
||||||
|
events: LegacyEventRecord[];
|
||||||
|
/** 알림 (아직 mock — AIAlert, MobileService에서 사용) */
|
||||||
alerts: AlertRecord[];
|
alerts: AlertRecord[];
|
||||||
|
/** 상태별 통계 */
|
||||||
|
stats: EventStats;
|
||||||
|
/** 페이지네이션 */
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
/** 로딩/에러 */
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
load: () => void;
|
/** API 호출 */
|
||||||
filterByLevel: (level: EventRecord['level'] | null) => EventRecord[];
|
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) => ({
|
export const useEventStore = create<EventStore>((set, get) => ({
|
||||||
|
rawEvents: [],
|
||||||
events: [],
|
events: [],
|
||||||
alerts: [],
|
alerts: [],
|
||||||
|
stats: {},
|
||||||
|
totalElements: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
load: () => {
|
load: async (params) => {
|
||||||
if (get().loaded) return;
|
// 중복 호출 방지 (파라미터 없는 기본 호출은 loaded 체크)
|
||||||
import('@data/mock/events').then(({ MOCK_EVENTS, MOCK_ALERTS }) => {
|
if (!params && get().loaded && !get().error) return;
|
||||||
set({ events: MOCK_EVENTS, alerts: MOCK_ALERTS, loaded: true });
|
|
||||||
});
|
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) => {
|
filterByLevel: (level) => {
|
||||||
|
|||||||
@ -1,31 +1,56 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { KpiMetric, MonthlyTrend, ViolationType } from '@/data/mock/kpi';
|
||||||
import {
|
import {
|
||||||
MOCK_KPI_METRICS,
|
getKpiMetrics,
|
||||||
MOCK_MONTHLY_TRENDS,
|
getMonthlyStats,
|
||||||
MOCK_VIOLATION_TYPES,
|
toKpiMetric,
|
||||||
type KpiMetric,
|
toMonthlyTrend,
|
||||||
type MonthlyTrend,
|
toViolationTypes,
|
||||||
type ViolationType,
|
} from '@/services/kpi';
|
||||||
} from '@/data/mock/kpi';
|
|
||||||
|
|
||||||
interface KpiStore {
|
interface KpiStore {
|
||||||
metrics: KpiMetric[];
|
metrics: KpiMetric[];
|
||||||
monthly: MonthlyTrend[];
|
monthly: MonthlyTrend[];
|
||||||
violationTypes: ViolationType[];
|
violationTypes: ViolationType[];
|
||||||
loaded: boolean;
|
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: [],
|
metrics: [],
|
||||||
monthly: [],
|
monthly: [],
|
||||||
violationTypes: [],
|
violationTypes: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: () =>
|
loading: false,
|
||||||
set({
|
error: null,
|
||||||
metrics: MOCK_KPI_METRICS,
|
load: async () => {
|
||||||
monthly: MOCK_MONTHLY_TRENDS,
|
if (get().loading) return;
|
||||||
violationTypes: MOCK_VIOLATION_TYPES,
|
set({ loading: true, error: null });
|
||||||
loaded: true,
|
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