kcg-ai-monitoring/src/features/vessel/VesselDetail.tsx
htlee c0ce01eaf6 chore: 팀 워크플로우 기반 초기 프로젝트 구성
KCG AI 기반 불법조업 탐지·차단 플랫폼 프론트엔드.
React 19 + TypeScript 5.9 + Vite 8 + MapLibre + deck.gl + Zustand + Tailwind CSS.
SFR 20개 전체 UI 구현 완료, 백엔드 연동 대기.

- npm + Nexus 프록시 레지스트리 설정
- 팀 워크플로우 v1.6.1 부트스트랩 파일 배치
- .githooks (commit-msg, post-checkout)
- package.json name: kcg-ai-monitoring v0.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:11:29 +09:00

394 lines
20 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import {
Search, ChevronDown, ChevronUp, ChevronRight, Plus, X,
Ship, AlertTriangle, Radar, Anchor, MapPin, Printer,
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain
} from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
// TODO: 향후 store 통합 시 교체 — VesselDetail의 VesselTrack 형상(callSign, source, detail 등)이
// useVesselStore().vessels(VesselData)와 구조가 달라 현재는 인라인 데이터 유지
// ─── 선박 데이터 ──────────────────────
interface VesselTrack {
id: string;
mmsi: string;
callSign: string;
source: string;
name: string;
type: string;
country: string;
detail: Record<string, string>;
}
const VESSELS: VesselTrack[] = [
{
id: '1', mmsi: '440162980', callSign: '122@', source: 'AIS',
name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)',
detail: {
'청코드': '부산', '호출부호': '951554', '입항횟수': '006', '전송구분': '최종',
'선명': '태평양호', '선박종류': '어선', '총톤수': '30', '국제톤수': '30',
'입항일시': '2023-03-28 16:00', '계선장소': '기타 남항 사설조선소',
'전출항지': '2023-03-28 16:00', '전출항지항구명': '김천', '위험물톤수': '-',
'외내항구분': '내항', '입항수리일자': '2023-03-24',
'한국인선원수': '5', '외국인선원수': '9', '예선': 'N', '도선': 'N',
},
},
{
id: '2', mmsi: '440162923', callSign: '122@', source: 'AIS',
name: 'ZZ', type: 'V-Pass', country: 'Korea(Republic of)',
detail: {
'청코드': '인천', '호출부호': '862331', '입항횟수': '012', '전송구분': '최종',
'선명': '금강호', '선박종류': '어선', '총톤수': '45', '국제톤수': '45',
'입항일시': '2023-04-15 09:00', '계선장소': '인천항 제2부두',
'전출항지': '2023-04-15 09:00', '전출항지항구명': '인천', '위험물톤수': '-',
'외내항구분': '내항', '입항수리일자': '2023-04-10',
'한국인선원수': '3', '외국인선원수': '7', '예선': 'N', '도선': 'N',
},
},
];
// ─── 특이운항 / 비허가 선박 ──────────────
const ALERT_VESSELS = [
{ name: '제303 대양호', highlight: true },
{ name: '제609 한일호', highlight: false },
{ name: '한진아일랜드 고속훼리', highlight: false },
];
// ─── AI 조업 분석 데이터 ─────────────────
interface FishingAnalysis {
no: number;
mmsi: string;
name: string;
eezPermit: '허가' | '무허가';
vesselType: '어선' | '어구';
gearType: string;
gearIcon: string;
}
const FISHING_ANALYSIS: FishingAnalysis[] = [
{ no: 1, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '무허가', vesselType: '어구', gearType: '쌍끌이', gearIcon: '🚢' },
{ no: 2, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '범장망', gearIcon: '🚢' },
{ no: 3, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
{ no: 4, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
{ no: 5, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
];
const GEAR_FILTERS = ['외끌이', '쌍끌이', '트롤', '범장망', '형망', '채낚기', '통망'];
// ─── 지도 마커 ────────────────────────
const MAP_MARKERS = [
{ id: 'm1', x: 72, y: 38, label: '현재선박명', sensors: ['E', 'A', 'V'] },
{ id: 'm2', x: 65, y: 43, label: '현재선박명', sensors: ['V', 'B', 'A'] },
{ id: 'm3', x: 73, y: 49, label: '현재선박명', sensors: ['A', 'V', 'E'] },
];
const VTS_MARKERS = [{ id: 'vts1', x: 52, y: 63, label: '태안연안', sub: 'VTS 신호수신 선박명' }];
const PATROL_MARKERS = [
{ id: 'p1', x: 62, y: 63, label: 'E204', sub: '함정레이더 신호수신 선박명' },
{ id: 'p2', x: 80, y: 70, label: 'E204', sub: '함정레이더 신호수신 선박명' },
];
const CLUSTERS = [
{ x: 58, y: 22, n: 10 }, { x: 75, y: 30, n: 5 }, { x: 52, y: 55, n: 5 }, { x: 35, y: 68, n: 5 },
];
const RIGHT_TOOLS = [
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
];
// ─── 메인 컴포넌트 ────────────────────
export function VesselDetail() {
const [expandedId, setExpandedId] = useState<string | null>('2');
const [startDate, setStartDate] = useState('2023-08-20 11:30:02');
const [endDate, setEndDate] = useState('2023-08-20 11:30:02');
const [shipId, setShipId] = useState('');
const [showAiPanel, setShowAiPanel] = useState(false);
const [gearChecks, setGearChecks] = useState<Record<string, boolean>>({ '쌍끌이': true, '범장망': true });
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
// 관할해역 구역
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map(a => ({
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
})), 80000, 0.05),
// 등심선
...DEPTH_CONTOURS.map((contour, i) =>
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
})
),
// 선박 마커
createMarkerLayer('vessels', MAP_MARKERS.map((m): MarkerData => {
const lat = 34.2 + Math.random() * 2;
const lng = 125.5 + Math.random() * 3;
return { lat, lng, color: '#3b82f6', radius: 800, label: m.label };
})),
// VTS 마커
createMarkerLayer('vts', VTS_MARKERS.map((m): MarkerData => ({
lat: 34.0, lng: 126.2, color: '#eab308', radius: 800, label: m.label,
}))),
// 함정 마커
createMarkerLayer('patrols', PATROL_MARKERS.map((m): MarkerData => ({
lat: 33.5 + Math.random(), lng: 127.0 + Math.random(), color: '#a855f7', radius: 800, label: m.label,
}))),
// 클러스터
createMarkerLayer('clusters', CLUSTERS.map((c, i): MarkerData => ({
lat: 33.0 + i * 0.8, lng: 125.5 + i * 0.5, color: '#6b7280', radius: 2400, label: `${c.n}`,
}))),
// 선박충돌 알림
createMarkerLayer('alerts', [{
lat: 33.8, lng: 127.5, color: '#ef4444', radius: 1400, label: '선박충돌',
}]),
], []);
useMapLayers(mapRef, buildLayers, []);
const toggleGear = (g: string) => setGearChecks((p) => ({ ...p, [g]: !p[g] }));
return (
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
{/* ── 좌측: 항적조회 패널 ── */}
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
{/* 헤더: 검색 조건 */}
<div className="p-3 border-b border-border space-y-2">
<h2 className="text-sm font-bold text-heading"></h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" />
<span className="text-hint text-[10px]">~</span>
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" />
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0"></span>
<select className="bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none w-16">
<option></option><option>1</option><option>5</option><option>10</option><option>30</option>
</select>
<span className="text-[9px] text-hint ml-2 shrink-0">ID</span>
<input value={shipId} onChange={(e) => setShipId(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
</div>
<div className="flex items-center justify-between">
<button className="flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300">
<Plus className="w-3 h-3" />
</button>
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
</div>
{/* 선박 카드 */}
<div className="flex-1 overflow-y-auto">
{VESSELS.map((v) => {
const isOpen = expandedId === v.id;
return (
<div key={v.id} className="border-b border-border">
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-overlay cursor-pointer" onClick={() => setExpandedId(isOpen ? null : v.id)}>
<div className="flex-1 min-w-0">
<div className="text-[9px] text-hint">
ID | <span className="text-label">{v.mmsi}</span>
<span className="ml-2"> | <span className="text-label">{v.callSign}</span></span>
<span className="ml-2"> | <span className="text-label">{v.source}</span></span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[11px] font-bold text-heading">{v.name}</span>
<Badge className={`text-[7px] px-1 py-0 border-0 ${v.type === 'Fishing' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}>{v.type}</Badge>
</div>
<div className="text-[8px] text-hint mt-0.5 flex items-center gap-1">🇰🇷 {v.country}</div>
</div>
{isOpen ? <ChevronUp className="w-4 h-4 text-blue-400" /> : <ChevronDown className="w-4 h-4 text-hint" />}
</div>
{isOpen && (
<div className="px-3 pb-3">
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
{Object.entries(v.detail).map(([k, val], i) => (
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
<span className="flex-1 px-2.5 py-1.5 text-label">{val}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* ── 중앙: 지도 ── */}
<div className="flex-1 relative bg-card/40 overflow-hidden">
{/* 상단 패널: 특이운항 + 비허가/재제선박 */}
<div className="absolute top-3 left-3 z-10 flex gap-2">
{(['특이운항', '비허가/재제선박'] as const).map((title) => (
<div key={title} className="bg-card/95 backdrop-blur-sm rounded-lg border border-border w-52">
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
<span className="text-[10px] font-bold text-heading">{title}</span>
<ChevronDown className="w-3 h-3 text-hint" />
</div>
{ALERT_VESSELS.map((v, i) => (
<button key={i} className={`w-full flex items-center justify-between px-3 py-1.5 text-[9px] transition-colors ${v.highlight ? 'bg-red-600/80 text-heading' : 'text-label hover:bg-surface-overlay'}`}>
{v.name}<ChevronRight className="w-3 h-3 opacity-50" />
</button>
))}
</div>
))}
</div>
{/* AI 조업 분석 패널 (토글) */}
<button
onClick={() => setShowAiPanel(!showAiPanel)}
className="absolute top-3 right-14 z-20 flex items-center gap-1.5 bg-blue-600/90 backdrop-blur-sm text-heading rounded-lg px-3 py-1.5 text-[10px] font-bold hover:bg-blue-500 transition-colors shadow-lg"
>
<Brain className="w-3.5 h-3.5" />AI
</button>
{showAiPanel && (
<div className="absolute top-12 right-14 z-20 w-[480px] bg-input-background/98 backdrop-blur-md rounded-xl border border-blue-500/30 shadow-2xl shadow-blue-900/20 overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-overlay border-b border-border">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">AI </span>
</div>
<button onClick={() => setShowAiPanel(false)} className="text-hint hover:text-heading"><X className="w-4 h-4" /></button>
</div>
{/* 선택선박 + 조업식별 필터 */}
<div className="px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-1.5 text-[10px]">
<Anchor className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span className="text-heading font-bold text-sm">50</span>
<span className="text-hint"></span>
</div>
<button className="ml-auto bg-blue-600 text-heading rounded-lg px-4 py-1.5 text-[10px] font-bold flex items-center gap-1 hover:bg-blue-500 transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[9px] text-hint mr-1 flex items-center gap-1"><Radar className="w-3 h-3" /></span>
{GEAR_FILTERS.map((g) => (
<label key={g} className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox" checked={!!gearChecks[g]}
onChange={() => toggleGear(g)}
className="w-3 h-3 rounded border-slate-600 bg-secondary text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<span className="text-[9px] text-label">{g}</span>
</label>
))}
</div>
</div>
{/* 테이블 헤더 */}
<div className="grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-1.5 text-[9px] text-hint font-medium border-b border-border bg-surface-overlay">
<span></span>
<span>ID/</span>
<span>EEZ허가</span>
<span>/</span>
<span></span>
</div>
{/* 테이블 행 */}
<div className="max-h-[300px] overflow-y-auto">
{FISHING_ANALYSIS.map((row) => (
<div
key={row.no}
className={`grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-2.5 items-center border-b border-border hover:bg-surface-overlay cursor-pointer transition-colors ${row.no === 1 ? 'bg-surface-overlay' : ''}`}
>
<span className="text-[10px] text-muted-foreground">{row.no}</span>
<div>
<div className="text-[8px] text-hint">ID | {row.mmsi}</div>
<div className="text-[11px] font-bold text-heading">{row.name}</div>
</div>
<span className={`text-[10px] font-bold ${row.eezPermit === '무허가' ? 'text-red-400' : 'text-green-400'}`}>
{row.eezPermit}
</span>
<span className="text-[10px] text-label">{row.vesselType}</span>
<div className="flex items-center gap-1">
{row.gearType !== '-' && (
<Badge className={`text-[8px] px-1.5 py-0.5 border-0 ${
row.gearType === '쌍끌이' ? 'bg-orange-500/20 text-orange-400'
: row.gearType === '범장망' ? 'bg-purple-500/20 text-purple-400'
: 'bg-muted text-muted-foreground'
}`}>
{row.gearIcon && <span className="mr-0.5">{row.gearIcon}</span>}
{row.gearType}
</Badge>
)}
{row.gearType === '-' && <span className="text-[10px] text-hint">-</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* MapLibre GL + deck.gl 지도 */}
<BaseMap
ref={mapRef}
center={[34.5, 126.5]}
zoom={7}
height="100%"
/>
{/* 하단 좌표 바 */}
<div className="absolute bottom-0 left-0 right-0 h-6 bg-background/90 backdrop-blur-sm border-t border-border flex items-center justify-center gap-4 px-4 z-[1000]">
<span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">34.5000</span>
</span>
<span className="flex items-center gap-1 text-[8px]">
<MapPin className="w-2.5 h-2.5 text-green-400" />
<span className="text-hint"></span>
<span className="text-green-400 font-mono font-bold">126.5000</span>
</span>
<span className="text-[8px]">
<span className="text-blue-400 font-bold">UTC</span>
<span className="text-label font-mono ml-1">2023-07-10() 12:32:45</span>
</span>
<span className="ml-auto text-[7px] text-hint">8,531 | 0 25 50NM</span>
</div>
</div>
{/* ── 우측 도구바 ── */}
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
{RIGHT_TOOLS.map((t) => (
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
</button>
))}
<div className="flex-1" />
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
<div className="h-px bg-white/[0.06]" />
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
</div>
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
</div>
</div>
);
}