import { useState, useMemo, useRef, useEffect, forwardRef } from 'react' import { MediaModal } from './MediaModal' export interface Incident { id: string name: string status: 'active' | 'investigating' | 'closed' date: string time: string region: string office: string location: { lat: number; lon: number } causeType?: string oilType?: string prediction?: string vesselName?: string mediaCount?: number } interface IncidentsLeftPanelProps { incidents: Incident[] selectedIncidentId: string | null onIncidentSelect: (id: string) => void } const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const const REGIONS = ['전체', '남해청', '서해청', '동해청', '제주청', '중부청'] as const import { fetchIncidentWeather } from '../services/incidentsApi' import type { WeatherInfo } from '../services/incidentsApi' function formatDate(d: Date) { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${dd}` } function getPresetStartDate(preset: string): string { const now = new Date() switch (preset) { case '오늘': return formatDate(now) case '1주일': { const d = new Date(now); d.setDate(d.getDate() - 7); return formatDate(d) } case '1개월': { const d = new Date(now); d.setMonth(d.getMonth() - 1); return formatDate(d) } case '3개월': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return formatDate(d) } case '6개월': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return formatDate(d) } case '1년': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return formatDate(d) } default: return formatDate(now) } } export function IncidentsLeftPanel({ incidents, selectedIncidentId, onIncidentSelect }: IncidentsLeftPanelProps) { const today = formatDate(new Date()) const todayLabel = today.replace(/-/g, '-') const [searchTerm, setSearchTerm] = useState('') const [selectedPeriod, setSelectedPeriod] = useState('1개월') const [dateFrom, setDateFrom] = useState(getPresetStartDate('1개월')) const [dateTo, setDateTo] = useState(today) const [selectedRegion, setSelectedRegion] = useState('전체') const [selectedStatus, setSelectedStatus] = useState('전체') // Media modal const [mediaModalIncident, setMediaModalIncident] = useState(null) // Weather popup const [weatherPopupId, setWeatherPopupId] = useState(null) const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) const [weatherInfo, setWeatherInfo] = useState(null) const weatherRef = useRef(null) useEffect(() => { if (!weatherPopupId) return let cancelled = false fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => { if (!cancelled) setWeatherInfo(data) }) return () => { cancelled = true; setWeatherInfo(null) } }, [weatherPopupId]); useEffect(() => { if (!weatherPopupId) return const handler = (e: MouseEvent) => { const target = e.target as HTMLElement if (weatherRef.current && !weatherRef.current.contains(target) && !target.closest('.inc-wx-btn')) { setWeatherPopupId(null) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [weatherPopupId]) const handleWeatherClick = (e: React.MouseEvent, incId: string) => { e.stopPropagation() if (weatherPopupId === incId) { setWeatherPopupId(null) return } const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() let top = rect.bottom + 6 let left = rect.left if (top + 480 > window.innerHeight) top = rect.top - 480 if (left + 280 > window.innerWidth) left = window.innerWidth - 290 if (left < 10) left = 10 setWeatherPos({ top, left }) setWeatherPopupId(incId) } const filteredIncidents = useMemo(() => { return incidents.filter((incident) => { const matchesSearch = incident.name.toLowerCase().includes(searchTerm.toLowerCase()) || (incident.vesselName && incident.vesselName.toLowerCase().includes(searchTerm.toLowerCase())) const matchesRegion = selectedRegion === '전체' || incident.region === selectedRegion const matchesStatus = selectedStatus === '전체' || (selectedStatus === 'active' && incident.status === 'active') || (selectedStatus === 'investigating' && incident.status === 'investigating') || (selectedStatus === 'closed' && incident.status === 'closed') const matchesDate = incident.date >= dateFrom && incident.date <= dateTo return matchesSearch && matchesRegion && matchesStatus && matchesDate }) }, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]) const regionCounts = useMemo(() => { const dateFiltered = incidents.filter(i => { const matchesSearch = i.name.toLowerCase().includes(searchTerm.toLowerCase()) || (i.vesselName && i.vesselName.toLowerCase().includes(searchTerm.toLowerCase())) const matchesDate = i.date >= dateFrom && i.date <= dateTo return matchesSearch && matchesDate }) const counts: Record = { '전체': dateFiltered.length } REGIONS.forEach(r => { if (r !== '전체') counts[r] = dateFiltered.filter(i => i.region === r).length }) return counts }, [incidents, searchTerm, dateFrom, dateTo]) const statusCounts = useMemo(() => { const base = incidents.filter(i => { const matchesSearch = i.name.toLowerCase().includes(searchTerm.toLowerCase()) || (i.vesselName && i.vesselName.toLowerCase().includes(searchTerm.toLowerCase())) const matchesRegion = selectedRegion === '전체' || i.region === selectedRegion const matchesDate = i.date >= dateFrom && i.date <= dateTo return matchesSearch && matchesRegion && matchesDate }) return { active: base.filter(i => i.status === 'active').length, investigating: base.filter(i => i.status === 'investigating').length, closed: base.filter(i => i.status === 'closed').length, } }, [incidents, searchTerm, selectedRegion, dateFrom, dateTo]) // Pagination const pageSize = 6 const [currentPage, setCurrentPage] = useState(1) const totalPages = Math.max(1, Math.ceil(filteredIncidents.length / pageSize)) const safePage = Math.min(currentPage, totalPages) const pagedIncidents = filteredIncidents.slice((safePage - 1) * pageSize, safePage * pageSize) const resetPage = () => setCurrentPage(1) const handlePeriodClick = (preset: string) => { setSelectedPeriod(preset) setDateFrom(getPresetStartDate(preset)) setDateTo(today) resetPage() } return (
{/* Search */}
🔍 { setSearchTerm(e.target.value); resetPage() }} style={{ width: '100%', padding: '8px 12px 8px 32px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fK)', fontSize: '12px', outline: 'none', }} />
{/* Date Range */}
{ setDateFrom(e.target.value); setSelectedPeriod(''); resetPage() }} style={{ padding: '5px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: '11px', outline: 'none', flex: 1 }} /> ~ { setDateTo(e.target.value); setSelectedPeriod(''); resetPage() }} style={{ padding: '5px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: '11px', outline: 'none', flex: 1 }} />
{/* Period Presets */}
{PERIOD_PRESETS.map(p => ( ))}
{/* Today Summary */}
📅 오늘 ({todayLabel}) 사고 현황
{REGIONS.map(r => { const count = regionCounts[r] ?? 0 const isActive = selectedRegion === r return ( ) })}
{/* Status Filter */}
{[ { id: '전체', label: '전체', dot: '' }, { id: 'active', label: `대응중 (${statusCounts.active})`, dot: 'var(--red)' }, { id: 'investigating', label: `조사중 (${statusCounts.investigating})`, dot: 'var(--orange)' }, { id: 'closed', label: `종료 (${statusCounts.closed})`, dot: 'var(--t3)' }, ].map(s => ( ))}
{/* Count */}
총 {filteredIncidents.length}건
{/* Incident List */}
{pagedIncidents.length === 0 ? (
검색 결과가 없습니다.
) : pagedIncidents.map(inc => { const isSel = selectedIncidentId === inc.id const dotStyle: Record = { active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)', } const dotShadow: Record = { active: '0 0 6px var(--red)', investigating: '0 0 6px var(--orange)', closed: 'none', } const stBg: Record = { active: 'rgba(239,68,68,0.15)', investigating: 'rgba(249,115,22,0.15)', closed: 'rgba(100,116,139,0.15)', } const stColor: Record = { active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)', } const stLabel: Record = { active: '대응중', investigating: '조사중', closed: '종료', } return (
onIncidentSelect(inc.id)} style={{ padding: '12px 16px', borderBottom: '1px solid var(--bd)', cursor: 'pointer', background: isSel ? 'rgba(6,182,212,0.04)' : undefined, borderLeft: isSel ? '3px solid var(--cyan)' : '3px solid transparent', transition: 'background 0.15s', }} onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.background = 'rgba(255,255,255,0.02)' }} onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.background = '' }} > {/* Row 1: name + status */}
{inc.name}
{stLabel[inc.status]}
{/* Row 2: meta */}
📅 {inc.date} {inc.time} 🏛 {inc.office}
{/* Row 3: tags + buttons */}
{inc.causeType && ( {inc.causeType} )} {inc.oilType && ( {inc.oilType} )} {inc.prediction && ( {inc.prediction} )}
{(inc.mediaCount ?? 0) > 0 && ( )}
) })}
{/* Media Modal */} {mediaModalIncident && ( setMediaModalIncident(null)} /> )} {/* Weather Popup (fixed position) */} {weatherPopupId && weatherInfo && ( setWeatherPopupId(null)} /> )} {/* Pagination */}
{filteredIncidents.length}건 중 {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
setCurrentPage(1)} /> setCurrentPage(Math.max(1, safePage - 1))} /> {Array.from({ length: totalPages }, (_, i) => i + 1).filter(p => Math.abs(p - safePage) <= 2).map(p => ( setCurrentPage(p)} /> ))} = totalPages} onClick={() => setCurrentPage(Math.min(totalPages, safePage + 1))} /> = totalPages} onClick={() => setCurrentPage(totalPages)} />
) } function PgBtn({ label, active, disabled, onClick }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void }) { return ( ) } /* ════════════════════════════════════════════════════ WeatherPopup – 사고 위치 기상정보 팝업 ════════════════════════════════════════════════════ */ const WeatherPopup = forwardRef void }>(({ data, position, onClose }, ref) => { return (
{/* Header */}
🌤
{data.locNm}
{data.obsDtm}
{/* Body */}
{/* Main weather */}
{data.icon}
{data.temp}
{data.weatherDc}
{/* Detail grid */}
{/* Tide info */}
고조 (만조)
{data.highTide}
저조 (간조)
{data.lowTide}
{/* 24h Forecast */}
24h 예보
{data.forecast.map((f, i) => (
{f.hour}
{f.icon}
{f.temp}
))}
{/* Impact */}
⚠ 방제 작업 영향
{data.impactDc}
) }) WeatherPopup.displayName = 'WeatherPopup' function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) { return (
{icon}
{label}
{value}
) }