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:
htlee 2026-04-07 15:36:38 +09:00
부모 e12d1c33e2
커밋 19b1613157
47개의 변경된 파일452개의 추가작업 그리고 787개의 파일을 삭제

파일 보기

@ -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에서 사용) */

파일 보기

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