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:
htlee 2026-04-07 12:14:53 +09:00
부모 b70ef399b5
커밋 4e6ac8645a
13개의 변경된 파일1061개의 추가작업 그리고 194개의 파일을 삭제

파일 보기

@ -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>
);
}

파일 보기

@ -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,
});
}
},
}));