From 19b16131575eaab2e562f372f9b9ecb84789ce81 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 15:36:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=A0=84?= =?UTF-8?q?=EC=88=98=20mock=20=EC=A0=95=EB=A6=AC=20+=20UTC=E2=86=92KST=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20+=20i18n=20=EC=88=98=EC=A0=95=20+=20stats?= =?UTF-8?q?=20hourly=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 시간 표시 KST 통일 - shared/utils/dateFormat.ts 공통 유틸 신규 (formatDateTime/formatDate/formatTime/toDateParam) - 14개 파일에서 인라인 toLocaleString → 공통 유틸 교체 ## i18n 'group.parentInference' 사이드바 미번역 수정 - ko/en common.json의 'group' 키 중복 정의를 병합 (95행 두번째 group 객체가 35행을 덮어써서 parentInference 누락) ## Dashboard/MonitoringDashboard/Statistics 더미→실 API - 백엔드 GET /api/stats/hourly 신규 (PredictionStatsHourly 엔티티/리포지토리) - Dashboard: HOURLY_DETECTION/VESSEL_TYPE/AREA_RISK 하드코딩 제거 → getHourlyStats(24) + getDailyStats(today) 결과로 useMemo 변환 - MonitoringDashboard: TREND Math.random() 제거 → getHourlyStats 기반 위험도 가중평균 + 경보 카운트 - Statistics: KPI_DATA 하드코딩 제거 → getKpiMetrics() 결과를 표 행으로 ## Store mock 의존성 제거 - eventStore.alerts/MOCK_ALERTS 제거 (MobileService는 events에서 직접 추출) - enforcementStore.plans 제거 (EnforcementPlan은 이미 직접 API 호출) - transferStore + MOCK_TRANSFERS 완전 제거 (ChinaFishing/TransferDetection은 RealTransshipSuspects 컴포넌트 사용) - mock/events.ts, mock/enforcement.ts, mock/transfers.ts 파일 삭제 ## RiskMap 랜덤 격자 제거 - generateGrid() Math.random() 제거 → 빈 배열 + 'AI 분석 데이터 수집 중' 안내 - MTIS 외부 통계 5개 탭에 [MTIS 외부 통계] 배지 추가 ## 12개 mock 화면에 '데모 데이터' 노란색 배지 추가 - patrol/PatrolRoute, FleetOptimization - admin/AdminPanel, DataHub, NoticeManagement, SystemConfig - ai-operations/AIModelManagement, MLOpsPage - field-ops/ShipAgent - statistics/ReportManagement, ExternalService - surveillance/MapControl ## 백엔드 NUMERIC precision 동기화 - PredictionKpi.deltaPct: 5,2 → 12,2 - PredictionStatsDaily/Monthly.aiAccuracyPct: 5,2 → 12,2 - (V015 마이그레이션과 동기화) 44 files changed, +346 / -787 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mda/kcg/domain/stats/PredictionKpi.java | 2 +- .../domain/stats/PredictionStatsDaily.java | 2 +- .../domain/stats/PredictionStatsHourly.java | 43 +++ .../PredictionStatsHourlyRepository.java | 10 + .../domain/stats/PredictionStatsMonthly.java | 2 +- .../mda/kcg/domain/stats/StatsController.java | 16 + frontend/src/data/mock/enforcement.ts | 48 --- frontend/src/data/mock/events.ts | 290 ------------------ frontend/src/data/mock/transfers.ts | 48 --- frontend/src/features/admin/AccessControl.tsx | 5 +- frontend/src/features/admin/AccessLogs.tsx | 3 +- frontend/src/features/admin/AdminPanel.tsx | 3 + frontend/src/features/admin/AuditLogs.tsx | 3 +- frontend/src/features/admin/DataHub.tsx | 3 + .../src/features/admin/LoginHistoryView.tsx | 5 +- .../src/features/admin/NoticeManagement.tsx | 12 +- frontend/src/features/admin/SystemConfig.tsx | 3 + .../ai-operations/AIModelManagement.tsx | 3 + .../src/features/ai-operations/MLOpsPage.tsx | 7 +- frontend/src/features/dashboard/Dashboard.tsx | 109 +++++-- .../src/features/detection/ChinaFishing.tsx | 107 +------ .../detection/DarkVesselDetection.tsx | 3 +- .../src/features/detection/GearDetection.tsx | 3 +- frontend/src/features/field-ops/AIAlert.tsx | 3 +- .../src/features/field-ops/MobileService.tsx | 15 +- frontend/src/features/field-ops/ShipAgent.tsx | 7 +- .../monitoring/MonitoringDashboard.tsx | 31 +- .../parent-inference/LabelSession.tsx | 3 +- .../parent-inference/ParentExclusion.tsx | 3 +- .../parent-inference/ParentReview.tsx | 3 +- .../src/features/patrol/FleetOptimization.tsx | 8 +- frontend/src/features/patrol/PatrolRoute.tsx | 8 +- .../src/features/risk-assessment/RiskMap.tsx | 57 ++-- .../features/statistics/ExternalService.tsx | 7 +- .../features/statistics/ReportManagement.tsx | 7 +- .../src/features/statistics/Statistics.tsx | 69 ++--- .../src/features/surveillance/MapControl.tsx | 7 +- .../src/features/vessel/TransferDetection.tsx | 122 +------- frontend/src/features/vessel/VesselDetail.tsx | 3 +- frontend/src/lib/i18n/locales/en/common.json | 6 +- frontend/src/lib/i18n/locales/ko/common.json | 8 +- frontend/src/services/kpi.ts | 45 +++ frontend/src/shared/utils/dateFormat.ts | 53 ++++ frontend/src/stores/enforcementStore.ts | 13 +- frontend/src/stores/eventStore.ts | 13 +- frontend/src/stores/kpiStore.ts | 4 +- frontend/src/stores/transferStore.ts | 14 - 47 files changed, 452 insertions(+), 787 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourly.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourlyRepository.java delete mode 100644 frontend/src/data/mock/enforcement.ts delete mode 100644 frontend/src/data/mock/events.ts delete mode 100644 frontend/src/data/mock/transfers.ts create mode 100644 frontend/src/shared/utils/dateFormat.ts delete mode 100644 frontend/src/stores/transferStore.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java index cc5a27a..65860dc 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java @@ -24,7 +24,7 @@ public class PredictionKpi { @Column(name = "trend", length = 10) private String trend; - @Column(name = "delta_pct", precision = 5, scale = 2) + @Column(name = "delta_pct", precision = 12, scale = 2) private BigDecimal deltaPct; @Column(name = "updated_at") diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java index 3127df1..d0e1db2 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java @@ -57,7 +57,7 @@ public class PredictionStatsDaily { @Column(name = "false_positive_count") private Integer falsePositiveCount; - @Column(name = "ai_accuracy_pct", precision = 5, scale = 2) + @Column(name = "ai_accuracy_pct", precision = 12, scale = 2) private BigDecimal aiAccuracyPct; @Column(name = "updated_at") diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourly.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourly.java new file mode 100644 index 0000000..567608a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourly.java @@ -0,0 +1,43 @@ +package gc.mda.kcg.domain.stats; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.Map; + +@Entity +@Table(name = "prediction_stats_hourly", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionStatsHourly { + + @Id + @Column(name = "stat_hour") + private OffsetDateTime statHour; + + @Column(name = "total_detections") + private Integer totalDetections; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_category", columnDefinition = "jsonb") + private Map byCategory; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_zone", columnDefinition = "jsonb") + private Map byZone; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_risk_level", columnDefinition = "jsonb") + private Map byRiskLevel; + + @Column(name = "event_count") + private Integer eventCount; + + @Column(name = "critical_count") + private Integer criticalCount; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourlyRepository.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourlyRepository.java new file mode 100644 index 0000000..46f856c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsHourlyRepository.java @@ -0,0 +1,10 @@ +package gc.mda.kcg.domain.stats; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.OffsetDateTime; +import java.util.List; + +public interface PredictionStatsHourlyRepository extends JpaRepository { + List findByStatHourBetweenOrderByStatHourAsc(OffsetDateTime from, OffsetDateTime to); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java index 81eda4e..11a607d 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java @@ -54,7 +54,7 @@ public class PredictionStatsMonthly { @Column(name = "false_positive_count") private Integer falsePositiveCount; - @Column(name = "ai_accuracy_pct", precision = 5, scale = 2) + @Column(name = "ai_accuracy_pct", precision = 12, scale = 2) private BigDecimal aiAccuracyPct; @Column(name = "updated_at") diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java index a2ed5f0..d506157 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java @@ -6,6 +6,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.List; /** @@ -20,6 +21,7 @@ public class StatsController { private final PredictionKpiRepository kpiRepository; private final PredictionStatsMonthlyRepository monthlyRepository; private final PredictionStatsDailyRepository dailyRepository; + private final PredictionStatsHourlyRepository hourlyRepository; /** * 실시간 KPI 전체 목록 조회 @@ -57,4 +59,18 @@ public class StatsController { ) { return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to); } + + /** + * 시간별 통계 조회 (최근 N시간) + * @param hours 조회 시간 범위 (기본 24시간) + */ + @GetMapping("/hourly") + @RequirePermission(resource = "statistics", operation = "READ") + public List getHourly( + @RequestParam(defaultValue = "24") int hours + ) { + OffsetDateTime to = OffsetDateTime.now(); + OffsetDateTime from = to.minusHours(hours); + return hourlyRepository.findByStatHourBetweenOrderByStatHourAsc(from, to); + } } diff --git a/frontend/src/data/mock/enforcement.ts b/frontend/src/data/mock/enforcement.ts deleted file mode 100644 index d98892f..0000000 --- a/frontend/src/data/mock/enforcement.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @deprecated EnforcementHistory는 실제 API로 전환 완료. - * EnforcementPlan.tsx가 아직 MOCK_ENFORCEMENT_PLANS를 참조하므로 삭제하지 마세요. - */ - -/** @deprecated services/enforcement.ts의 EnforcementRecord 사용 권장 */ -export interface EnforcementRecord { - id: string; - date: string; - zone: string; - vessel: string; - violation: string; - action: string; - aiMatch: string; - result: string; -} - -export interface EnforcementPlanRecord { - id: string; - zone: string; - lat: number; - lng: number; - risk: number; - period: string; - ships: string; - crew: number; - status: string; - alert: string; -} - -/** Enforcement history (6 records) — src/features/enforcement/EnforcementHistory.tsx */ -export const MOCK_ENFORCEMENT_RECORDS: EnforcementRecord[] = [ - { id: 'ENF-001', date: '2026-04-03 08:47', zone: 'EEZ 북부', vessel: '鲁荣渔56555', violation: 'EEZ 침범', action: '나포', aiMatch: '일치', result: '처벌' }, - { id: 'ENF-002', date: '2026-04-03 07:23', zone: '서해 NLL', vessel: '津塘渔03966', violation: '무허가 조업', action: '검문·경고', aiMatch: '일치', result: '경고' }, - { id: 'ENF-003', date: '2026-04-02 22:15', zone: '서해 5도', vessel: '浙岱渔02856 외 7척', violation: '선단 침범', action: '퇴거 조치', aiMatch: '일치', result: '퇴거' }, - { id: 'ENF-004', date: '2026-04-02 14:30', zone: 'EEZ 서부', vessel: '冀黄港渔05001', violation: '불법환적', action: '증거 수집', aiMatch: '일치', result: '수사 의뢰' }, - { id: 'ENF-005', date: '2026-04-01 09:00', zone: '남해 연안', vessel: '한국어선-03', violation: '조업구역 이탈', action: '검문', aiMatch: '불일치', result: '오탐(정상)' }, - { id: 'ENF-006', date: '2026-03-30 16:40', zone: '동해 EEZ', vessel: '鲁荣渔51277', violation: '고속 도주', action: '추적·나포', aiMatch: '일치', result: '처벌' }, -]; - -/** Enforcement plans (5 plans) — src/features/risk-assessment/EnforcementPlan.tsx */ -export const MOCK_ENFORCEMENT_PLANS: EnforcementPlanRecord[] = [ - { id: 'EP-001', zone: '서해 NLL', lat: 37.80, lng: 124.90, risk: 92, period: '04-04 00:00~06:00', ships: '3001함, 3005함', crew: 48, status: '확정', alert: '경보 발령' }, - { id: 'EP-002', zone: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 78, period: '04-04 06:00~12:00', ships: '3009함', crew: 24, status: '확정', alert: '주의' }, - { id: 'EP-003', zone: '서해 5도', lat: 37.50, lng: 124.60, risk: 72, period: '04-04 12:00~18:00', ships: '서특단 1정', crew: 18, status: '계획중', alert: '주의' }, - { id: 'EP-004', zone: 'EEZ 서부', lat: 36.00, lng: 123.80, risk: 65, period: '04-05 00:00~06:00', ships: '3001함', crew: 24, status: '계획중', alert: '-' }, - { id: 'EP-005', zone: '남해 외해', lat: 34.20, lng: 127.50, risk: 45, period: '04-05 06:00~12:00', ships: '미정', crew: 0, status: '검토중', alert: '-' }, -]; diff --git a/frontend/src/data/mock/events.ts b/frontend/src/data/mock/events.ts deleted file mode 100644 index c4e1e7c..0000000 --- a/frontend/src/data/mock/events.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * @deprecated EventList, Dashboard, MonitoringDashboard는 실제 API로 전환 완료. - * 아직 AIAlert, MobileService가 AlertRecord mock을 참조하므로 삭제하지 마세요. - * - * Shared mock data: events & alerts - * - * Sources: - * - AIAlert.tsx DATA (5 alerts) — mock 유지 - * - MobileService.tsx ALERTS (3) — mock 유지 - */ - -// ──────────────────────────────────────────── -// Event record (EventList.tsx as primary, supplemented with Dashboard titles/details) -// ──────────────────────────────────────────── -export interface EventRecord { - 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; -} - -export const MOCK_EVENTS: EventRecord[] = [ - { - id: 'EVT-0001', - time: '2026-04-03 08:47:12', - level: 'CRITICAL', - type: 'EEZ 침범', - title: 'EEZ 침범 탐지', - detail: '鲁荣渔56555 외 2척 — N37°12\' E124°38\' 진입', - vesselName: '鲁荣渔56555', - mmsi: '412345678', - area: 'EEZ 북부', - lat: 37.2012, - lng: 124.6345, - speed: 8.2, - status: '추적 중', - assignee: '3001함', - }, - { - id: 'EVT-0002', - time: '2026-04-03 08:32:05', - level: 'HIGH', - type: '다크베셀', - title: '다크베셀 출현', - detail: 'MMSI 미상 선박 3척 — 서해 NLL 인근 AIS 소실', - vesselName: '미상선박-A', - mmsi: '미상', - area: '서해 NLL', - lat: 37.7512, - lng: 125.0234, - speed: 6.1, - status: '감시 중', - assignee: '상황실', - }, - { - id: 'EVT-0003', - time: '2026-04-03 08:15:33', - level: 'CRITICAL', - type: '선단밀집', - title: '선단 밀집 경보', - detail: '중국어선 14척 밀집 — N36°48\' E124°22\' 반경 2nm', - vesselName: '선단(14척)', - mmsi: '다수', - area: 'EEZ 서부', - lat: 36.8001, - lng: 124.3678, - speed: 4.5, - status: '경보 발령', - assignee: '서해청', - }, - { - id: 'EVT-0004', - time: '2026-04-03 07:58:44', - level: 'MEDIUM', - type: '불법환적', - title: '불법환적 의심', - detail: '冀黄港渔05001 + 운반선 접현 30분 이상', - vesselName: '冀黄港渔05001', - mmsi: '412987654', - area: '서해 중부', - lat: 36.4789, - lng: 124.2234, - speed: 0.3, - status: '확인 중', - assignee: '분석팀', - }, - { - id: 'EVT-0005', - time: '2026-04-03 07:41:18', - level: 'HIGH', - type: 'MMSI 변조', - title: 'MMSI 변조 탐지', - detail: '浙甬渔60651 — MMSI 3회 변경 이력 감지', - vesselName: '浙甬渔60651', - mmsi: '412111222', - area: 'EEZ 남부', - lat: 35.8678, - lng: 125.5012, - speed: 5.8, - status: '감시 중', - assignee: '상황실', - }, - { - id: 'EVT-0006', - time: '2026-04-03 07:23:01', - level: 'LOW', - type: '검문 완료', - title: '함정 검문 완료', - detail: '3009함 — 津塘渔03966 검문 완료, 경고 조치', - vesselName: '津塘渔03966', - mmsi: '412333444', - area: '서해 북부', - lat: 37.5012, - lng: 124.7890, - speed: 0, - status: '완료', - assignee: '3009함', - }, - { - id: 'EVT-0007', - time: '2026-04-03 07:05:55', - level: 'MEDIUM', - type: 'AIS 재송출', - title: 'AIS 재송출', - detail: '辽庄渔55567 — 4시간 소실 후 재송출', - vesselName: '辽庄渔55567', - mmsi: '412555666', - area: 'EEZ 북부', - lat: 37.3456, - lng: 124.8901, - speed: 3.2, - status: '확인 완료', - assignee: '상황실', - }, - { - id: 'EVT-0008', - time: '2026-04-03 06:48:22', - level: 'CRITICAL', - type: 'EEZ 침범', - title: '긴급 침범 경보', - detail: '浙岱渔02856 외 7척 — 서해 5도 수역 진입', - vesselName: '浙岱渔02856', - mmsi: '412777888', - area: '서해 5도', - lat: 37.0567, - lng: 124.9234, - speed: 4.5, - status: '추적 중', - assignee: '서특단', - }, - { - id: 'EVT-0009', - time: '2026-04-03 06:30:00', - level: 'LOW', - type: '정기 보고', - title: '정기 보고', - detail: '전 해역 야간 감시 결과 보고 완료', - vesselName: undefined, - mmsi: undefined, - area: '전 해역', - status: '완료', - assignee: '상황실', - }, - { - id: 'EVT-0010', - time: '2026-04-03 06:12:33', - level: 'HIGH', - type: '속력 이상', - title: '속력 이상 탐지', - detail: '鲁荣渔51277 — 18kt 고속 이동, 도주 패턴', - vesselName: '鲁荣渔51277', - mmsi: '412999000', - area: '동해 중부', - lat: 36.2512, - lng: 130.0890, - speed: 18.1, - status: '추적 중', - assignee: '동해청', - }, - { - id: 'EVT-0011', - time: '2026-04-03 05:45:10', - level: 'MEDIUM', - type: 'AIS 소실', - title: 'AIS 소실', - detail: '浙甬渔30112 남해 외해 AIS 소실', - vesselName: '浙甬渔30112', - mmsi: '412444555', - area: '남해 외해', - lat: 34.1234, - lng: 128.5678, - status: '감시 중', - assignee: '남해청', - }, - { - id: 'EVT-0012', - time: '2026-04-03 05:20:48', - level: 'HIGH', - type: '불법환적', - title: '불법환적 의심', - detail: '冀黄港渔03012 EEZ 서부 환적 의심', - vesselName: '冀黄港渔03012', - mmsi: '412666777', - area: 'EEZ 서부', - lat: 36.5678, - lng: 124.1234, - speed: 0.5, - status: '확인 중', - assignee: '분석팀', - }, - { - id: 'EVT-0013', - time: '2026-04-03 04:55:30', - level: 'LOW', - type: '구역 이탈', - title: '구역 이탈', - detail: '한국어선-12 연안 구역 이탈 경고', - vesselName: '한국어선-12', - mmsi: '440123456', - area: '연안 구역', - lat: 35.4567, - lng: 129.3456, - speed: 7.0, - status: '경고 완료', - assignee: '포항서', - }, - { - id: 'EVT-0014', - time: '2026-04-03 04:30:15', - level: 'CRITICAL', - type: 'EEZ 침범', - title: 'EEZ 침범 — 나포 작전', - detail: '鲁威渔15028 EEZ 북부 나포 작전 진행', - vesselName: '鲁威渔15028', - mmsi: '412888999', - area: 'EEZ 북부', - lat: 37.4012, - lng: 124.5567, - speed: 6.9, - status: '나포 작전', - assignee: '3001함', - }, - { - id: 'EVT-0015', - time: '2026-04-03 04:10:00', - level: 'MEDIUM', - type: 'MMSI 변조', - title: 'MMSI 변조 의심', - detail: '浙甬渔99871 남해 연안 MMSI 변조 의심', - vesselName: '浙甬渔99871', - mmsi: '412222333', - area: '남해 연안', - lat: 34.5678, - lng: 127.8901, - speed: 4.2, - status: '확인 중', - assignee: '상황실', - }, -]; - -// ──────────────────────────────────────────── -// Alert records (AIAlert.tsx as primary) -// ──────────────────────────────────────────── -export interface AlertRecord { - id: string; - time: string; - type: string; - location: string; - confidence: number; - target: string; - status: string; -} - -export const MOCK_ALERTS: AlertRecord[] = [ - { id: 'ALR-001', time: '08:47:12', type: 'EEZ 침범', location: 'N37.20 E124.63', confidence: 96, target: '3001함, 상황실', status: '수신확인' }, - { id: 'ALR-002', time: '08:32:05', type: '다크베셀', location: 'N37.75 E125.02', confidence: 91, target: '상황실', status: '수신확인' }, - { id: 'ALR-003', time: '08:15:33', type: '선단밀집', location: 'N36.80 E124.37', confidence: 88, target: '서특단, 상황실', status: '발송완료' }, - { id: 'ALR-004', time: '07:58:44', type: '불법환적', location: 'N36.48 E124.22', confidence: 82, target: '3005함', status: '수신확인' }, - { id: 'ALR-005', time: '07:41:18', type: 'MMSI변조', location: 'N35.87 E125.50', confidence: 94, target: '상황실', status: '미수신' }, -]; diff --git a/frontend/src/data/mock/transfers.ts b/frontend/src/data/mock/transfers.ts deleted file mode 100644 index c39b816..0000000 --- a/frontend/src/data/mock/transfers.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface TransferRecord { - id: string; - time: string; - vesselA: { name: string; mmsi: string }; - vesselB: { name: string; mmsi: string }; - distance: number; - duration: number; - speed: number; - score: number; - location: string; -} - -/** Transfer detection data (3 records) — shared by TransferDetection.tsx & ChinaFishing.tsx */ -export const MOCK_TRANSFERS: TransferRecord[] = [ - { - id: 'TR-001', - time: '2026-01-20 13:42:11', - vesselA: { name: '장저우8호', mmsi: '412345680' }, - vesselB: { name: '黑江9호', mmsi: '412345690' }, - distance: 45, - duration: 52, - speed: 2.3, - score: 89, - location: '서해 중부', - }, - { - id: 'TR-002', - time: '2026-01-20 11:15:33', - vesselA: { name: '江苏如东號', mmsi: '412345683' }, - vesselB: { name: '산동위해호', mmsi: '412345691' }, - distance: 38, - duration: 67, - speed: 1.8, - score: 92, - location: '서해 북부', - }, - { - id: 'TR-003', - time: '2026-01-20 09:23:45', - vesselA: { name: '辽宁大连號', mmsi: '412345682' }, - vesselB: { name: '무명선박-D', mmsi: '412345692' }, - distance: 62, - duration: 41, - speed: 2.7, - score: 78, - location: 'EEZ 북부', - }, -]; diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index 0f486a0..bfa4716 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -17,6 +17,7 @@ import { type AuditLog as ApiAuditLog, type AuditStats, } from '@/services/adminApi'; +import { formatDateTime } from '@shared/utils/dateFormat'; import { PermissionsPanel } from './PermissionsPanel'; import { UserRoleAssignDialog } from './UserRoleAssignDialog'; @@ -153,7 +154,7 @@ export function AccessControl() { { key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true, render: (v) => ( - {v ? new Date(v as string).toLocaleString('ko-KR') : '-'} + {formatDateTime(v as string)} ), }, @@ -178,7 +179,7 @@ export function AccessControl() { // ── 감사 로그 컬럼 ────────────── const auditColumns: DataColumn>[] = useMemo(() => [ { key: 'createdAt', label: '일시', width: '160px', sortable: true, - render: (v) => {new Date(v as string).toLocaleString('ko-KR')} }, + render: (v) => {formatDateTime(v as string)} }, { key: 'userAcnt', label: '사용자', width: '90px', sortable: true, render: (v) => {(v as string) || '-'} }, { key: 'actionCd', label: '액션', width: '180px', sortable: true, diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index a6fb1a6..e660abe 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; +import { formatDateTime } from '@shared/utils/dateFormat'; /** * 접근 이력 조회 + 메트릭 카드. @@ -107,7 +108,7 @@ export function AccessLogs() { {items.map((it) => ( {it.accessSn} - {new Date(it.createdAt).toLocaleString('ko-KR')} + {formatDateTime(it.createdAt)} {it.userAcnt || '-'} {it.httpMethod} {it.requestPath} diff --git a/frontend/src/features/admin/AdminPanel.tsx b/frontend/src/features/admin/AdminPanel.tsx index f06bc5c..6a66e06 100644 --- a/frontend/src/features/admin/AdminPanel.tsx +++ b/frontend/src/features/admin/AdminPanel.tsx @@ -35,6 +35,9 @@ export function AdminPanel() {

{t('adminPanel.title')} + + 데모 데이터 (백엔드 API 미구현) +

{t('adminPanel.desc')}

diff --git a/frontend/src/features/admin/AuditLogs.tsx b/frontend/src/features/admin/AuditLogs.tsx index a4a3b38..c427527 100644 --- a/frontend/src/features/admin/AuditLogs.tsx +++ b/frontend/src/features/admin/AuditLogs.tsx @@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi'; +import { formatDateTime } from '@shared/utils/dateFormat'; /** * 감사 로그 조회 + 메트릭 카드. @@ -93,7 +94,7 @@ export function AuditLogs() { {items.map((it) => ( {it.auditSn} - {new Date(it.createdAt).toLocaleString('ko-KR')} + {formatDateTime(it.createdAt)} {it.userAcnt || '-'} {it.actionCd} {it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''} diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index daa3716..de4376f 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -390,6 +390,9 @@ export function DataHub() {

{t('dataHub.title')} + + 데모 데이터 (백엔드 API 미구현) +

{t('dataHub.desc')} diff --git a/frontend/src/features/admin/LoginHistoryView.tsx b/frontend/src/features/admin/LoginHistoryView.tsx index 283b497..0fc75ed 100644 --- a/frontend/src/features/admin/LoginHistoryView.tsx +++ b/frontend/src/features/admin/LoginHistoryView.tsx @@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi'; +import { formatDateTime, formatDate } from '@shared/utils/dateFormat'; /** * 로그인 이력 조회 + 메트릭 카드. @@ -80,7 +81,7 @@ export function LoginHistoryView() { {stats.daily7d.map((d) => (

- {new Date(d.day).toLocaleDateString('ko-KR')} + {formatDate(d.day)}
성공 {d.success} 실패 {d.failed} @@ -118,7 +119,7 @@ export function LoginHistoryView() { {items.map((it) => ( {it.histSn} - {new Date(it.loginDtm).toLocaleString('ko-KR')} + {formatDateTime(it.loginDtm)} {it.userAcnt} {it.result} diff --git a/frontend/src/features/admin/NoticeManagement.tsx b/frontend/src/features/admin/NoticeManagement.tsx index e837dca..405f63a 100644 --- a/frontend/src/features/admin/NoticeManagement.tsx +++ b/frontend/src/features/admin/NoticeManagement.tsx @@ -8,6 +8,7 @@ import { CheckCircle, Clock, Pin, Monitor, MessageSquare, X, } from 'lucide-react'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; +import { toDateParam } from '@shared/utils/dateFormat'; import { SaveButton } from '@shared/components/common/SaveButton'; import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner'; @@ -75,19 +76,19 @@ export function NoticeManagement() { const [showForm, setShowForm] = useState(false); const [form, setForm] = useState({ id: '', type: 'info', display: 'banner', title: '', message: '', - startDate: new Date().toISOString().slice(0, 10), - endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + startDate: toDateParam(), + endDate: toDateParam(new Date(Date.now() + 7 * 86400000)), targetRoles: [], dismissible: true, pinned: false, }); - const now = new Date().toISOString().slice(0, 10); + const now = toDateParam(); const openNew = () => { setForm({ id: `N-${String(notices.length + 1).padStart(3, '0')}`, type: 'info', display: 'banner', title: '', message: '', startDate: now, - endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + endDate: toDateParam(new Date(Date.now() + 7 * 86400000)), targetRoles: [], dismissible: true, pinned: false, }); setEditingId(null); @@ -141,6 +142,9 @@ export function NoticeManagement() {

{t('notices.title')} + + 데모 데이터 (백엔드 API 미구현) +

{t('notices.desc')} diff --git a/frontend/src/features/admin/SystemConfig.tsx b/frontend/src/features/admin/SystemConfig.tsx index e4b9c58..3104293 100644 --- a/frontend/src/features/admin/SystemConfig.tsx +++ b/frontend/src/features/admin/SystemConfig.tsx @@ -150,6 +150,9 @@ export function SystemConfig() {

{t('systemConfig.title')} + + 데모 데이터 (백엔드 API 미구현) +

{t('systemConfig.desc')} diff --git a/frontend/src/features/ai-operations/AIModelManagement.tsx b/frontend/src/features/ai-operations/AIModelManagement.tsx index ce4513c..56898d9 100644 --- a/frontend/src/features/ai-operations/AIModelManagement.tsx +++ b/frontend/src/features/ai-operations/AIModelManagement.tsx @@ -251,6 +251,9 @@ export function AIModelManagement() {

{t('modelManagement.title')} + + 데모 데이터 (백엔드 API 미구현) +

{t('modelManagement.desc')} diff --git a/frontend/src/features/ai-operations/MLOpsPage.tsx b/frontend/src/features/ai-operations/MLOpsPage.tsx index ab82fdf..5978dde 100644 --- a/frontend/src/features/ai-operations/MLOpsPage.tsx +++ b/frontend/src/features/ai-operations/MLOpsPage.tsx @@ -117,7 +117,12 @@ export function MLOpsPage() {

-

{t('mlops.title')}

+

+ {t('mlops.title')} + + 데모 데이터 (백엔드 API 미구현) + +

{t('mlops.desc')}

diff --git a/frontend/src/features/dashboard/Dashboard.tsx b/frontend/src/features/dashboard/Dashboard.tsx index 554b976..33e1a64 100644 --- a/frontend/src/features/dashboard/Dashboard.tsx +++ b/frontend/src/features/dashboard/Dashboard.tsx @@ -16,6 +16,13 @@ import { useKpiStore } from '@stores/kpiStore'; import { useEventStore } from '@stores/eventStore'; import { usePatrolStore } from '@stores/patrolStore'; import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi'; +import { + getDailyStats, + getHourlyStats, + type PredictionStatsDaily, + type PredictionStatsHourly, +} from '@/services/kpi'; +import { toDateParam } from '@shared/utils/dateFormat'; // ─── 작전 경보 등급 ───────────────────── type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; @@ -44,32 +51,16 @@ const KPI_UI_MAP: Record = { }; -// TODO: /api/risk-grid 연동 예정 -const AREA_RISK_DATA = [ - { area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' }, - { area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' }, - { area: '서해 5도', vessels: 11, risk: 88, trend: 'stable' }, - { area: 'EEZ 서부', vessels: 6, risk: 72, trend: 'down' }, - { area: '동해 중부', vessels: 4, risk: 58, trend: 'up' }, - { area: 'EEZ 남부', vessels: 3, risk: 45, trend: 'down' }, - { area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' }, -]; - -// TODO: /api/stats/daily 연동 예정 -const HOURLY_DETECTION = [ - { hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 }, - { hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 }, - { hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 }, -]; - -// TODO: /api/stats/daily 연동 예정 -const VESSEL_TYPE_DATA = [ - { name: 'EEZ 침범', value: 18, color: '#ef4444' }, - { name: '다크베셀', value: 12, color: '#f97316' }, - { name: '불법환적', value: 8, color: '#a855f7' }, - { name: 'MMSI변조', value: 5, color: '#eab308' }, - { name: '고속도주', value: 4, color: '#06b6d4' }, -]; +// 위반 유형/어구 → 차트 색상 매핑 +const VESSEL_TYPE_COLORS: Record = { + 'EEZ 침범': '#ef4444', + '다크베셀': '#f97316', + '불법환적': '#a855f7', + 'MMSI변조': '#eab308', + '고속도주': '#06b6d4', + '어구 불법': '#6b7280', +}; +const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280']; // TODO: /api/weather 연동 예정 const WEATHER_DATA = { @@ -292,6 +283,14 @@ export function Dashboard() { const patrolStore = usePatrolStore(); const [riskVessels, setRiskVessels] = useState([]); + const [hourlyStats, setHourlyStats] = useState([]); + const [dailyStats, setDailyStats] = useState([]); + + useEffect(() => { + getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([])); + const today = toDateParam(new Date()); + getDailyStats(today, today).then(setDailyStats).catch(() => setDailyStats([])); + }, []); useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]); useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]); @@ -356,6 +355,64 @@ export function Dashboard() { fuel: s.fuel, })), [patrolStore.ships]); + // 시간대별 탐지 추이: hourly stats → 차트 데이터 + const HOURLY_DETECTION = useMemo(() => hourlyStats.map((h) => { + const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}시` : ''; + const eez = h.byZone + ? Object.entries(h.byZone) + .filter(([k]) => k.toUpperCase().includes('EEZ')) + .reduce((sum, [, v]) => sum + (Number(v) || 0), 0) + : 0; + return { + hour: hourLabel, + count: h.totalDetections ?? 0, + eez, + }; + }), [hourlyStats]); + + // 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory + const VESSEL_TYPE_DATA = useMemo(() => { + if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[]; + const totals: Record = {}; + dailyStats.forEach((d) => { + const src = d.byGearType ?? d.byCategory ?? null; + if (src) { + Object.entries(src).forEach(([k, v]) => { + totals[k] = (totals[k] ?? 0) + (Number(v) || 0); + }); + } + }); + return Object.entries(totals) + .sort((a, b) => b[1] - a[1]) + .map(([name, value], i) => ({ + name, + value, + color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length], + })); + }, [dailyStats]); + + // 해역별 위험도: daily byZone → 표 데이터 + const AREA_RISK_DATA = useMemo(() => { + if (dailyStats.length === 0) return [] as { area: string; vessels: number; risk: number; trend: string }[]; + const totals: Record = {}; + dailyStats.forEach((d) => { + if (d.byZone) { + Object.entries(d.byZone).forEach(([k, v]) => { + totals[k] = (totals[k] ?? 0) + (Number(v) || 0); + }); + } + }); + const max = Math.max(1, ...Object.values(totals)); + return Object.entries(totals) + .sort((a, b) => b[1] - a[1]) + .map(([area, vessels]) => ({ + area, + vessels, + risk: Math.round((vessels / max) * 100), + trend: 'stable', + })); + }, [dailyStats]); + const defconColors = ['', 'bg-red-600', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-blue-500']; const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5']; diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 076d2fa..3dd7682 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -6,10 +6,10 @@ import { Eye, AlertTriangle, Radio, RotateCcw, MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; +import { formatDateTime } from '@shared/utils/dateFormat'; import { GearIdentification } from './GearIdentification'; -import { RealAllVessels } from './RealVesselAnalysis'; +import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; import { PieChart as EcPieChart } from '@lib/charts'; -import { useTransferStore } from '@stores/transferStore'; import { fetchVesselAnalysis, filterDarkVessels, @@ -72,7 +72,7 @@ const VTS_ITEMS = [ { name: '태안연안', active: false }, ]; -// ─── 환적 탐지 데이터: useTransferStore().transfers 사용 ─── +// ─── 환적 탐지 뷰: RealTransshipSuspects 컴포넌트 사용 ─── // ─── 서브 컴포넌트 ───────────────────── @@ -159,25 +159,6 @@ function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number // ─── 환적 탐지 뷰 ───────────────────── function TransferView() { - const { transfers, load } = useTransferStore(); - useEffect(() => { load(); }, [load]); - - const TRANSFER_DATA = useMemo( - () => - transfers.map((t) => ({ - id: t.id, - time: t.time, - a: t.vesselA, - b: t.vesselB, - dist: t.distance, - dur: t.duration, - spd: t.speed, - score: t.score, - loc: t.location, - })), - [transfers], - ); - return (
@@ -206,84 +187,8 @@ function TransferView() { - {/* 환적 이벤트 */} - {TRANSFER_DATA.map((tr) => ( - - -
-
-

{tr.id}

-
{tr.time}
-
- 환적 의심도: {tr.score}% -
- -
-
- {/* 선박 A & B */} -
-
-
선박 A
-
{tr.a.name}
-
MMSI: {tr.a.mmsi}
-
-
-
선박 B
-
{tr.b.name}
-
MMSI: {tr.b.mmsi}
-
-
- - {/* 타임라인 */} -
-
접촉 타임라인
-
-
- - 접근 시작 - 거리: 500m -
-
- - 근접 유지 - 거리: {tr.dist}m, 지속: {tr.dur}분 -
-
- - 의심 행위 감지 - 평균 속도: {tr.spd}kn -
-
-
-
- - {/* 우측 정보 */} -
-
-
접촉 정보
-
최소 거리{tr.dist}m
-
접촉 시간{tr.dur}분
-
평균 속도{tr.spd}kn
-
-
-
위치
-
{tr.loc}
-
-
-
환적 의심도
-
-
-
-
{tr.score}%
-
- -
-
- - - ))} + {/* prediction 분석 결과 기반 환적 의심 선박 */} +
); } @@ -430,7 +335,7 @@ export function ChinaFishing() {
- 기준 : {new Date().toLocaleString('ko-KR')} + 기준 : {formatDateTime(new Date())}
@@ -204,7 +210,14 @@ export function RiskMap() { {/* ── 위험도 히트맵 (지도 기반) ── */} {tab === 'heatmap' && (
+
+ + + prediction_risk_grid 테이블 데이터 수집 중입니다. 히트맵/등급 카드/해역별 위험도는 표시 예시이며 실제 운영 데이터로 곧 대체됩니다. + +
{/* 위험도 등급 요약 카드 */} +
데이터 출처: AI 분석 데이터 수집 중 (예시 데이터)
{RISK_LEVELS.map(r => (
@@ -287,7 +300,7 @@ export function RiskMap() { {/* ── ① 년도별 통계 ── */} {tab === 'yearly' && (
-
출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
+
{MTIS_BADGE}출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
해양사고 추세
@@ -338,7 +351,7 @@ export function RiskMap() { {/* ── ② 선박 특성별 ── */} {tab === 'shipProp' && (
-
출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
+
{MTIS_BADGE}출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
선박용도별
@@ -379,7 +392,7 @@ export function RiskMap() { {/* ── ③ 사고종류별 ── */} {tab === 'accType' && (
-
출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
+
{MTIS_BADGE}출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
사고종류별 분포
@@ -413,7 +426,7 @@ export function RiskMap() { {/* ── ④ 시간적 특성별 ── */} {tab === 'timeStat' && (
-
출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
+
{MTIS_BADGE}출처: 중앙해양안전심판원 해양사고 통계 (MTIS)
월별 사고건수
@@ -451,7 +464,7 @@ export function RiskMap() { {/* ── ⑤ 사고율 ── */} {tab === 'accRate' && (
-
출처: 중앙해양안전심판원 해양사고 통계 (MTIS) · 사고율 = (사고건수 / 등록척수) × 100
+
{MTIS_BADGE}출처: 중앙해양안전심판원 해양사고 통계 (MTIS) · 사고율 = (사고건수 / 등록척수) × 100
선박용도별 사고율
diff --git a/frontend/src/features/statistics/ExternalService.tsx b/frontend/src/features/statistics/ExternalService.tsx index cad27d2..bdc24a0 100644 --- a/frontend/src/features/statistics/ExternalService.tsx +++ b/frontend/src/features/statistics/ExternalService.tsx @@ -33,7 +33,12 @@ export function ExternalService() { return (
-

{t('externalService.title')}

+

+ {t('externalService.title')} + + 데모 데이터 (백엔드 API 미구현) + +

{t('externalService.desc')}

diff --git a/frontend/src/features/statistics/ReportManagement.tsx b/frontend/src/features/statistics/ReportManagement.tsx index 8c78d48..b6b1277 100644 --- a/frontend/src/features/statistics/ReportManagement.tsx +++ b/frontend/src/features/statistics/ReportManagement.tsx @@ -40,7 +40,12 @@ export function ReportManagement() {
-

{t('reports.title')}

+

+ {t('reports.title')} + + 데모 데이터 (백엔드 API 미구현) + +

{t('reports.desc')}

diff --git a/frontend/src/features/statistics/Statistics.tsx b/frontend/src/features/statistics/Statistics.tsx index 8468f66..231357a 100644 --- a/frontend/src/features/statistics/Statistics.tsx +++ b/frontend/src/features/statistics/Statistics.tsx @@ -6,61 +6,28 @@ import { DataTable, type DataColumn } from '@shared/components/common/DataTable' import { BarChart3, Download } from 'lucide-react'; import { BarChart, AreaChart } from '@lib/charts'; import { + getKpiMetrics, getMonthlyStats, toMonthlyTrend, toViolationTypes, + type PredictionKpi, type PredictionStatsMonthly, } from '@/services/kpi'; import type { MonthlyTrend, ViolationType } from '@data/mock/kpi'; +import { toDateParam } from '@shared/utils/dateFormat'; /* SFR-13: 통계·지표·성과 분석 */ -const KPI_DATA: { +interface KpiRow { 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]>[] = [ +const kpiCols: DataColumn[] = [ { key: 'id', label: 'ID', @@ -105,6 +72,7 @@ export function Statistics() { const [monthly, setMonthly] = useState([]); const [violationTypes, setViolationTypes] = useState([]); + const [kpiMetrics, setKpiMetrics] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -117,17 +85,17 @@ export function Statistics() { 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), - ); + const [data, kpiData] = await Promise.all([ + getMonthlyStats(toDateParam(from), toDateParam(now)), + getKpiMetrics().catch(() => [] as PredictionKpi[]), + ]); if (cancelled) return; setMonthly(data.map(toMonthlyTrend)); setViolationTypes(toViolationTypes(data)); + setKpiMetrics(kpiData); } catch (err) { if (!cancelled) { setError( @@ -154,6 +122,19 @@ export function Statistics() { const BY_TYPE = violationTypes; + const KPI_DATA: KpiRow[] = kpiMetrics.map((k, i) => { + const trendLabel = + k.trend === 'up' ? '상승' : k.trend === 'down' ? '하락' : k.trend === 'flat' ? '유지' : '-'; + const deltaLabel = k.deltaPct != null ? ` (${k.deltaPct > 0 ? '+' : ''}${k.deltaPct}%)` : ''; + return { + id: `KPI-${String(i + 1).padStart(2, '0')}`, + name: k.kpiLabel, + target: '-', + current: String(k.value), + status: `${trendLabel}${deltaLabel}`, + }; + }); + return (
diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx index 7725b6d..1b4a46d 100644 --- a/frontend/src/features/surveillance/MapControl.tsx +++ b/frontend/src/features/surveillance/MapControl.tsx @@ -254,7 +254,12 @@ export function MapControl() {
-

해역 통제

+

+ 해역 통제 + + 데모 데이터 (백엔드 API 미구현) + +

한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원

diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index febc19a..3c3da5f 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -1,29 +1,7 @@ -import { useEffect, useMemo } from 'react'; import { Card, CardContent } from '@shared/components/ui/card'; -import { Badge } from '@shared/components/ui/badge'; -import { Ship, MapPin } from 'lucide-react'; -import { useTransferStore } from '@stores/transferStore'; import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis'; export function TransferDetection() { - const { transfers, load } = useTransferStore(); - useEffect(() => { load(); }, [load]); - - const transferData = useMemo( - () => - transfers.map((t) => ({ - id: t.id, - time: t.time, - a: t.vesselA, - b: t.vesselB, - dist: t.distance, - dur: t.duration, - spd: t.speed, - score: t.score, - loc: t.location, - })), - [transfers], - ); return (
@@ -31,7 +9,7 @@ export function TransferDetection() {

선박 간 근접 접촉 및 환적 의심 행위 분석

- {/* iran 백엔드 실시간 전재 의심 */} + {/* prediction 분석 결과 기반 실시간 환적 의심 선박 */} {/* 탐지 조건 */} @@ -54,104 +32,6 @@ export function TransferDetection() {
- - {/* 환적 이벤트 카드 */} - {transferData.map((tr) => ( - - - {/* 헤더 */} -
-
-

{tr.id}

-
{tr.time}
-
- 환적 의심도: {tr.score}% -
- -
- {/* 선박 A & B + 타임라인 */} -
-
-
-
- 선박 A -
-
{tr.a.name}
-
MMSI: {tr.a.mmsi}
-
-
-
- 선박 B -
-
{tr.b.name}
-
MMSI: {tr.b.mmsi}
-
-
- - {/* 접촉 타임라인 */} -
-
접촉 타임라인
-
-
-
- 접근 시작 - 거리: 500m -
-
-
- 근접 유지 - 거리: {tr.dist}m, 지속: {tr.dur}분 -
-
-
- 의심 행위 감지 - 평균 속도: {tr.spd}kn -
-
-
-
- - {/* 우측: 접촉 정보 */} -
- - -
접촉 정보
-
-
최소 거리{tr.dist}m
-
접촉 시간{tr.dur}분
-
평균 속도{tr.spd}kn
-
-
-
- - - -
- 위치 -
-
{tr.loc}
-
-
- -
-
환적 의심도
-
-
-
-
{tr.score}%
-
- - -
-
- - - ))}
); } diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index dff4ee9..4f73499 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -12,6 +12,7 @@ import { fetchVesselAnalysis, type VesselAnalysisItem, } from '@/services/vesselAnalysisApi'; +import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; // ─── 허가 정보 타입 ────────────────────── @@ -406,7 +407,7 @@ export function VesselDetail() { UTC - {new Date().toISOString().substring(0, 19).replace('T', ' ')} + {formatDateTime(new Date())}
diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index e73ee3c..a23a840 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -32,11 +32,6 @@ "accessLogs": "Access Logs", "loginHistory": "Login History" }, - "group": { - "fieldOps": "Field Ops", - "parentInference": "Parent Workflow", - "admin": "Admin" - }, "status": { "active": "Active", "inactive": "Inactive", @@ -98,6 +93,7 @@ "patrol": "Patrol", "enforcement": "Enforcement", "fieldOps": "Field Ops", + "parentInference": "Parent Workflow", "statistics": "Statistics", "aiOps": "AI Ops", "admin": "Admin" diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 3b11c36..3cfafec 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -32,11 +32,6 @@ "accessLogs": "접근 이력", "loginHistory": "로그인 이력" }, - "group": { - "fieldOps": "함정·현장", - "parentInference": "모선 워크플로우", - "admin": "관리자" - }, "status": { "active": "활성", "inactive": "비활성", @@ -97,7 +92,8 @@ "detection": "탐지·분석", "patrol": "순찰·경로", "enforcement": "단속·이력", - "fieldOps": "현장 대응", + "fieldOps": "함정·현장", + "parentInference": "모선 워크플로우", "statistics": "통계·보고", "aiOps": "AI 운영", "admin": "시스템 관리" diff --git a/frontend/src/services/kpi.ts b/frontend/src/services/kpi.ts index 394f60c..06d67be 100644 --- a/frontend/src/services/kpi.ts +++ b/frontend/src/services/kpi.ts @@ -52,6 +52,51 @@ export async function getMonthlyStats( return res.json(); } +export interface PredictionStatsDaily { + statDate: string; + totalDetections: number; + enforcementCount: number; + eventCount: number; + criticalEventCount: number; + byCategory: Record | null; + byZone: Record | null; + byRiskLevel: Record | null; + byGearType: Record | null; + byViolationType: Record | null; + aiAccuracyPct: number | null; +} + +export interface PredictionStatsHourly { + statHour: string; + totalDetections: number; + eventCount: number; + criticalCount: number; + byCategory: Record | null; + byZone: Record | null; + byRiskLevel: Record | null; +} + +export async function getDailyStats( + from: string, + to: string, +): Promise { + const res = await fetch(`${API_BASE}/stats/daily?from=${from}&to=${to}`, { + credentials: 'include', + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export async function getHourlyStats( + hours: number = 24, +): Promise { + const res = await fetch(`${API_BASE}/stats/hourly?hours=${hours}`, { + credentials: 'include', + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + // ─── 하위 호환 변환 헬퍼 ─────────────────── /** PredictionKpi -> 기존 KpiMetric 형태로 변환 (Dashboard에서 사용) */ diff --git a/frontend/src/shared/utils/dateFormat.ts b/frontend/src/shared/utils/dateFormat.ts new file mode 100644 index 0000000..44a83df --- /dev/null +++ b/frontend/src/shared/utils/dateFormat.ts @@ -0,0 +1,53 @@ +/** + * 날짜/시간 포맷 유틸 — KST(Asia/Seoul) 고정 출력. + * + * 서버에서 UTC ISO 문자열이 오든, Date 객체가 오든 + * 항상 KST로 변환하여 표시합니다. + */ + +const KST: Intl.DateTimeFormatOptions = { timeZone: 'Asia/Seoul' }; + +/** 2026-04-07 14:30:00 형식 (KST) */ +export const formatDateTime = (value: string | Date | null | undefined): string => { + if (!value) return '-'; + const d = typeof value === 'string' ? new Date(value) : value; + if (isNaN(d.getTime())) return '-'; + return d.toLocaleString('ko-KR', { + ...KST, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); +}; + +/** 2026-04-07 형식 (KST) */ +export const formatDate = (value: string | Date | null | undefined): string => { + if (!value) return '-'; + const d = typeof value === 'string' ? new Date(value) : value; + if (isNaN(d.getTime())) return '-'; + return d.toLocaleDateString('ko-KR', { + ...KST, + year: 'numeric', month: '2-digit', day: '2-digit', + }); +}; + +/** 14:30:00 형식 (KST) */ +export const formatTime = (value: string | Date | null | undefined): string => { + if (!value) return '-'; + const d = typeof value === 'string' ? new Date(value) : value; + if (isNaN(d.getTime())) return '-'; + return d.toLocaleTimeString('ko-KR', { + ...KST, + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); +}; + +/** yyyy-MM-dd 형식 문자열 (KST 기준, API 파라미터용) */ +export const toDateParam = (d: Date = new Date()): string => { + const kst = new Date(d.toLocaleString('en-US', KST)); + const y = kst.getFullYear(); + const m = String(kst.getMonth() + 1).padStart(2, '0'); + const day = String(kst.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; diff --git a/frontend/src/stores/enforcementStore.ts b/frontend/src/stores/enforcementStore.ts index ed88bfc..22f7205 100644 --- a/frontend/src/stores/enforcementStore.ts +++ b/frontend/src/stores/enforcementStore.ts @@ -5,15 +5,12 @@ import { type EnforcementRecord, type LegacyEnforcementRecord, } from '@/services/enforcement'; -import type { EnforcementPlanRecord } from '@/data/mock/enforcement'; interface EnforcementStore { /** 원본 API 단속 기록 */ rawRecords: EnforcementRecord[]; /** 하위 호환용 레거시 형식 */ records: LegacyEnforcementRecord[]; - /** 단속 계획 (아직 mock — EnforcementPlan.tsx에서 사용) */ - plans: EnforcementPlanRecord[]; /** 페이지네이션 */ totalElements: number; totalPages: number; @@ -28,7 +25,6 @@ interface EnforcementStore { export const useEnforcementStore = create((set, get) => ({ rawRecords: [], records: [], - plans: [], totalElements: 0, totalPages: 0, loading: false, @@ -41,17 +37,10 @@ export const useEnforcementStore = create((set, get) => ({ 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), - ]); + const res = await getEnforcementRecords(params); set({ rawRecords: res.content, records: res.content.map(toLegacyRecord), - plans: planModule ?? get().plans, totalElements: res.totalElements, totalPages: res.totalPages, loaded: true, diff --git a/frontend/src/stores/eventStore.ts b/frontend/src/stores/eventStore.ts index a94b47e..8c85589 100644 --- a/frontend/src/stores/eventStore.ts +++ b/frontend/src/stores/eventStore.ts @@ -7,7 +7,6 @@ import { 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'; @@ -17,8 +16,6 @@ interface EventStore { rawEvents: PredictionEvent[]; /** 하위 호환용 레거시 형식 이벤트 */ events: LegacyEventRecord[]; - /** 알림 (아직 mock — AIAlert, MobileService에서 사용) */ - alerts: AlertRecord[]; /** 상태별 통계 */ stats: EventStats; /** 페이지네이션 */ @@ -39,7 +36,6 @@ interface EventStore { export const useEventStore = create((set, get) => ({ rawEvents: [], events: [], - alerts: [], stats: {}, totalElements: 0, totalPages: 0, @@ -55,18 +51,11 @@ export const useEventStore = create((set, get) => ({ 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 res = await getEvents(params); 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, diff --git a/frontend/src/stores/kpiStore.ts b/frontend/src/stores/kpiStore.ts index fd9f9df..58ea748 100644 --- a/frontend/src/stores/kpiStore.ts +++ b/frontend/src/stores/kpiStore.ts @@ -7,6 +7,7 @@ import { toMonthlyTrend, toViolationTypes, } from '@/services/kpi'; +import { toDateParam } from '@shared/utils/dateFormat'; interface KpiStore { metrics: KpiMetric[]; @@ -32,11 +33,10 @@ export const useKpiStore = create((set, get) => ({ // 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)), + getMonthlyStats(toDateParam(from), toDateParam(now)), ]); set({ diff --git a/frontend/src/stores/transferStore.ts b/frontend/src/stores/transferStore.ts deleted file mode 100644 index fc3e44f..0000000 --- a/frontend/src/stores/transferStore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { create } from 'zustand'; -import { MOCK_TRANSFERS, type TransferRecord } from '@/data/mock/transfers'; - -interface TransferStore { - transfers: TransferRecord[]; - loaded: boolean; - load: () => void; -} - -export const useTransferStore = create((set) => ({ - transfers: [], - loaded: false, - load: () => set({ transfers: MOCK_TRANSFERS, loaded: true }), -}));