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 | null) => 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 }); // undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음 const [weatherInfo, setWeatherInfo] = useState(undefined); const weatherRef = useRef(null); useEffect(() => { if (!weatherPopupId) return; let cancelled = false; fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => { if (!cancelled) setWeatherInfo(data); }); return () => { cancelled = true; setWeatherInfo(undefined); }; }, [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(); }} className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-xs outline-none" style={{ borderRadius: 'var(--radius-sm)' }} />
{/* Date Range */}
{ setDateFrom(e.target.value); setSelectedPeriod(''); resetPage(); }} className="bg-bg-base border border-stroke font-mono text-label-2 outline-none flex-1" style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }} /> ~ { setDateTo(e.target.value); setSelectedPeriod(''); resetPage(); }} className="bg-bg-base border border-stroke font-mono text-label-2 outline-none flex-1" style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }} />
{/* 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(--color-danger)' }, { id: 'investigating', label: `조사중 (${statusCounts.investigating})`, dot: 'var(--color-warning)', }, { id: 'closed', label: `종료 (${statusCounts.closed})`, dot: 'var(--fg-disabled)' }, ].map((s) => ( ))}
{/* Count */}
총 {filteredIncidents.length}건
{/* Incident List */}
{pagedIncidents.length === 0 ? (
검색 결과가 없습니다.
) : ( pagedIncidents.map((inc) => { const isSel = selectedIncidentId === inc.id; const dotStyle: Record = { active: 'var(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)', }; const dotShadow: Record = { active: '0 0 6px var(--color-danger)', investigating: '0 0 6px var(--color-warning)', 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(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)', }; const stLabel: Record = { active: '대응중', investigating: '조사중', closed: '종료', }; return (
onIncidentSelect(isSel ? null : inc.id)} className="px-4 py-3 border-b border-stroke cursor-pointer" style={{ background: isSel ? 'rgba(6,182,212,0.04)' : undefined, borderLeft: isSel ? '3px solid var(--color-accent)' : '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 !== undefined && ( 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< HTMLDivElement, { data: WeatherInfo | null; position: { top: number; left: number }; onClose: () => void; } >(({ data, position, onClose }, ref) => { const forecast = data?.forecast ?? []; 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 예보
{forecast.length > 0 ? (
{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 | null }) { return (
{icon}
{label}
{value || '-'}
); }