wing-ops/frontend/src/tabs/scat/components/ScatPopup.tsx
htlee c727afd1ba refactor(frontend): 대형 View 서브탭 단위 분할 + FEATURE_ID 체계 도입
6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를
서브탭 단위로 분할하여 모듈 경계를 명확히 함.

- AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등
- AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등
- ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등
- PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등
- AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등
- LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등

FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및
감사로그 서브탭 추적 훅(useFeatureTracking) 추가.

.gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:22 +09:00

327 lines
16 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { ScatDetail } from './scatTypes'
// ═══ Popup Map (Leaflet) ═══
function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
useEffect(() => {
if (!containerRef.current) return
// 이전 맵 제거
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
const map = L.map(containerRef.current, {
center: [lat, lng],
zoom: 15,
zoomControl: false,
attributionControl: false,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
// 해안 구간 라인 (시뮬레이션)
const segLine: [number, number][] = [
[lat - 0.002, lng - 0.004],
[lat - 0.001, lng - 0.002],
[lat, lng],
[lat + 0.001, lng + 0.002],
[lat + 0.002, lng + 0.004],
]
L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map)
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lat - 0.0015, lng - 0.003],
[lat - 0.0005, lng - 0.001],
[lat + 0.0005, lng + 0.001],
[lat + 0.0015, lng + 0.003],
]
L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map)
// 메인 마커
L.circleMarker([lat, lng], {
radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9,
}).bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${code} ${name}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${esi}</div>
</div>`,
{ permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' }
).addTo(map)
// 접근 포인트
L.circleMarker([lat - 0.0015, lng - 0.003], {
radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7,
}).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map)
mapRef.current = map
return () => { map.remove(); mapRef.current = null }
}, [lat, lng, esi, esiCol, code, name])
return <div ref={containerRef} className="w-full h-full" />
}
// ═══ SCAT Popup Modal ═══
interface ScatPopupProps {
data: ScatDetail | null
segCode: string
onClose: () => void
}
function ScatPopup({
data,
segCode,
onClose,
}: ScatPopupProps) {
const [popTab, setPopTab] = useState(0)
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
if (!data) return null
return (
<div className="fixed inset-0 bg-[rgba(5,8,18,0.75)] backdrop-blur-md z-[9999] flex items-center justify-center" onClick={onClose}>
<div
className="w-[92%] max-w-[1200px] h-[90vh] bg-bg-1 border border-border rounded-xl shadow-[0_24px_64px_rgba(0,0,0,0.5)] flex flex-col overflow-hidden"
style={{ animation: 'spIn 0.3s ease' }}
onClick={e => e.stopPropagation()}
>
<style>{`
@keyframes spIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
`}</style>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold text-status-green font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">{data.code}</span>
<span className="text-base font-bold font-korean">{data.name}</span>
<span className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white" style={{ background: data.esiColor }}>ESI {data.esi}</span>
</div>
<button onClick={onClose} className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-status-red hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"></button>
</div>
{/* Tabs */}
<div className="flex border-b border-border px-6 flex-shrink-0">
{['해안정보', '조사 이력'].map((label, i) => (
<button
key={i}
onClick={() => setPopTab(i)}
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
popTab === i ? 'text-status-green border-status-green' : 'text-text-3 border-transparent hover:text-text-2'
}`}
>
{label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-hidden">
{popTab === 0 && (
<div className="flex h-full overflow-hidden">
{/* Left column */}
<div className="flex-1 overflow-y-auto border-r border-border p-5 px-6 scrollbar-thin">
{/* 해안 조사 사진 */}
<div className="w-full bg-bg-0 border border-border rounded-md mb-4 relative overflow-hidden">
<img
src={`/scat-photos/${segCode}-1.png`}
alt={`${segCode} 해안 조사 사진`}
className="w-full h-auto object-contain"
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<div className="w-full aspect-video flex-col items-center justify-center text-text-3 text-xs font-korean hidden">
<span className="text-[40px]">📷</span>
<span> </span>
</div>
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
{segCode}
</div>
</div>
{/* Survey Info */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🏖
</div>
{[
['유형', data.type, ''],
['기질', data.substrate, data.esiColor === '#dc2626' || data.esiColor === '#991b1b' ? 'text-status-red' : data.esiColor === '#f97316' ? 'text-status-orange' : ''],
['구간 길이', data.length, ''],
['민감도', data.sensitivity, data.sensitivity === '상' || data.sensitivity === '최상' ? 'text-status-red' : data.sensitivity === '중' ? 'text-status-orange' : 'text-status-green'],
['조사 상태', data.status, data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : ''],
['접근성', data.access, ''],
['접근 포인트', data.accessPt, ''],
].map(([k, v, cls], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
<span className="text-text-2 font-korean">{k}</span>
<span className={`text-white font-semibold font-korean ${cls}`}>{v}</span>
</div>
))}
</div>
{/* Sensitive Resources */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🌿
</div>
{data.sensitive.map((s, i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
<span className="text-text-2 font-korean">{s.t}</span>
<span className="text-white font-semibold font-korean">{s.v}</span>
</div>
))}
</div>
{/* Cleanup Methods */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🧹
</div>
<div className="flex flex-wrap gap-1">
{data.cleanup.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-text-1 font-medium font-korean">{c}</span>
))}
</div>
</div>
{/* End Criteria */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
{data.endCriteria.map((e, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-status-yellow"></span>
{e}
</div>
))}
</div>
</div>
{/* Notes */}
<div>
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
📝
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
{data.notes.map((n, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-status-yellow"></span>
{n}
</div>
))}
</div>
</div>
</div>
{/* Right column - Satellite map */}
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
{/* Leaflet Map */}
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
<PopupMap lat={data.lat} lng={data.lng} esi={data.esi} esiCol={data.esiColor} code={data.code} name={data.name} />
</div>
{/* Legend */}
<div className="flex flex-wrap gap-1.5 mb-4">
{[
{ color: '#dc2626', label: 'ESI 고위험 구간' },
{ color: '#f97316', label: 'ESI 중위험 구간' },
{ color: '#22c55e', label: 'ESI 저위험 구간' },
{ color: '#3b82f6', label: '조사 경로' },
{ color: '#eab308', label: '접근 포인트' },
].map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean">
<span className="w-5 h-1 rounded-sm" style={{ background: item.color }} />
{item.label}
</div>
))}
</div>
{/* Coordinates */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
📍
</div>
{[
['시작점 위도', `${data.lat.toFixed(4)}°N`],
['시작점 경도', `${data.lng.toFixed(4)}°E`],
['끝점 위도', `${(data.lat + 0.005).toFixed(4)}°N`],
['끝점 경도', `${(data.lng + 0.008).toFixed(4)}°E`],
].map(([k, v], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
<span className="text-text-3 font-korean">{k}</span>
<span className="text-status-green font-mono font-medium">{v}</span>
</div>
))}
</div>
{/* Survey parameters */}
<div>
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
</div>
{[
['조사 일시', '2026-01-15 10:30'],
['조사팀', '제주해경 방제과'],
['기상 상태', '맑음, 풍속 3.2m/s'],
['조위', '중조 (TP +1.2m)'],
['파고', '0.5-1.0m'],
['수온', '14.2°C'],
].map(([k, v], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
<span className="text-text-3 font-korean">{k}</span>
<span className="text-text-1 font-medium font-korean">{v}</span>
</div>
))}
</div>
</div>
</div>
)}
{popTab === 1 && (
<div className="p-6 overflow-y-auto h-full scrollbar-thin">
<div className="text-sm font-bold font-korean mb-4">{data.code} {data.name} </div>
<div className="flex flex-col gap-3">
{[
{ date: '2026-01-15', team: '제주해경 방제과', type: 'Pre-SCAT', status: '완료', note: '초기 사전조사 실시. ESI 확인.' },
].map((h, i) => (
<div key={i} className="bg-bg-3 border border-border rounded-md p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold font-mono">{h.date}</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${
h.type === 'Pre-SCAT' ? 'bg-[rgba(34,197,94,0.15)] text-status-green' : 'bg-[rgba(59,130,246,0.15)] text-primary-blue'
}`}>
{h.type}
</span>
</div>
<div className="text-[11px] text-text-2 font-korean mb-1">: {h.team}</div>
<div className="text-[11px] text-text-3 font-korean">{h.note}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default ScatPopup