wing-ops/frontend/src/tabs/incidents/components/MediaModal.tsx
htlee 46c7307ab9 feat(incidents): 사고관리 탭 mock → DB/API 전환
- DB: ACDNT, SPIL_DATA, PRED_EXEC, ACDNT_WEATHER, ACDNT_MEDIA 5개 테이블 생성
- 시드: 사고 12건, 유출정보 12건, 예측실행 18건, 기상 6건, 미디어 6건
- 백엔드: incidentsService + incidentsRouter (사고 목록/상세/예측/기상/미디어 5개 API)
- 프론트: IncidentsView, IncidentTable, IncidentsLeftPanel, MediaModal mock → API 전환
- mockIncidents, WEATHER_DATA, MEDIA_DATA 3개 mock 완전 제거
- SECTION_DATA, MOCK_SENSITIVE, mockVessels는 별도 도메인으로 유지

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

445 lines
25 KiB
TypeScript
Executable File

import { useState, useEffect } from 'react'
import type { Incident } from './IncidentsLeftPanel'
import { fetchIncidentMedia } from '../services/incidentsApi'
import type { MediaInfo } from '../services/incidentsApi'
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'
const MEDIA_TABS: { id: MediaTab; label: string; icon: string }[] = [
{ id: 'all', label: '전체', icon: '' },
{ id: 'photo', label: '사진', icon: '📷' },
{ id: 'video', label: '영상', icon: '🎬' },
{ id: 'satellite', label: '위성', icon: '🛰' },
{ id: 'cctv', label: 'CCTV', icon: '📹' },
]
function str(obj: Record<string, unknown> | null, key: string, fallback = '—'): string {
if (!obj || obj[key] == null) return fallback;
return String(obj[key]);
}
function num(obj: Record<string, unknown> | null, key: string, fallback = 0): number {
if (!obj || obj[key] == null) return fallback;
return Number(obj[key]);
}
function bool(obj: Record<string, unknown> | null, key: string): boolean {
if (!obj || obj[key] == null) return false;
return Boolean(obj[key]);
}
/* ════════════════════════════════════════════════════
MediaModal
════════════════════════════════════════════════════ */
export function MediaModal({ incident, onClose }: { incident: Incident; onClose: () => void }) {
const [activeTab, setActiveTab] = useState<MediaTab>('all')
const [selectedCam, setSelectedCam] = useState(0)
const [media, setMedia] = useState<MediaInfo | null>(null)
useEffect(() => {
fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
}, [incident.id]);
// Timeline dots (UI constant)
const timelineDots = [
{ pct: 5, color: '#ef4444', label: incident.time },
{ pct: 30, color: '#ef4444', label: '' },
{ pct: 42, color: '#ef4444', label: '' },
{ pct: 58, color: '#f59e0b', label: '' },
{ pct: 78, color: '#ef4444', label: '' },
{ pct: 95, color: '#6b7280', label: '' },
]
if (!media) {
return (
<div onClick={(e) => { if (e.target === e.currentTarget) onClose() }} style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)',
}}>
<div style={{
width: 300, padding: 40, background: '#0d1117', border: '1px solid #30363d',
borderRadius: 14, textAlign: 'center', color: '#8b949e', fontFamily: 'var(--fK)', fontSize: 12,
}}>
...
</div>
</div>
)
}
const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt
const showPhoto = activeTab === 'all' || activeTab === 'photo'
const showVideo = activeTab === 'all' || activeTab === 'video'
const showSat = activeTab === 'all' || activeTab === 'satellite'
const showCctv = activeTab === 'all' || activeTab === 'cctv'
return (
<div onClick={(e) => { if (e.target === e.currentTarget) onClose() }} style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)',
}}>
<div style={{
width: '95vw', height: '92vh', maxWidth: 1600,
background: '#0d1117', border: '1px solid #30363d', borderRadius: 14,
overflow: 'hidden', display: 'flex', flexDirection: 'column',
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}>
{/* ── Header ─────────────────────────────────── */}
<div style={{
flexShrink: 0, padding: '12px 20px',
background: 'linear-gradient(135deg,#161b22,#0d1117)',
borderBottom: '1px solid #30363d',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 18 }}>📋</span>
<div>
<div style={{ fontSize: 14, fontWeight: 800, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{incident.name}
</div>
<div style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{incident.name} · {incident.date} · {media.photoCnt} / {media.videoCnt} / {media.satCnt} / CCTV {media.cctvCnt}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2 }}>
{MEDIA_TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
padding: '5px 12px', borderRadius: 6, fontSize: 11, fontWeight: activeTab === t.id ? 700 : 400,
fontFamily: 'var(--fK)', cursor: 'pointer', border: 'none',
background: activeTab === t.id ? 'rgba(168,85,247,0.15)' : 'transparent',
color: activeTab === t.id ? '#c084fc' : '#8b949e',
}}>
{t.icon ? `${t.icon} ${t.label}` : t.label}
</button>
))}
</div>
{/* Upload */}
<button style={{
padding: '5px 14px', borderRadius: 6, fontSize: 11, fontWeight: 700,
fontFamily: 'var(--fK)', cursor: 'pointer', border: 'none',
background: 'linear-gradient(135deg,rgba(168,85,247,0.3),rgba(168,85,247,0.15))',
color: '#c084fc',
}}>📤 </button>
{/* Close */}
<span onClick={onClose} style={{
fontSize: 18, cursor: 'pointer', color: '#8b949e', padding: '2px 6px',
borderRadius: 4,
}}></span>
</div>
</div>
{/* ── Timeline ────────────────────────────────── */}
<div style={{
flexShrink: 0, padding: '6px 20px', borderBottom: '1px solid #21262d',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fK)', whiteSpace: 'nowrap' }}>TIMELINE</span>
<div style={{ flex: 1, position: 'relative', height: 16 }}>
<div style={{ position: 'absolute', top: 7, left: 0, right: 0, height: 2, background: '#21262d', borderRadius: 1 }} />
{timelineDots.map((d, i) => (
<div key={i} style={{
position: 'absolute', left: `${d.pct}%`, top: 3,
width: 10, height: 10, borderRadius: '50%', background: d.color,
boxShadow: `0 0 6px ${d.color}`, transform: 'translateX(-5px)',
}} />
))}
</div>
<div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)', display: 'flex', gap: 8, whiteSpace: 'nowrap' }}>
<span style={{ color: '#ef4444' }}> </span>
<span style={{ color: '#f59e0b' }}> </span>
<span style={{ color: '#8b949e' }}> </span>
</div>
</div>
{/* ── 2x2 Grid Content ────────────────────────── */}
<div style={{
flex: 1, display: 'grid', overflow: 'hidden',
gridTemplateColumns: (showPhoto || showSat) && (showVideo || showCctv) ? '1fr 1fr' : '1fr',
gridTemplateRows: (showPhoto || showVideo) && (showSat || showCctv) ? '1fr 1fr' : '1fr',
gap: 1, background: '#21262d',
}}>
{/* ── Q1: 현장사진 ──────────────────────────── */}
{showPhoto && (
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Section header */}
<div style={{
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #21262d',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>📷</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{str(media.photoMeta, 'title', '현장 사진')}
</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
</div>
</div>
{/* Photo content */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 48, color: '#30363d' }}>📷</div>
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
</div>
{/* Thumbnails */}
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map((_, i) => (
<div key={i} style={{
width: 40, height: 36, borderRadius: 4,
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#30363d', cursor: 'pointer',
}}>📷</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span>
<span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D </span>
</div>
</div>
</div>
)}
{/* ── Q2: 드론 영상 ─────────────────────────── */}
{showVideo && (
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #21262d',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>🎬</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{str(media.droneMeta, 'title', '드론 영상')}
</span>
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700,
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
}}> REC</span>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 48, color: '#30363d' }}>🎬</div>
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
</div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')}
</div>
</div>
{/* Video controls */}
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}></span>
<div style={{
width: 28, height: 28, borderRadius: '50%', background: 'rgba(168,85,247,0.15)',
border: '1px solid rgba(168,85,247,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, color: '#c084fc', cursor: 'pointer',
}}></div>
<span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}></span>
<span style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>02:34 / {str(media.droneMeta, 'duration')}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
🎬 {num(media.droneMeta, 'videoCount')} · {str(media.droneMeta, 'stage')}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>📂 </span>
<span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D </span>
</div>
</div>
</div>
</div>
)}
{/* ── Q3: 위성영상 ──────────────────────────── */}
{showSat && (
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #21262d',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>🛰</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{str(media.satMeta, 'title', '위성영상')}
</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
</div>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{str(media.satMeta, 'detection') !== '—' && (
<div style={{
position: 'absolute', top: '15%', left: '10%', width: '55%', height: '60%',
border: '2px dashed #ef4444', borderRadius: 4,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<div style={{ position: 'absolute', top: -10, left: 8, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)', background: '#0d1117', padding: '0 4px' }}>
{str(media.satMeta, 'detection')}
</div>
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
<div style={{ fontSize: 11, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
{str(media.satMeta, 'title', '위성영상')}
</div>
<div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{str(media.satMeta, 'date')} · {str(media.satMeta, 'resolution')}
</div>
</div>
)}
{str(media.satMeta, 'detection') === '—' && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
<div style={{ fontSize: 11, color: '#8b949e', fontFamily: 'var(--fK)', marginTop: 8 }}> </div>
</div>
)}
</div>
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
{num(media.satMeta, 'thumbCount') > 0 && (
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
<div key={i} style={{
width: 40, height: 36, borderRadius: 4,
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
border: i === 0 ? '2px solid rgba(168,85,247,0.5)' : '1px solid #30363d',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#30363d', cursor: 'pointer',
}}>🛰</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
🛰 {num(media.satMeta, 'thumbCount')} · {str(media.satMeta, 'sensor')}
</span>
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔍 / </span>
</div>
</div>
</div>
)}
{/* ── Q4: CCTV ──────────────────────────────── */}
{showCctv && (
<div style={{ background: '#0d1117', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
flexShrink: 0, padding: '8px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #21262d',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>📹</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
CCTV {str(media.cctvMeta, 'title', 'CCTV')}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{bool(media.cctvMeta, 'live') && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700,
background: 'rgba(34,197,94,0.15)', color: '#22c55e',
}}> LIVE</span>
)}
<NavBtn label="↗" />
</div>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, position: 'relative' }}>
{bool(media.cctvMeta, 'live') && (
<div style={{ position: 'absolute', top: 10, left: 16, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)' }}>
LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
</div>
)}
<div style={{ fontSize: 48, color: '#30363d' }}>📹</div>
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
</div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} · {bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
</div>
</div>
{/* CAM buttons */}
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 6 }}>
{Array.from({ length: num(media.cctvMeta, 'camCount') }).map((_, i) => (
<button key={i} onClick={() => setSelectedCam(i)} style={{
padding: '6px 16px', borderRadius: 4, fontSize: 10, fontWeight: 600,
fontFamily: 'var(--fM)', cursor: 'pointer',
background: selectedCam === i ? 'rgba(168,85,247,0.12)' : '#161b22',
border: selectedCam === i ? '1px solid rgba(168,85,247,0.4)' : '1px solid #30363d',
color: selectedCam === i ? '#c084fc' : '#8b949e',
}}>CAM{i + 1}</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
📹 CCTV {num(media.cctvMeta, 'camCount')} · {str(media.cctvMeta, 'location')}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 8, color: '#ef4444', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔴 </span>
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🎥 PTZ</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* ── Bottom Bar ──────────────────────────────── */}
<div style={{
flexShrink: 0, padding: '8px 20px',
background: '#161b22', borderTop: '1px solid #30363d',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', gap: 16, fontSize: 10, fontFamily: 'var(--fM)', color: '#8b949e' }}>
<span>📷 <b style={{ color: '#f0f6fc' }}>{media.photoCnt}</b></span>
<span>🎬 <b style={{ color: '#f0f6fc' }}>{media.videoCnt}</b></span>
<span>🛰 <b style={{ color: '#f0f6fc' }}>{media.satCnt}</b></span>
<span>📹 CCTV <b style={{ color: '#f0f6fc' }}>{media.cctvCnt}</b></span>
<span>📎 <b style={{ color: '#c084fc' }}>{total}</b></span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<BottomBtn icon="📥" label="다운로드" bg="rgba(100,116,139,0.1)" bd="rgba(100,116,139,0.2)" fg="#8b949e" />
<BottomBtn icon="📝" label="보고서" bg="rgba(59,130,246,0.1)" bd="rgba(59,130,246,0.2)" fg="#58a6ff" />
<BottomBtn icon="🔗" label="R&D 분석 연계" bg="rgba(168,85,247,0.1)" bd="rgba(168,85,247,0.25)" fg="#c084fc" />
</div>
</div>
</div>
</div>
)
}
function NavBtn({ label }: { label: string }) {
return (
<button style={{
width: 22, height: 22, borderRadius: 4, fontSize: 10,
background: '#161b22', border: '1px solid #30363d', color: '#8b949e',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>{label}</button>
)
}
function BottomBtn({ icon, label, bg, bd, fg }: { icon: string; label: string; bg: string; bd: string; fg: string }) {
return (
<button style={{
padding: '6px 14px', borderRadius: 6, fontSize: 10, fontWeight: 700,
fontFamily: 'var(--fK)', cursor: 'pointer',
background: bg, border: `1px solid ${bd}`, color: fg,
display: 'flex', alignItems: 'center', gap: 4,
}}>{icon} {label}</button>
)
}