## 시간 표시 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>
148 lines
7.9 KiB
TypeScript
148 lines
7.9 KiB
TypeScript
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
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: 단속요원 이용 모바일 대응 서비스 */
|
|
|
|
const PUSH_SETTINGS = [
|
|
{ name: 'EEZ 침범 경보', enabled: true }, { name: '다크베셀 탐지', enabled: true },
|
|
{ name: '불법환적 의심', enabled: true }, { name: '순찰경로 업데이트', enabled: false },
|
|
{ name: '기상 특보', enabled: true },
|
|
];
|
|
|
|
// 모바일 지도에 표시할 마커
|
|
const MOBILE_MARKERS = [
|
|
{ lat: 37.20, lng: 124.63, name: '鲁荣渔56555', type: 'alert', color: '#ef4444' },
|
|
{ lat: 37.75, lng: 125.02, name: '미상선박-A', type: 'dark', color: '#f97316' },
|
|
{ lat: 36.80, lng: 124.37, name: '冀黄港渔05001', type: 'suspect', color: '#eab308' },
|
|
{ lat: 37.45, lng: 125.30, name: '3009함(아군)', type: 'patrol', color: '#a855f7' },
|
|
];
|
|
|
|
export function MobileService() {
|
|
const { t } = useTranslation('fieldOps');
|
|
const mapRef = useRef<MapHandle>(null);
|
|
const { events, load } = useEventStore();
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const buildLayers = useCallback(() => [
|
|
createPolylineLayer('eez-simple', [
|
|
[38.5, 124.0], [37.0, 123.0], [36.0, 122.5], [35.0, 123.0],
|
|
], { color: '#ef4444', width: 1, opacity: 0.3, dashArray: [4, 4] }),
|
|
createMarkerLayer('mobile-markers', MOBILE_MARKERS.map(m => ({
|
|
lat: m.lat, lng: m.lng, color: m.color,
|
|
} as MarkerData)), '#3b82f6', 800),
|
|
], []);
|
|
useMapLayers(mapRef, buildLayers, []);
|
|
|
|
const ALERTS = useMemo(
|
|
() =>
|
|
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,
|
|
})),
|
|
[events],
|
|
);
|
|
|
|
return (
|
|
<div className="p-5 space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}</h2>
|
|
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{/* 모바일 프리뷰 */}
|
|
<Card>
|
|
<CardContent className="p-4 flex justify-center">
|
|
<div className="w-[220px] h-[420px] bg-background border-2 border-slate-600 rounded-[24px] overflow-hidden relative">
|
|
{/* 상태바 */}
|
|
<div className="h-6 bg-secondary flex items-center justify-center"><span className="text-[8px] text-hint">해경 모바일 앱</span></div>
|
|
<div className="p-3 space-y-2">
|
|
{/* 긴급 경보 */}
|
|
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
|
|
<div className="text-[9px] text-red-400 font-bold">[긴급] EEZ 침범 탐지</div>
|
|
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
|
|
</div>
|
|
{/* 지도 영역 — MapLibre GL */}
|
|
<div className="rounded-lg overflow-hidden relative" style={{ height: 128 }}>
|
|
<BaseMap
|
|
ref={mapRef}
|
|
center={[37.2, 125.0]}
|
|
zoom={8}
|
|
height={128}
|
|
interactive={false}
|
|
/>
|
|
{/* 미니 범례 */}
|
|
<div className="absolute bottom-1 right-1 z-[1000] bg-black/70 rounded px-1.5 py-0.5 flex items-center gap-1.5">
|
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-[6px] text-muted-foreground">침범</span></span>
|
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-[6px] text-muted-foreground">다크</span></span>
|
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-purple-500" /><span className="text-[6px] text-muted-foreground">아군</span></span>
|
|
</div>
|
|
</div>
|
|
{/* KPI 그리드 */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{[['위험도', '87점'], ['의심선박', '3척'], ['추천경로', '2건'], ['경보', '5건']].map(([k, v]) => (
|
|
<div key={k} className="bg-surface-overlay rounded p-1.5 text-center">
|
|
<div className="text-[10px] text-heading font-bold">{v}</div>
|
|
<div className="text-[7px] text-hint">{k}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* 최근 알림 */}
|
|
<div className="space-y-1">
|
|
{ALERTS.slice(0, 2).map((a, i) => (
|
|
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
|
|
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
|
|
<span className="text-[8px] text-label truncate">{a.title}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 홈바 */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-5 bg-secondary rounded-b-[22px]" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 기능 설명 + 푸시 설정 */}
|
|
<div className="col-span-2 space-y-3">
|
|
<Card><CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3">모바일 주요 기능</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{[{ icon: AlertTriangle, name: '예측 정보 수신', desc: '불법행위 위험도, 의심 선박·어구 정보' },
|
|
{ icon: Navigation, name: '경로 추천', desc: 'AI 순찰 경로 수신 및 네비게이션' },
|
|
{ icon: MapPin, name: '지도 조회', desc: '해상 위치 확인·해양환경정보 간단 조회' },
|
|
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
|
|
].map(f => (
|
|
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
|
|
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
|
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent></Card>
|
|
<Card><CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" />푸시 알림 설정</div>
|
|
<div className="space-y-2">
|
|
{PUSH_SETTINGS.map(p => (
|
|
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
|
|
<span className="text-[11px] text-label">{p.name}</span>
|
|
<div className={`w-9 h-5 rounded-full relative ${p.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
|
|
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 shadow-sm" style={{ left: p.enabled ? '18px' : '2px' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent></Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|