From 4e6ac8645abbb035491db422bfaab3c91c26de8d Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 12:14:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20S5=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=E2=80=94=20EventList/Statistics/EnforcementHistory/Dashboard?= =?UTF-8?q?=20KPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이벤트 목록 (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) --- frontend/src/data/mock/enforcement.ts | 6 + frontend/src/data/mock/events.ts | 10 +- frontend/src/features/dashboard/Dashboard.tsx | 28 +- .../enforcement/EnforcementHistory.tsx | 168 +++++++++-- .../src/features/enforcement/EventList.tsx | 152 ++++++---- .../src/features/statistics/Statistics.tsx | 275 +++++++++++++++--- frontend/src/services/enforcement.ts | 153 ++++++++++ frontend/src/services/event.ts | 144 ++++++++- frontend/src/services/index.ts | 14 +- frontend/src/services/kpi.ts | 99 ++++++- frontend/src/stores/enforcementStore.ts | 64 +++- frontend/src/stores/eventStore.ts | 85 +++++- frontend/src/stores/kpiStore.ts | 57 +++- 13 files changed, 1061 insertions(+), 194 deletions(-) create mode 100644 frontend/src/services/enforcement.ts diff --git a/frontend/src/data/mock/enforcement.ts b/frontend/src/data/mock/enforcement.ts index 9e9b5af..d98892f 100644 --- a/frontend/src/data/mock/enforcement.ts +++ b/frontend/src/data/mock/enforcement.ts @@ -1,3 +1,9 @@ +/** + * @deprecated EnforcementHistory는 실제 API로 전환 완료. + * EnforcementPlan.tsx가 아직 MOCK_ENFORCEMENT_PLANS를 참조하므로 삭제하지 마세요. + */ + +/** @deprecated services/enforcement.ts의 EnforcementRecord 사용 권장 */ export interface EnforcementRecord { id: string; date: string; diff --git a/frontend/src/data/mock/events.ts b/frontend/src/data/mock/events.ts index 8057d2b..c4e1e7c 100644 --- a/frontend/src/data/mock/events.ts +++ b/frontend/src/data/mock/events.ts @@ -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 유지 */ // ──────────────────────────────────────────── diff --git a/frontend/src/features/dashboard/Dashboard.tsx b/frontend/src/features/dashboard/Dashboard.tsx index bf747ae..c4ba30d 100644 --- a/frontend/src/features/dashboard/Dashboard.tsx +++ b/frontend/src/features/dashboard/Dashboard.tsx @@ -26,7 +26,7 @@ const ALERT_COLORS: Record = { '실시간 탐지': { icon: Radar, color: '#3b82f6' }, 'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' }, @@ -34,6 +34,13 @@ const KPI_UI_MAP: Record = { '불법환적 의심': { 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, diff --git a/frontend/src/features/enforcement/EnforcementHistory.tsx b/frontend/src/features/enforcement/EnforcementHistory.tsx index f695d25..5b20f37 100644 --- a/frontend/src/features/enforcement/EnforcementHistory.tsx +++ b/frontend/src/features/enforcement/EnforcementHistory.tsx @@ -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[] = [ - { key: 'id', label: 'ID', width: '80px', render: v => {v as string} }, - { key: 'date', label: '일시', width: '130px', sortable: true, render: v => {v as string} }, + { + key: 'id', + label: 'ID', + width: '80px', + render: (v) => ( + {v as string} + ), + }, + { + key: 'date', + label: '일시', + width: '130px', + sortable: true, + render: (v) => ( + + {v as string} + + ), + }, { key: 'zone', label: '해역', width: '90px', sortable: true }, - { key: 'vessel', label: '대상 선박', sortable: true, render: v => {v as string} }, - { key: 'violation', label: '위반 내용', width: '100px', sortable: true, render: v => {v as string} }, + { + key: 'vessel', + label: '대상 선박', + sortable: true, + render: (v) => ( + {v as string} + ), + }, + { + key: 'violation', + label: '위반 내용', + width: '100px', + sortable: true, + render: (v) => ( + + {v as string} + + ), + }, { key: 'action', label: '조치', width: '90px' }, - { key: 'aiMatch', label: 'AI 매칭', width: '70px', align: 'center', - render: v => { const m = v as string; return m === '일치' ? : ; } }, - { 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 {r}; } }, + { + key: 'aiMatch', + label: 'AI 매칭', + width: '70px', + align: 'center', + render: (v) => { + const m = v as string; + return m === '일치' ? ( + + ) : ( + + ); + }, + }, + { + 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 ( + {r} + ); + }, + }, ]; 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 (
-

{t('history.title')}

+

+ + {t('history.title')} +

{t('history.desc')}

+ + {/* KPI 카드 */}
- {[{ 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 => ( -
- {k.v}{k.l} + {[ + { 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) => ( +
+ {k.v} + {k.l}
))}
- + + {/* 에러 표시 */} + {error && ( +
+ 데이터 로딩 실패: {error} +
+ )} + + {/* 로딩 인디케이터 */} + {loading && ( +
+ + + 로딩 중... + +
+ )} + + {/* DataTable */} + {!loading && ( + + )}
); } diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 2fc63f7..9be5547 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -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 = { LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, }; -// ─── EventRecord is now loaded from useEventStore ─── +const STATUS_COLORS: Record = { + 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[] = [ +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[] = [ { key: 'level', label: '등급', width: '70px', sortable: true, render: (val) => { const lv = val as AlertLevel; const s = LEVEL_STYLES[lv]; - return {lv}; + return {lv}; }, }, { key: 'time', label: '발생시간', width: '140px', sortable: true, @@ -69,11 +83,7 @@ const columns: DataColumn[] = [ 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 {s}; + return {s}; }, }, { key: 'assignee', label: '담당', width: '70px' }, @@ -81,35 +91,50 @@ const columns: DataColumn[] = [ 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(''); 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 (
@@ -131,6 +156,7 @@ export function EventList() {
@@ -197,15 +231,25 @@ export function EventList() { )} + {/* 로딩 인디케이터 */} + {loading && ( +
+ + 로딩 중... +
+ )} + {/* DataTable — 검색+정렬+페이징+엑셀+출력 */} - + {!loading && ( + + )} ); } diff --git a/frontend/src/features/statistics/Statistics.tsx b/frontend/src/features/statistics/Statistics.tsx index d37d82c..8468f66 100644 --- a/frontend/src/features/statistics/Statistics.tsx +++ b/frontend/src/features/statistics/Statistics.tsx @@ -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[] = [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'name', label: '지표명', sortable: true, render: v => {v as string} }, +const kpiCols: DataColumn<(typeof KPI_DATA)[0]>[] = [ + { + key: 'id', + label: 'ID', + width: '70px', + render: (v) => ( + {v as string} + ), + }, + { + key: 'name', + label: '지표명', + sortable: true, + render: (v) => ( + {v as string} + ), + }, { key: 'target', label: '목표', width: '80px', align: 'center' }, - { key: 'current', label: '현재', width: '80px', align: 'center', render: v => {v as string} }, - { key: 'status', label: '상태', width: '60px', align: 'center', - render: v => {v as string} }, + { + key: 'current', + label: '현재', + width: '80px', + align: 'center', + render: (v) => ( + {v as string} + ), + }, + { + key: 'status', + label: '상태', + width: '60px', + align: 'center', + render: (v) => ( + + {v as string} + + ), + }, ]; export function Statistics() { const { t } = useTranslation('statistics'); - const kpiStore = useKpiStore(); - useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]); + const [monthly, setMonthly] = useState([]); + const [violationTypes, setViolationTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 (
-

{t('statistics.title')}

-

{t('statistics.desc')}

+

+ + {t('statistics.title')} +

+

+ {t('statistics.desc')} +

- +
-
- -
월별 단속·탐지 추이
- -
- -
AI 정확도 추이
- -
-
- -
위반 유형별 분포
-
{BY_TYPE.map(t => ( -
-
{t.count}
-
{t.type}
-
{t.pct}%
+ + {loading && ( +
+ 데이터를 불러오는 중... +
+ )} + + {error && ( +
{error}
+ )} + + {!loading && !error && ( + <> +
+ + +
+ 월별 단속·탐지 추이 +
+ +
+
+ + +
+ AI 정확도 추이 +
+ +
+
- ))}
- - + + +
+ 위반 유형별 분포 +
+
+ {BY_TYPE.map((item) => ( +
+
+ {item.count} +
+
+ {item.type} +
+
{item.pct}%
+
+ ))} +
+
+
+ + )} + +
); } diff --git a/frontend/src/services/enforcement.ts b/frontend/src/services/enforcement.ts new file mode 100644 index 0000000..16475cd --- /dev/null +++ b/frontend/src/services/enforcement.ts @@ -0,0 +1,153 @@ +/** + * 단속 이력/계획 API 서비스 + */ + +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +// ─── 페이지 응답 공통 타입 ──────────────────────── + +export interface PageResponse { + 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> { + 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 { + 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> { + 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 ?? '-', + }; +} diff --git a/frontend/src/services/event.ts b/frontend/src/services/event.ts index 990cc4e..e7e77ab 100644 --- a/frontend/src/services/event.ts +++ b/frontend/src/services/event.ts @@ -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 { - 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 { - 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; } diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 49070bc..cb71484 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -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'; diff --git a/frontend/src/services/kpi.ts b/frontend/src/services/kpi.ts index 0a38c90..394f60c 100644 --- a/frontend/src/services/kpi.ts +++ b/frontend/src/services/kpi.ts @@ -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 { - 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 { - return MOCK_MONTHLY_TRENDS; +export interface PredictionStatsMonthly { + statMonth: string; // '2026-04-01' (DATE -> ISO string) + totalDetections: number; + totalEnforcements: number; + byCategory: Record | null; + byZone: Record | null; + byRiskLevel: Record | null; + byGearType: Record | null; + byViolationType: Record | null; + eventCount: number; + criticalEventCount: number; + falsePositiveCount: number; + aiAccuracyPct: number | null; } -/** TODO: GET /api/v1/kpi/violations */ -export async function getViolationTypes(): Promise { - return MOCK_VIOLATION_TYPES; +// ─── API 호출 ─────────────────── + +export async function getKpiMetrics(): Promise { + 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 { + 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 = {}; + 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); } diff --git a/frontend/src/stores/enforcementStore.ts b/frontend/src/stores/enforcementStore.ts index aed86ca..ed88bfc 100644 --- a/frontend/src/stores/enforcementStore.ts +++ b/frontend/src/stores/enforcementStore.ts @@ -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; } -export const useEnforcementStore = create((set) => ({ +export const useEnforcementStore = create((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 }); + } + }, })); diff --git a/frontend/src/stores/eventStore.ts b/frontend/src/stores/eventStore.ts index 2d9c911..a94b47e 100644 --- a/frontend/src/stores/eventStore.ts +++ b/frontend/src/stores/eventStore.ts @@ -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; + loadStats: () => Promise; + filterByLevel: (level: string | null) => LegacyEventRecord[]; } export const useEventStore = create((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) => { diff --git a/frontend/src/stores/kpiStore.ts b/frontend/src/stores/kpiStore.ts index 634ec59..fd9f9df 100644 --- a/frontend/src/stores/kpiStore.ts +++ b/frontend/src/stores/kpiStore.ts @@ -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; } -export const useKpiStore = create((set) => ({ +export const useKpiStore = create((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, + }); + } + }, }));