feat: 프론트 전수 mock 정리 + UTC→KST 통일 + i18n 수정 + stats hourly API
## 시간 표시 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) <noreply@anthropic.com>
This commit is contained in:
부모
e12d1c33e2
커밋
19b1613157
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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<String, Object> byCategory;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_zone", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byZone;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "by_risk_level", columnDefinition = "jsonb")
|
||||
private Map<String, Object> byRiskLevel;
|
||||
|
||||
@Column(name = "event_count")
|
||||
private Integer eventCount;
|
||||
|
||||
@Column(name = "critical_count")
|
||||
private Integer criticalCount;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
@ -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<PredictionStatsHourly, OffsetDateTime> {
|
||||
List<PredictionStatsHourly> findByStatHourBetweenOrderByStatHourAsc(OffsetDateTime from, OffsetDateTime to);
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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<PredictionStatsHourly> getHourly(
|
||||
@RequestParam(defaultValue = "24") int hours
|
||||
) {
|
||||
OffsetDateTime to = OffsetDateTime.now();
|
||||
OffsetDateTime from = to.minusHours(hours);
|
||||
return hourlyRepository.findByStatHourBetweenOrderByStatHourAsc(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: '-' },
|
||||
];
|
||||
@ -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: '미수신' },
|
||||
];
|
||||
@ -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 북부',
|
||||
},
|
||||
];
|
||||
@ -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) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v ? new Date(v as string).toLocaleString('ko-KR') : '-'}
|
||||
{formatDateTime(v as string)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -178,7 +179,7 @@ export function AccessControl() {
|
||||
// ── 감사 로그 컬럼 ──────────────
|
||||
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
|
||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{new Date(v as string).toLocaleString('ko-KR')}</span> },
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
||||
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
||||
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
||||
|
||||
@ -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) => (
|
||||
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||
|
||||
@ -35,6 +35,9 @@ export function AdminPanel() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
{t('adminPanel.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
|
||||
</div>
|
||||
|
||||
@ -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) => (
|
||||
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||
|
||||
@ -390,6 +390,9 @@ export function DataHub() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-cyan-400" />
|
||||
{t('dataHub.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('dataHub.desc')}
|
||||
|
||||
@ -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() {
|
||||
<CardContent className="px-4 pb-4 space-y-1">
|
||||
{stats.daily7d.map((d) => (
|
||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground font-mono">{new Date(d.day).toLocaleDateString('ko-KR')}</span>
|
||||
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-green-400">성공 {d.success}</span>
|
||||
<span className="text-orange-400">실패 {d.failed}</span>
|
||||
@ -118,7 +119,7 @@ export function LoginHistoryView() {
|
||||
{items.map((it) => (
|
||||
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.loginDtm).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||
|
||||
@ -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<SystemNotice>({
|
||||
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() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-yellow-400" />
|
||||
{t('notices.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('notices.desc')}
|
||||
|
||||
@ -150,6 +150,9 @@ export function SystemConfig() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-cyan-400" />
|
||||
{t('systemConfig.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('systemConfig.desc')}
|
||||
|
||||
@ -251,6 +251,9 @@ export function AIModelManagement() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-purple-400" />
|
||||
{t('modelManagement.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('modelManagement.desc')}
|
||||
|
||||
@ -117,7 +117,12 @@ export function MLOpsPage() {
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<string, { icon: LucideIcon; color: string }> = {
|
||||
};
|
||||
|
||||
|
||||
// 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<string, string> = {
|
||||
'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<VesselAnalysisItem[]>([]);
|
||||
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
|
||||
const [dailyStats, setDailyStats] = useState<PredictionStatsDaily[]>([]);
|
||||
|
||||
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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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'];
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@ -206,84 +187,8 @@ function TransferView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 환적 이벤트 */}
|
||||
{TRANSFER_DATA.map((tr) => (
|
||||
<Card key={tr.id} className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-heading font-bold">{tr.id}</h3>
|
||||
<div className="text-[10px] text-hint">{tr.time}</div>
|
||||
</div>
|
||||
<Badge className="bg-red-500/20 text-red-400 border border-red-500/30 text-[10px]">환적 의심도: {tr.score}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 선박 A & B */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 mb-1"><Ship className="w-3 h-3" />선박 A</div>
|
||||
<div className="text-sm text-heading font-bold">{tr.a.name}</div>
|
||||
<div className="text-[9px] text-hint">MMSI: {tr.a.mmsi}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-teal-400 mb-1"><Ship className="w-3 h-3" />선박 B</div>
|
||||
<div className="text-sm text-heading font-bold">{tr.b.name}</div>
|
||||
<div className="text-[9px] text-hint">MMSI: {tr.b.mmsi}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 */}
|
||||
<div className="bg-surface-overlay rounded-lg p-3">
|
||||
<div className="text-[10px] text-muted-foreground mb-2">접촉 타임라인</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-blue-400">접근 시작</span>
|
||||
<span className="text-hint">거리: 500m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">근접 유지</span>
|
||||
<span className="text-hint">거리: {tr.dist}m, 지속: {tr.dur}분</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-red-400">의심 행위 감지</span>
|
||||
<span className="text-hint">평균 속도: {tr.spd}kn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 정보 */}
|
||||
<div className="w-44 shrink-0 space-y-2">
|
||||
<div className="bg-surface-overlay rounded-lg p-3 space-y-1.5 text-[10px]">
|
||||
<div className="text-muted-foreground mb-1">접촉 정보</div>
|
||||
<div className="flex justify-between"><span className="text-hint">최소 거리</span><span className="text-heading font-medium">{tr.dist}m</span></div>
|
||||
<div className="flex justify-between"><span className="text-hint">접촉 시간</span><span className="text-heading font-medium">{tr.dur}분</span></div>
|
||||
<div className="flex justify-between"><span className="text-hint">평균 속도</span><span className="text-heading font-medium">{tr.spd}kn</span></div>
|
||||
</div>
|
||||
<div className="bg-surface-overlay rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground mb-1"><MapPin className="w-3 h-3" />위치</div>
|
||||
<div className="text-heading text-sm font-medium">{tr.loc}</div>
|
||||
</div>
|
||||
<div className="bg-purple-950/30 border border-purple-900/30 rounded-lg p-3">
|
||||
<div className="text-[10px] text-purple-400 mb-1.5">환적 의심도</div>
|
||||
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden mb-1.5">
|
||||
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" style={{ width: `${tr.score}%` }} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-heading">{tr.score}%</div>
|
||||
</div>
|
||||
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-[11px] py-2 rounded-lg transition-colors">
|
||||
상세 분석 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{/* prediction 분석 결과 기반 환적 의심 선박 */}
|
||||
<RealTransshipSuspects />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -430,7 +335,7 @@ export function ChinaFishing() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] text-label">기준 : {new Date().toLocaleString('ko-KR')}</span>
|
||||
<span className="text-[11px] text-label">기준 : {formatDateTime(new Date())}</span>
|
||||
</div>
|
||||
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
filterDarkVessels,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||
|
||||
@ -53,7 +54,7 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
||||
flag: deriveFlag(item.mmsi),
|
||||
pattern: derivePattern(item),
|
||||
risk,
|
||||
lastAIS: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-',
|
||||
lastAIS: formatDateTime(item.timestamp),
|
||||
status,
|
||||
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
||||
lat: 0,
|
||||
|
||||
@ -7,6 +7,7 @@ import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
import { formatDate } from '@shared/utils/dateFormat';
|
||||
|
||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||
|
||||
@ -41,7 +42,7 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
||||
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
|
||||
status,
|
||||
permit: 'NONE',
|
||||
installed: g.snapshotTime ? new Date(g.snapshotTime).toLocaleDateString('ko-KR') : '-',
|
||||
installed: formatDate(g.snapshotTime),
|
||||
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
|
||||
risk,
|
||||
lat: g.centerLat,
|
||||
|
||||
@ -4,6 +4,7 @@ import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { getAlerts, type PredictionAlert } from '@/services/event';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
||||
|
||||
@ -125,7 +126,7 @@ export function AIAlert() {
|
||||
alerts.map((a) => ({
|
||||
id: a.id,
|
||||
eventId: a.eventId,
|
||||
time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-',
|
||||
time: formatDateTime(a.sentAt),
|
||||
channel: a.channel ?? '-',
|
||||
recipient: a.recipient ?? '-',
|
||||
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',
|
||||
|
||||
@ -5,6 +5,7 @@ import { Badge } from '@shared/components/ui/badge';
|
||||
import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
|
||||
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { formatTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
||||
|
||||
@ -25,7 +26,7 @@ const MOBILE_MARKERS = [
|
||||
export function MobileService() {
|
||||
const { t } = useTranslation('fieldOps');
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
const { alerts: storeAlerts, load } = useEventStore();
|
||||
const { events, load } = useEventStore();
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const buildLayers = useCallback(() => [
|
||||
@ -40,13 +41,13 @@ export function MobileService() {
|
||||
|
||||
const ALERTS = useMemo(
|
||||
() =>
|
||||
storeAlerts.slice(0, 3).map((a) => ({
|
||||
time: a.time.slice(0, 5),
|
||||
title: a.type === 'EEZ 침범' ? `[긴급] ${a.type} 탐지` : a.type,
|
||||
detail: `${a.location}`,
|
||||
level: a.confidence >= 95 ? 'CRITICAL' : a.confidence >= 90 ? 'HIGH' : 'MEDIUM',
|
||||
events.slice(0, 3).map((e) => ({
|
||||
time: formatTime(e.time).slice(0, 5),
|
||||
title: e.type === 'EEZ 침범' || e.level === 'CRITICAL' ? `[긴급] ${e.title}` : e.title,
|
||||
detail: e.area ?? e.detail,
|
||||
level: e.level,
|
||||
})),
|
||||
[storeAlerts],
|
||||
[events],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -31,7 +31,12 @@ export function ShipAgent() {
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
@ -7,6 +7,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { AreaChart, PieChart } from '@lib/charts';
|
||||
import { useKpiStore } from '@stores/kpiStore';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
|
||||
import { SystemStatusPanel } from './SystemStatusPanel';
|
||||
|
||||
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
||||
@ -20,7 +21,6 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'추적 중': { icon: Target, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
const TREND = Array.from({ length: 24 }, (_, i) => ({ h: `${i}시`, risk: 30 + Math.floor(Math.random() * 50), alarms: Math.floor(Math.random() * 8) }));
|
||||
// 위반 유형 → 차트 색상 매핑
|
||||
const PIE_COLOR_MAP: Record<string, string> = {
|
||||
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
||||
@ -33,8 +33,35 @@ export function MonitoringDashboard() {
|
||||
const kpiStore = useKpiStore();
|
||||
const eventStore = useEventStore();
|
||||
|
||||
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
|
||||
|
||||
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
||||
useEffect(() => {
|
||||
getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([]));
|
||||
}, []);
|
||||
|
||||
// 24시간 위험도/경보 추이: hourly stats → 차트 데이터
|
||||
const TREND = useMemo(() => hourlyStats.map((h) => {
|
||||
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}시` : '';
|
||||
// 위험도 점수: byRiskLevel 가중합 (CRITICAL=100, HIGH=70, MEDIUM=40, LOW=10) 정규화
|
||||
let riskScore = 0;
|
||||
let total = 0;
|
||||
if (h.byRiskLevel) {
|
||||
const weights: Record<string, number> = { CRITICAL: 100, HIGH: 70, MEDIUM: 40, LOW: 10 };
|
||||
Object.entries(h.byRiskLevel).forEach(([k, v]) => {
|
||||
const cnt = Number(v) || 0;
|
||||
riskScore += (weights[k.toUpperCase()] ?? 0) * cnt;
|
||||
total += cnt;
|
||||
});
|
||||
}
|
||||
const risk = total > 0 ? Math.round(riskScore / total) : 0;
|
||||
return {
|
||||
h: hourLabel,
|
||||
risk,
|
||||
alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0),
|
||||
};
|
||||
}), [hourlyStats]);
|
||||
|
||||
// KPI: store metrics + UI 매핑
|
||||
const KPI = kpiStore.metrics.map((m) => ({
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
cancelLabelSession,
|
||||
type LabelSession as LabelSessionType,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/**
|
||||
* 모선 추론 학습 세션 페이지.
|
||||
@ -164,7 +165,7 @@ export function LabelSession() {
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.activeFrom).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.status === 'ACTIVE' && (
|
||||
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
releaseExclusion,
|
||||
type CandidateExclusion,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/**
|
||||
* 모선 후보 제외 페이지.
|
||||
@ -211,7 +212,7 @@ export function ParentExclusion() {
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
reviewParent,
|
||||
type ParentResolution,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
|
||||
/**
|
||||
* 모선 확정/거부/리셋 페이지.
|
||||
@ -233,7 +234,7 @@ export function ParentReview() {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">
|
||||
{new Date(it.updatedAt).toLocaleString('ko-KR')}
|
||||
{formatDateTime(it.updatedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
|
||||
@ -99,7 +99,13 @@ export function FleetOptimization() {
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span>
|
||||
<span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@ -96,7 +96,13 @@ export function PatrolRoute() {
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span>
|
||||
<span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@ -2,10 +2,19 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { HeatPoint } from '@lib/map';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Map, Layers, Filter, Clock, BarChart3, Target, AlertTriangle, Eye, RefreshCw, Printer, Download, Ship, Anchor, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart, BaseChart } from '@lib/charts';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
|
||||
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts';
|
||||
|
||||
const MTIS_BADGE = (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/15 border border-blue-500/30 rounded text-[9px] text-blue-300 font-normal">
|
||||
[MTIS 외부 통계]
|
||||
</span>
|
||||
);
|
||||
const COLLECTING_BADGE = (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-orange-500/15 border border-orange-500/30 rounded text-[9px] text-orange-300 font-normal">
|
||||
AI 분석 데이터 수집 중
|
||||
</span>
|
||||
);
|
||||
|
||||
/*
|
||||
* SFR-05: 격자 기반 불법조업 위험도 지도 생성·시각화
|
||||
@ -23,11 +32,8 @@ const RISK_LEVELS = [
|
||||
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
|
||||
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
|
||||
];
|
||||
const GRID_ROWS = 10;
|
||||
const GRID_COLS = 18;
|
||||
const generateGrid = () => Array.from({ length: GRID_ROWS }, () =>
|
||||
Array.from({ length: GRID_COLS }, () => Math.floor(Math.random() * 5) + 1)
|
||||
);
|
||||
// prediction_risk_grid 테이블 데이터 수집 전이라 빈 격자 반환
|
||||
const generateGrid = (): number[][] => [];
|
||||
const ZONE_SUMMARY = [
|
||||
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
|
||||
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
|
||||
@ -151,16 +157,13 @@ function generateHeatPoints(): [number, number, number][] {
|
||||
|
||||
const HEAT_POINTS = generateHeatPoints();
|
||||
|
||||
type SelectedGrid = { row: number; col: number } | null;
|
||||
|
||||
export function RiskMap() {
|
||||
const [grid] = useState(generateGrid);
|
||||
const [selectedGrid, setSelectedGrid] = useState<SelectedGrid>(null);
|
||||
// prediction_risk_grid 데이터 수집 전이라 빈 격자 유지
|
||||
const grid = generateGrid();
|
||||
void grid;
|
||||
const [tab, setTab] = useState<Tab>('heatmap');
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
const riskColor = (level: number) => RISK_LEVELS.find(r => r.level === level)?.color || '#6b7280';
|
||||
|
||||
const buildLayers = useCallback(() => {
|
||||
if (tab !== 'heatmap') return [];
|
||||
return [
|
||||
@ -175,8 +178,11 @@ export function RiskMap() {
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2"><Map className="w-5 h-5 text-red-400" />격자 기반 불법조업 위험도 지도</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">SFR-05 | 위험도 히트맵 + MTIS 해양사고 통계 (중앙해양안전심판원)</p>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
|
||||
<Map className="w-5 h-5 text-red-400" />격자 기반 불법조업 위험도 지도
|
||||
{COLLECTING_BADGE}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Printer className="w-3 h-3" />인쇄</button>
|
||||
@ -204,7 +210,14 @@ export function RiskMap() {
|
||||
{/* ── 위험도 히트맵 (지도 기반) ── */}
|
||||
{tab === 'heatmap' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-[10px] text-orange-300">
|
||||
prediction_risk_grid 테이블 데이터 수집 중입니다. 히트맵/등급 카드/해역별 위험도는 표시 예시이며 실제 운영 데이터로 곧 대체됩니다.
|
||||
</span>
|
||||
</div>
|
||||
{/* 위험도 등급 요약 카드 */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">데이터 출처: AI 분석 데이터 수집 중 (예시 데이터)</div>
|
||||
<div className="flex gap-2">
|
||||
{RISK_LEVELS.map(r => (
|
||||
<div key={r.level} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
@ -287,7 +300,7 @@ export function RiskMap() {
|
||||
{/* ── ① 년도별 통계 ── */}
|
||||
{tab === 'yearly' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</span></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">해양사고 추세</div>
|
||||
@ -338,7 +351,7 @@ export function RiskMap() {
|
||||
{/* ── ② 선박 특성별 ── */}
|
||||
{tab === 'shipProp' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</span></div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">선박용도별</div>
|
||||
@ -379,7 +392,7 @@ export function RiskMap() {
|
||||
{/* ── ③ 사고종류별 ── */}
|
||||
{tab === 'accType' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</span></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">사고종류별 분포</div>
|
||||
@ -413,7 +426,7 @@ export function RiskMap() {
|
||||
{/* ── ④ 시간적 특성별 ── */}
|
||||
{tab === 'timeStat' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</span></div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">월별 사고건수</div>
|
||||
@ -451,7 +464,7 @@ export function RiskMap() {
|
||||
{/* ── ⑤ 사고율 ── */}
|
||||
{tab === 'accRate' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS) · 사고율 = (사고건수 / 등록척수) × 100</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 해양사고 통계 (MTIS) · 사고율 = (사고건수 / 등록척수) × 100</span></div>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-3">선박용도별 사고율</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@ -33,7 +33,12 @@ export function ExternalService() {
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}</h2>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@ -40,7 +40,12 @@ export function ReportManagement() {
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading whitespace-nowrap">{t('reports.title')}</h2>
|
||||
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
{t('reports.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -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<KpiRow>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
@ -105,6 +72,7 @@ export function Statistics() {
|
||||
|
||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||
const [kpiMetrics, setKpiMetrics] = useState<PredictionKpi[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -254,7 +254,12 @@ export function MapControl() {
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2"><Map className="w-5 h-5 text-cyan-400" />해역 통제</h2>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
|
||||
<Map className="w-5 h-5 text-cyan-400" />해역 통제
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
@ -31,7 +9,7 @@ export function TransferDetection() {
|
||||
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||
</div>
|
||||
|
||||
{/* iran 백엔드 실시간 전재 의심 */}
|
||||
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
|
||||
<RealTransshipSuspects />
|
||||
|
||||
{/* 탐지 조건 */}
|
||||
@ -54,104 +32,6 @@ export function TransferDetection() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 환적 이벤트 카드 */}
|
||||
{transferData.map((tr) => (
|
||||
<Card key={tr.id} className="bg-surface-overlay border-slate-700/40">
|
||||
<CardContent className="p-5">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-heading font-bold text-lg">{tr.id}</h3>
|
||||
<div className="text-[11px] text-hint">{tr.time}</div>
|
||||
</div>
|
||||
<Badge className="bg-red-500 text-heading text-xs">환적 의심도: {tr.score}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* 선박 A & B + 타임라인 */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-[11px] text-blue-400 mb-2">
|
||||
<Ship className="w-3.5 h-3.5" />선박 A
|
||||
</div>
|
||||
<div className="text-heading font-bold">{tr.a.name}</div>
|
||||
<div className="text-[11px] text-hint">MMSI: {tr.a.mmsi}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-[11px] text-teal-400 mb-2">
|
||||
<Ship className="w-3.5 h-3.5" />선박 B
|
||||
</div>
|
||||
<div className="text-heading font-bold">{tr.b.name}</div>
|
||||
<div className="text-[11px] text-hint">MMSI: {tr.b.mmsi}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 접촉 타임라인 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3">
|
||||
<div className="text-[11px] text-muted-foreground mb-2">접촉 타임라인</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-blue-400">접근 시작</span>
|
||||
<span className="text-hint">거리: 500m</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">근접 유지</span>
|
||||
<span className="text-hint">거리: {tr.dist}m, 지속: {tr.dur}분</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-red-400">의심 행위 감지</span>
|
||||
<span className="text-hint">평균 속도: {tr.spd}kn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 접촉 정보 */}
|
||||
<div className="w-[200px] shrink-0 space-y-3">
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-[11px] text-muted-foreground mb-2">접촉 정보</div>
|
||||
<div className="space-y-1.5 text-[11px]">
|
||||
<div className="flex justify-between"><span className="text-hint">최소 거리</span><span className="text-heading font-medium">{tr.dist}m</span></div>
|
||||
<div className="flex justify-between"><span className="text-hint">접촉 시간</span><span className="text-heading font-medium">{tr.dur}분</span></div>
|
||||
<div className="flex justify-between"><span className="text-hint">평균 속도</span><span className="text-heading font-medium">{tr.spd}kn</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground mb-1">
|
||||
<MapPin className="w-3 h-3" />위치
|
||||
</div>
|
||||
<div className="text-heading text-sm font-medium">{tr.loc}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="bg-purple-950/30 border border-purple-900/30 rounded-xl p-3">
|
||||
<div className="text-[11px] text-purple-400 mb-1.5">환적 의심도</div>
|
||||
<div className="h-2 bg-switch-background rounded-full overflow-hidden mb-1.5">
|
||||
<div
|
||||
className="h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||
style={{ width: `${tr.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-heading">{tr.score}%</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-sm py-2.5 rounded-lg transition-colors">
|
||||
상세 분석 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</span>
|
||||
<span className="text-[8px]">
|
||||
<span className="text-blue-400 font-bold">UTC</span>
|
||||
<span className="text-label font-mono ml-1">{new Date().toISOString().substring(0, 19).replace('T', ' ')}</span>
|
||||
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "시스템 관리"
|
||||
|
||||
@ -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<string, number> | null;
|
||||
byZone: Record<string, number> | null;
|
||||
byRiskLevel: Record<string, number> | null;
|
||||
byGearType: Record<string, number> | null;
|
||||
byViolationType: Record<string, number> | null;
|
||||
aiAccuracyPct: number | null;
|
||||
}
|
||||
|
||||
export interface PredictionStatsHourly {
|
||||
statHour: string;
|
||||
totalDetections: number;
|
||||
eventCount: number;
|
||||
criticalCount: number;
|
||||
byCategory: Record<string, number> | null;
|
||||
byZone: Record<string, number> | null;
|
||||
byRiskLevel: Record<string, number> | null;
|
||||
}
|
||||
|
||||
export async function getDailyStats(
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<PredictionStatsDaily[]> {
|
||||
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<PredictionStatsHourly[]> {
|
||||
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에서 사용) */
|
||||
|
||||
53
frontend/src/shared/utils/dateFormat.ts
Normal file
53
frontend/src/shared/utils/dateFormat.ts
Normal file
@ -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}`;
|
||||
};
|
||||
@ -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<EnforcementStore>((set, get) => ({
|
||||
rawRecords: [],
|
||||
records: [],
|
||||
plans: [],
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
loading: false,
|
||||
@ -41,17 +37,10 @@ export const useEnforcementStore = create<EnforcementStore>((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,
|
||||
|
||||
@ -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<EventStore>((set, get) => ({
|
||||
rawEvents: [],
|
||||
events: [],
|
||||
alerts: [],
|
||||
stats: {},
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
@ -55,18 +51,11 @@ export const useEventStore = create<EventStore>((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,
|
||||
|
||||
@ -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<KpiStore>((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({
|
||||
|
||||
@ -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<TransferStore>((set) => ({
|
||||
transfers: [],
|
||||
loaded: false,
|
||||
load: () => set({ transfers: MOCK_TRANSFERS, loaded: true }),
|
||||
}));
|
||||
불러오는 중...
Reference in New Issue
Block a user