- 단일 함정 순찰 작전: 4가지 작전 유형(정찰 순찰/긴급 출동/감시 초계/근접 차단), 가용 함정 현황, SOP 절차 - 다함정 순찰 작전: 4가지 작전 유형(포위 차단/광역 초계/합동 단속/호위), 역할 분담, 통신 프로토콜, SOP 절차 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
580 lines
31 KiB
TypeScript
580 lines
31 KiB
TypeScript
import { useState, useEffect, 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 { Button } from '@shared/components/ui/button';
|
|
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, EyeOff, RefreshCw,
|
|
Navigation, Anchor, Radio, Target, Clock, Compass, Eye,
|
|
MapPin, Zap, ChevronRight, CheckCircle, Activity,
|
|
} from 'lucide-react';
|
|
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
|
import type { MarkerData } from '@lib/map';
|
|
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
|
|
import { getEvents, type PredictionEvent } from '@/services/event';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
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 변환 */
|
|
function toPlan(p: EnforcementPlanApi): Plan {
|
|
return {
|
|
id: p.planUid,
|
|
zone: p.areaName ?? p.zoneCode ?? '-',
|
|
lat: p.lat ?? 0,
|
|
lng: p.lon ?? 0,
|
|
risk: p.riskScore ?? 0,
|
|
period: p.plannedDate,
|
|
ships: `${p.assignedShipCount}척`,
|
|
crew: p.assignedCrew,
|
|
status: p.status,
|
|
alert: p.alertStatus ?? '-',
|
|
};
|
|
}
|
|
|
|
const cols: DataColumn<Plan>[] = [
|
|
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
|
{ key: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
|
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
|
render: v => { const n = v as number; return <Badge intent={getRiskIntent(n)} size="xs">{n}점</Badge>; } },
|
|
{ key: 'period', label: '단속 시간', width: '160px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
|
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
|
|
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
|
|
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
|
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
|
|
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
|
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
|
];
|
|
|
|
export function EnforcementPlan() {
|
|
const { t } = useTranslation('enforcement');
|
|
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);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
Promise.all([
|
|
getEnforcementPlans({ size: 100 }),
|
|
getEvents({ level: 'CRITICAL', status: 'NEW', size: 20 }).catch(() => null),
|
|
])
|
|
.then(([planRes, evtRes]) => {
|
|
if (!cancelled) {
|
|
setPlans(planRes.content.map(toPlan));
|
|
setCriticalEvents(evtRes?.content ?? []);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setLoading(false);
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const PLANS = plans;
|
|
|
|
const mapRef = useRef<MapHandle>(null);
|
|
|
|
const buildLayers = useCallback(() => [
|
|
...createStaticLayers(),
|
|
createRadiusLayer(
|
|
'ep-radius-confirmed',
|
|
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({
|
|
lat: p.lat,
|
|
lng: p.lng,
|
|
radius: 20000,
|
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
|
})),
|
|
0.15,
|
|
),
|
|
createRadiusLayer(
|
|
'ep-radius-planned',
|
|
PLANS.filter(p => p.status !== '확정' && p.status !== 'CONFIRMED').map(p => ({
|
|
lat: p.lat,
|
|
lng: p.lng,
|
|
radius: 20000,
|
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
|
})),
|
|
0.06,
|
|
),
|
|
createMarkerLayer(
|
|
'ep-markers',
|
|
PLANS.map(p => ({
|
|
lat: p.lat,
|
|
lng: p.lng,
|
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
|
radius: 1000,
|
|
label: `${p.id} ${p.zone}`,
|
|
} as MarkerData)),
|
|
),
|
|
], [PLANS]);
|
|
|
|
useMapLayers(mapRef, buildLayers, [PLANS]);
|
|
|
|
// 통계 요약값
|
|
const todayCount = PLANS.length;
|
|
const alertCount = PLANS.filter(p => p.alert === '경보 발령' || p.alert === 'ALERT').length;
|
|
const totalShips = PLANS.reduce((sum, p) => {
|
|
const num = parseInt(p.ships, 10);
|
|
return sum + (isNaN(num) ? 0 : num);
|
|
}, 0);
|
|
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Shield}
|
|
iconColor="text-orange-400"
|
|
title={t('enforcementPlan.title')}
|
|
description={t('enforcementPlan.desc')}
|
|
actions={
|
|
<Button variant="primary" size="md" icon={<Plus className="w-3.5 h-3.5" />}>
|
|
단속 계획 수립
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{/* 탭 */}
|
|
<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>
|
|
{/* 탐지 기반 단속 대상 (CRITICAL 이벤트 통합) */}
|
|
{criticalEvents.length > 0 && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<AlertTriangle className="w-4 h-4 text-red-400" />
|
|
<span className="text-[12px] font-bold text-heading">탐지 기반 단속 대상</span>
|
|
<Badge intent="critical" size="xs">{criticalEvents.length}건</Badge>
|
|
<span className="text-[9px] text-hint ml-auto">다크베셀 · 환적 · EEZ 침범 · 고위험 선박</span>
|
|
</div>
|
|
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
|
{criticalEvents.map((evt) => {
|
|
const cat = evt.category ?? '';
|
|
const catIcon = cat === 'DARK_VESSEL' ? EyeOff
|
|
: cat === 'ILLEGAL_TRANSSHIP' ? RefreshCw
|
|
: cat === 'EEZ_INTRUSION' ? Shield
|
|
: AlertTriangle;
|
|
const catLabel = cat === 'DARK_VESSEL' ? '다크베셀'
|
|
: cat === 'ILLEGAL_TRANSSHIP' ? '환적 의심'
|
|
: cat === 'EEZ_INTRUSION' ? 'EEZ 침범'
|
|
: cat === 'HIGH_RISK_VESSEL' ? '고위험'
|
|
: cat || '기타';
|
|
const CatIcon = catIcon;
|
|
return (
|
|
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
|
|
<CatIcon className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
|
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
|
{catLabel}
|
|
</Badge>
|
|
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
|
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
|
|
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── ① 단속 계획 (기존) ── */}
|
|
{tab === 'overview' && (
|
|
<div className="space-y-3">
|
|
{loading && (
|
|
<div className="text-center text-muted-foreground text-sm py-4">단속 계획을 불러오는 중...</div>
|
|
)}
|
|
{error && (
|
|
<div className="text-center text-red-400 text-sm py-4">로드 실패: {error}</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
{[
|
|
{ l: '오늘 계획', v: `${todayCount}건`, c: 'text-heading', i: Calendar },
|
|
{ l: '경보 발령', v: `${alertCount}건`, c: 'text-red-400', i: AlertTriangle },
|
|
{ l: '투입 함정', v: `${totalShips}척`, c: 'text-cyan-400', i: Ship },
|
|
{ l: '투입 인력', v: `${totalCrew}명`, c: 'text-green-400', i: Users },
|
|
].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>
|
|
{criticalEvents.length > 0 && (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<AlertTriangle className="w-4 h-4 text-red-400" />
|
|
<span className="text-[12px] font-bold text-heading">미배정 CRITICAL 이벤트</span>
|
|
<Badge intent="critical" size="xs">{criticalEvents.length}건</Badge>
|
|
</div>
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{criticalEvents.map((evt) => (
|
|
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
|
|
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
|
{getAlertLevelLabel(evt.level, tc, lang)}
|
|
</Badge>
|
|
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
|
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
|
|
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="text-[12px] font-bold text-heading mb-3">경보 임계값 설정</div>
|
|
<div className="flex gap-4 text-[10px]">
|
|
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
|
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
|
<Badge intent="critical" size="sm">{k}</Badge>
|
|
<span className="text-muted-foreground">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</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">
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-red-500/70 border border-red-500" /><span className="text-[8px] text-muted-foreground">위험도 80+</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-orange-500/70 border border-orange-500" /><span className="text-[8px] text-muted-foreground">위험도 60~80</span></div>
|
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-yellow-500/70 border border-yellow-500" /><span className="text-[8px] text-muted-foreground">위험도 40~60</span></div>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
|
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border-2 border-orange-500/50 bg-orange-500/15" /><span className="text-[7px] text-hint">확정</span></div>
|
|
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border border-dashed border-orange-500/40 bg-orange-500/5" /><span className="text-[7px] text-hint">계획중</span></div>
|
|
</div>
|
|
</div>
|
|
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
|
<span className="text-[10px] text-orange-400 font-bold">{PLANS.length}개</span>
|
|
<span className="text-[9px] text-hint ml-1">단속 구역 배치</span>
|
|
</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>
|
|
);
|
|
}
|