kcg-ai-monitoring/frontend/src/features/patrol/FleetOptimization.tsx
htlee 2e66f920a5 feat: prediction 알고리즘 재설계 + 프론트 CRUD 권한 가드 보완
- prediction: dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계
- prediction: vessel_store/scheduler/config 개선, monitoring_zones 데이터 추가
- prediction: signal_api 신규, diagnostic-snapshot 스크립트 추가
- frontend: 지도 레이어 구조 정리 (BaseMap, useMapLayers, static layers)
- frontend: NoticeManagement CRUD 권한 가드 추가 (admin:notices C/U/D)
- frontend: EventList CRUD 권한 가드 추가 (enforcement:event-list U, enforcement:enforcement-history C)
- frontend: 지도 페이지 6개 + Dashboard 등 4개 페이지 소폭 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:01:35 +09:00

234 lines
11 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { usePatrolStore } from '@stores/patrolStore';
/* SFR-08: AI 경비함정 다함정 협력형 경로 최적화 서비스 */
const FLEET_SHIP_IDS = ['P-3001', 'P-3005', 'P-3009', 'P-5001', 'P-1503'];
const FLEET_COLORS: Record<string, string> = {
'P-3001': '#ef4444',
'P-3005': '#f97316',
'P-3009': '#eab308',
'P-5001': '#22c55e',
'P-1503': '#64748b',
};
export function FleetOptimization() {
const { t } = useTranslation('patrol');
const { ships, coverage: COVERAGE, fleetRoutes: FLEET_ROUTES, load } = usePatrolStore();
useEffect(() => { load(); }, [load]);
const mapRef = useRef<MapHandle>(null);
const [simRunning, setSimRunning] = useState(false);
const [approved, setApproved] = useState(false);
const FLEET = useMemo(
() =>
ships
.filter((s) => FLEET_SHIP_IDS.includes(s.id))
.map((s) => ({
id: s.id,
name: s.name,
zone: s.zone ?? '-',
status: s.status === '정비중' ? '정비중' : ['추적중', '검문중', '초계중'].includes(s.status) ? '출동중' : '가용',
speed: s.speed > 0 ? `${s.speed}kt` : '-',
fuel: s.fuel,
eta: s.status === '정비중' ? '-' : ['추적중', '검문중', '초계중'].includes(s.status) ? '2h' : '즉시',
lat: s.lat,
lng: s.lng,
color: FLEET_COLORS[s.id] ?? '#64748b',
})),
[ships],
);
const buildLayers = useCallback(() => {
// 커버리지 영역 (최적화 후)
const coverageZones = COVERAGE.map((c) => ({
name: c.zone,
lat: c.lat,
lng: c.lng,
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
radiusM: c.radius,
}));
// 커버리지 라벨 마커
const coverageLabels = COVERAGE.map((c) => ({
lat: c.lat,
lng: c.lng,
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
radius: 500,
label: `${c.zone} ${c.optimized}%`,
}));
// 함정별 순찰 경로
const routeLayers = FLEET
.filter((f) => FLEET_ROUTES[f.id] && f.status !== '정비중')
.map((f) =>
createPolylineLayer(`route-${f.id}`, FLEET_ROUTES[f.id], {
color: f.color,
width: 2.5,
opacity: 0.7,
dashArray: f.status === '출동중' ? [6, 4] : undefined,
}),
);
// 함정 마커
const fleetMarkers = FLEET.map((f) => ({
lat: f.lat,
lng: f.lng,
color: f.color,
radius: f.status !== '정비중' ? 1200 : 600,
}));
return [
...createStaticLayers(),
createZoneLayer('coverage', coverageZones, 30000, 0.12),
createMarkerLayer('coverage-labels', coverageLabels),
...routeLayers,
createMarkerLayer('fleet-markers', fleetMarkers),
];
}, [FLEET, COVERAGE, FLEET_ROUTES]);
useMapLayers(mapRef, buildLayers, [ships, COVERAGE, FLEET_ROUTES]);
return (
<PageContainer>
<PageHeader
icon={Users}
iconColor="text-purple-400"
title={t('fleetOptimization.title')}
description={t('fleetOptimization.desc')}
demo
actions={
<>
<Button variant="primary" size="sm" onClick={() => setSimRunning(true)} icon={<Play className="w-3 h-3" />}>
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setApproved(true)}
disabled={!simRunning}
icon={<CheckCircle className="w-3 h-3" />}
className="bg-green-600 hover:bg-green-500 border-green-700"
>
</Button>
</>
}
/>
{/* KPI */}
<div className="flex gap-2">
{[{ l: '투입 가능', v: `${FLEET.filter(f => f.status === '가용').length}`, c: 'text-green-400', i: Ship },
{ l: '커버리지(현재)', v: '52%', c: 'text-yellow-400', i: Target },
{ l: '커버리지(최적화)', v: '88%', c: 'text-cyan-400', i: Layers },
{ l: '효율 개선', v: '+36%p', c: 'text-purple-400', i: BarChart3 },
].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3">
{/* 함정 목록 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{FLEET.map(f => (
<div key={f.id} className={`px-3 py-2 rounded-lg ${f.status === '가용' ? 'bg-surface-overlay' : 'bg-surface-overlay opacity-50'}`}>
<div className="flex justify-between items-center">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: f.color }} />
<span className="text-[11px] font-bold text-heading">{f.name}</span>
</div>
<Badge intent={f.status === '출동중' ? 'warning' : getStatusIntent(f.status)} size="xs">{f.status}</Badge>
</div>
<div className="flex gap-3 mt-1 text-[9px] text-hint">
<span>: {f.zone}</span><span>: {f.speed}</span><span>: {f.fuel}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 커버리지 비교 */}
<Card className="col-span-2 bg-surface-raised border-border">
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> ( vs AI )</div>
<div className="space-y-3">
{COVERAGE.map(c => (
<div key={c.zone} className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">{c.zone}</span>
<span className="text-hint">{c.ships > 0 ? `${c.ships}척 배치` : '미배치'}</span>
</div>
<div className="flex gap-2 items-center">
<span className="text-[9px] text-hint w-8"></span>
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full bg-yellow-500 rounded-full" style={{ width: `${c.current}%` }} />
</div>
<span className="text-[9px] text-yellow-400 w-8 text-right">{c.current}%</span>
</div>
<div className="flex gap-2 items-center">
<span className="text-[9px] text-hint w-8"></span>
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
<div className="h-full bg-cyan-500 rounded-full" style={{ width: `${c.optimized}%` }} />
</div>
<span className="text-[9px] text-cyan-400 w-8 text-right">{c.optimized}%</span>
</div>
</div>
))}
</div>
{approved && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-400" />
<span className="text-[11px] text-green-400 font-bold"> (Human in the loop)</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* 해역 커버리지 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[36.0, 127.0]} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" />
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> · </div>
<div className="space-y-1">
{FLEET.filter(f => f.status !== '정비중').map(f => (
<div key={f.id} className="flex items-center gap-1.5">
<div className="w-4 h-0 border-t-2" style={{ borderColor: f.color }} />
<span className="text-[8px] text-muted-foreground">{f.name} ({f.zone})</span>
</div>
))}
</div>
<div className="mt-1.5 pt-1.5 border-t border-border space-y-0.5">
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-cyan-500/20 border border-cyan-500/40" /><span className="text-[8px] text-muted-foreground"> (90%+)</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-blue-500/15 border border-blue-500/30" /><span className="text-[8px] text-muted-foreground"> (70~90%)</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full border border-dashed border-slate-500/40" /><span className="text-[8px] text-muted-foreground"> </span></div>
</div>
</div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<Users className="w-3.5 h-3.5 text-purple-400" />
<span className="text-[10px] text-purple-400 font-bold">{FLEET.filter(f => f.status !== '정비중').length} </span>
<span className="text-[9px] text-hint"> 88%</span>
</div>
</CardContent>
</Card>
</PageContainer>
);
}