feat: 단속 계획에 단일 함정 순찰 작전·다함정 순찰 작전 탭 추가
- 단일 함정 순찰 작전: 4가지 작전 유형(정찰 순찰/긴급 출동/감시 초계/근접 차단), 가용 함정 현황, SOP 절차 - 다함정 순찰 작전: 4가지 작전 유형(포위 차단/광역 초계/합동 단속/호위), 역할 분담, 통신 프로토콜, SOP 절차 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
a238887322
커밋
8fe55474d9
@ -7,7 +7,11 @@ import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2,
|
||||
Navigation, Anchor, Radio, Target, Clock, Compass, Eye,
|
||||
MapPin, Zap, ChevronRight, CheckCircle, Activity,
|
||||
} from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
|
||||
@ -17,6 +21,64 @@ import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
||||
|
||||
type PlanTab = 'overview' | 'single' | 'multi';
|
||||
|
||||
// ─── 단일 함정 작전 데이터 ──────────────────
|
||||
|
||||
const SINGLE_OP_TYPES = [
|
||||
{ type: '정찰 순찰', desc: '특정 구역 내 불법조업 의심 선박 탐색·확인', duration: '4~8시간', crew: '8~12명', risk: '중간', icon: Eye },
|
||||
{ type: '긴급 출동', desc: 'CRITICAL 경보 발생 시 즉시 현장 투입', duration: '1~3시간', crew: '10~15명', risk: '높음', icon: Zap },
|
||||
{ type: '감시 초계', desc: '고위험 해역 정기 순찰 및 AIS 모니터링', duration: '6~12시간', crew: '8~10명', risk: '낮음', icon: Compass },
|
||||
{ type: '근접 차단', desc: '도주 의심 선박 접근·정선명령·검문검색', duration: '2~4시간', crew: '12~18명', risk: '높음', icon: Target },
|
||||
];
|
||||
|
||||
const SINGLE_SCENARIOS = [
|
||||
{ phase: '① 출항 전', actions: ['작전 브리핑 (위협 분석·해상상태)', '장비 점검 (레이더·AIS·카메라·무선)', '인력 배치 및 역할 분담'], time: '출항 60분 전' },
|
||||
{ phase: '② 이동·접근', actions: ['최적 경로 항해 (연료·시간 최적화)', 'AIS 실시간 추적 + 레이더 교차확인', '본부 상황실 위치 보고 (15분 주기)'], time: '이동 중' },
|
||||
{ phase: '③ 현장 작전', actions: ['정선명령 → 임검 또는 감시 유지', '증거 수집 (영상·사진·AIS 로그)', '위반 확인 시 나포 절차 진행'], time: '현장 도착 후' },
|
||||
{ phase: '④ 복귀·보고', actions: ['작전 결과 보고 (상황실 실시간 전송)', '증거물 봉인 및 인계', '작전 후 회고 (AAR) 기록'], time: '작전 종료 후' },
|
||||
];
|
||||
|
||||
const AVAILABLE_SHIPS = [
|
||||
{ name: '3009함', type: '1,000톤급', speed: '25kt', equip: '레이더·AIS·FLIR', status: '가용', crew: 18, zone: 'II구역' },
|
||||
{ name: '3012함', type: '500톤급', speed: '30kt', equip: '레이더·AIS·EO/IR', status: '가용', crew: 12, zone: 'III구역' },
|
||||
{ name: '1502함', type: '250톤급', speed: '28kt', equip: '레이더·AIS', status: '초계중', crew: 10, zone: 'I구역' },
|
||||
{ name: '1507함', type: '250톤급', speed: '28kt', equip: '레이더·AIS·소나', status: '가용', crew: 10, zone: 'IV구역' },
|
||||
{ name: '523정', type: '100톤급', speed: '32kt', equip: '레이더·AIS', status: '정비중', crew: 8, zone: '-' },
|
||||
{ name: '527정', type: '100톤급', speed: '33kt', equip: '레이더·AIS·드론', status: '가용', crew: 8, zone: 'II구역' },
|
||||
];
|
||||
|
||||
// ─── 다함정 작전 데이터 ──────────────────
|
||||
|
||||
const MULTI_OP_TYPES = [
|
||||
{ type: '포위 차단 작전', desc: '2~4척이 불법 선단을 포위하여 도주로 차단', ships: '3~4척', formation: '삼각·사각 포위', command: '지휘함 1 + 차단함 2~3', icon: Target },
|
||||
{ type: '광역 초계 작전', desc: '넓은 해역을 분할하여 동시 순찰', ships: '4~6척', formation: '구역 분할 병렬', command: '지휘함 1 + 순찰함 3~5', icon: Compass },
|
||||
{ type: '합동 단속 작전', desc: '항공·함정 협동으로 고위험 해역 집중 단속', ships: '3~5척 + 항공 1', formation: '항공 유도 + 수상 차단', command: '합동지휘소', icon: Shield },
|
||||
{ type: '호위 작전', desc: '아국 어선 보호 및 불법어선 접근 억제', ships: '2~3척', formation: '호위 종대', command: '지휘함 1 + 호위함 1~2', icon: Ship },
|
||||
];
|
||||
|
||||
const MULTI_ROLES = [
|
||||
{ role: '지휘함', duty: '작전 총괄·의사결정·상황실 보고', requirement: '500톤급 이상', comm: 'VHF Ch.16 + 보안채널', badge: 'critical' as const },
|
||||
{ role: '차단함', duty: '도주로 차단·정선명령 집행', requirement: '250톤급 이상', comm: 'VHF + AIS 공유', badge: 'high' as const },
|
||||
{ role: '감시함', duty: '원거리 레이더·FLIR 감시, 증거 촬영', requirement: '100톤급 이상', comm: 'VHF + 영상 전송', badge: 'info' as const },
|
||||
{ role: '기동함', duty: '고속 접근·소형선박 추적·인력 투입', requirement: '100톤급 고속정', comm: 'VHF + 전술채널', badge: 'warning' as const },
|
||||
];
|
||||
|
||||
const MULTI_COMM_PROTOCOL = [
|
||||
{ channel: 'VHF Ch.16', purpose: '국제 조난·호출 (공용)', encryption: '비암호', usage: '초기 접촉·정선명령' },
|
||||
{ channel: 'VHF Ch.22', purpose: '함정 간 전술 통신', encryption: '비암호', usage: '작전 기동·위치 보고' },
|
||||
{ channel: '보안 채널 (HF)', purpose: '지휘함-상황실 암호 통신', encryption: 'AES-256', usage: '작전 지시·기밀 보고' },
|
||||
{ channel: 'AIS 공유', purpose: '실시간 위치·속도 동기화', encryption: '-', usage: '함정 간 위치 인식' },
|
||||
{ channel: '위성 데이터링크', purpose: '원거리 영상·데이터 전송', encryption: 'TLS 1.3', usage: '상황실 실시간 전송' },
|
||||
];
|
||||
|
||||
const MULTI_SCENARIOS = [
|
||||
{ phase: '① 작전 계획', tasks: ['AI 위험도 분석 기반 작전 구역 선정', '함정 배치 및 역할 분담', '통신 채널·보고 주기 확정', '기상·해황·조류 분석'], commander: '작전지휘관' },
|
||||
{ phase: '② 전개·배치', tasks: ['각 함정 지정 위치로 이동', '포위망 형성 또는 구역 분할 완료', '지휘함 위치 확인 보고', 'AIS 상호 추적 개시'], commander: '지휘함장' },
|
||||
{ phase: '③ 작전 수행', tasks: ['감시함 탐지 → 지휘함 판단 → 차단함 투입', '정선명령·임검·나포 절차', '도주 시 기동함 추적 + 차단함 우회', '증거 수집·실시간 상황실 전송'], commander: '현장지휘관' },
|
||||
{ phase: '④ 철수·평가', tasks: ['나포 선박 호송 또는 감시 해제', '전 함정 귀항 또는 재배치', '합동 작전 보고서(AAR) 작성', '작전 데이터 DB 기록 (AI 학습용)'], commander: '작전지휘관' },
|
||||
];
|
||||
|
||||
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
|
||||
|
||||
/** API 응답 → 화면용 Plan 변환 */
|
||||
@ -54,6 +116,7 @@ export function EnforcementPlan() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const [tab, setTab] = useState<PlanTab>('overview');
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -145,7 +208,23 @@ export function EnforcementPlan() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 로딩/에러 상태 */}
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
{([
|
||||
{ key: 'overview' as PlanTab, icon: Calendar, label: '단속 계획' },
|
||||
{ key: 'single' as PlanTab, icon: Navigation, label: '단일 함정 순찰 작전' },
|
||||
{ key: 'multi' as PlanTab, icon: Anchor, label: '다함정 순찰 작전' },
|
||||
]).map((t) => (
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-orange-400 border-orange-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── ① 단속 계획 (기존) ── */}
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
{loading && (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">단속 계획을 불러오는 중...</div>
|
||||
)}
|
||||
@ -165,7 +244,6 @@ export function EnforcementPlan() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 미배정 CRITICAL 이벤트 */}
|
||||
{criticalEvents.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@ -205,11 +283,9 @@ export function EnforcementPlan() {
|
||||
</Card>
|
||||
<DataTable data={PLANS} columns={cols} pageSize={10} searchPlaceholder="구역, 함정명 검색..." searchKeys={['zone', 'ships']} exportFilename="단속계획" />
|
||||
|
||||
{/* 단속 구역 지도 */}
|
||||
<Card>
|
||||
<CardContent className="p-0 relative">
|
||||
<BaseMap ref={mapRef} center={[36.2, 126.0]} zoom={7} height={420} className="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">
|
||||
@ -228,6 +304,237 @@ export function EnforcementPlan() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ② 단일 함정 순찰 작전 ── */}
|
||||
{tab === 'single' && (
|
||||
<div className="space-y-3">
|
||||
{/* 작전 유형 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{SINGLE_OP_TYPES.map((op) => (
|
||||
<Card key={op.type} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<op.icon className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-[12px] font-bold text-heading">{op.type}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-hint mb-3">{op.desc}</p>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
['소요시간', op.duration],
|
||||
['필요인력', op.crew],
|
||||
['위험수준', op.risk],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||
<span className="text-muted-foreground">{k}</span>
|
||||
<span className="text-heading font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 가용 함정 현황 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ship className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">가용 함정 현황</span>
|
||||
<Badge intent="info" size="xs">{AVAILABLE_SHIPS.filter(s => s.status === '가용').length}척 가용</Badge>
|
||||
</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">함정명</th>
|
||||
<th className="text-center py-2">함급</th>
|
||||
<th className="text-center py-2">최대속력</th>
|
||||
<th className="text-left py-2">장비</th>
|
||||
<th className="text-center py-2">승조원</th>
|
||||
<th className="text-center py-2">배치 구역</th>
|
||||
<th className="text-center py-2">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{AVAILABLE_SHIPS.map((s) => (
|
||||
<tr key={s.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-bold">{s.name}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{s.type}</td>
|
||||
<td className="py-2 text-center text-cyan-400 font-mono">{s.speed}</td>
|
||||
<td className="py-2 text-muted-foreground text-[9px]">{s.equip}</td>
|
||||
<td className="py-2 text-center text-heading">{s.crew}명</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{s.zone}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getStatusIntent(s.status)} size="xs">{s.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 단일 함정 작전 절차 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">단일 함정 작전 절차 (SOP)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{SINGLE_SCENARIOS.map((s, i) => (
|
||||
<div key={s.phase} className="relative">
|
||||
{i < SINGLE_SCENARIOS.length - 1 && (
|
||||
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||
)}
|
||||
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[11px] font-bold text-orange-400">{s.phase}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-cyan-400 mb-2">{s.time}</div>
|
||||
<ul className="space-y-1.5">
|
||||
{s.actions.map((a) => (
|
||||
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>{a}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ③ 다함정 순찰 작전 ── */}
|
||||
{tab === 'multi' && (
|
||||
<div className="space-y-3">
|
||||
{/* 작전 유형 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{MULTI_OP_TYPES.map((op) => (
|
||||
<Card key={op.type} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<op.icon className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">{op.type}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-hint mb-3">{op.desc}</p>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
['투입 함정', op.ships],
|
||||
['대형', op.formation],
|
||||
['지휘 체계', op.command],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||
<span className="text-muted-foreground">{k}</span>
|
||||
<span className="text-heading font-medium text-[9px]">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 함정 역할 분담 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">함정별 역할 분담</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{MULTI_ROLES.map((r) => (
|
||||
<div key={r.role} className="bg-surface-overlay rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge intent={r.badge} size="sm">{r.role}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-heading">{r.duty}</div>
|
||||
<div className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||
<span className="text-muted-foreground">요구 사양</span>
|
||||
<span className="text-label">{r.requirement}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||
<span className="text-muted-foreground">통신</span>
|
||||
<span className="text-label">{r.comm}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 통신 프로토콜 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Radio className="w-4 h-4 text-green-400" />
|
||||
<span className="text-[12px] font-bold text-heading">함정 간 통신 프로토콜</span>
|
||||
</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="text-hint border-b border-border">
|
||||
<th className="text-left py-2 px-2">채널</th>
|
||||
<th className="text-left py-2">용도</th>
|
||||
<th className="text-center py-2">암호화</th>
|
||||
<th className="text-left py-2">사용 시점</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MULTI_COMM_PROTOCOL.map((c) => (
|
||||
<tr key={c.channel} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2 text-heading font-medium">{c.channel}</td>
|
||||
<td className="py-2 text-muted-foreground">{c.purpose}</td>
|
||||
<td className="py-2 text-center">
|
||||
<Badge intent={c.encryption === 'AES-256' || c.encryption === 'TLS 1.3' ? 'success' : 'muted'} size="xs">{c.encryption}</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">{c.usage}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 다함정 합동 작전 절차 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-[12px] font-bold text-heading">다함정 합동 작전 절차 (SOP)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{MULTI_SCENARIOS.map((s, i) => (
|
||||
<div key={s.phase} className="relative">
|
||||
{i < MULTI_SCENARIOS.length - 1 && (
|
||||
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||
)}
|
||||
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
|
||||
<Badge intent="muted" size="xs">{s.commander}</Badge>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{s.tasks.map((t) => (
|
||||
<li key={t} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>{t}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user