- 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>
528 lines
27 KiB
TypeScript
Executable File
528 lines
27 KiB
TypeScript
Executable File
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<string>('1개월')
|
||
const [dateFrom, setDateFrom] = useState(getPresetStartDate('1개월'))
|
||
const [dateTo, setDateTo] = useState(today)
|
||
const [selectedRegion, setSelectedRegion] = useState<string>('전체')
|
||
const [selectedStatus, setSelectedStatus] = useState<string>('전체')
|
||
|
||
// Media modal
|
||
const [mediaModalIncident, setMediaModalIncident] = useState<Incident | null>(null)
|
||
|
||
// Weather popup
|
||
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
||
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
|
||
const weatherRef = useRef<HTMLDivElement>(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<string, number> = { '전체': 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 (
|
||
<div className="flex flex-col bg-bg-1 border-r border-border overflow-hidden" style={{ width: '360px', flexShrink: 0 }}>
|
||
{/* Search */}
|
||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--bd)', flexShrink: 0 }}>
|
||
<div style={{ position: 'relative' }}>
|
||
<span style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', fontSize: '12px' }}>🔍</span>
|
||
<input
|
||
type="text"
|
||
placeholder="사고명, 선박명 검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => { 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',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date Range */}
|
||
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
|
||
<input type="date" value={dateFrom}
|
||
onChange={(e) => { 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 }}
|
||
/>
|
||
<span style={{ color: 'var(--t3)', fontSize: '11px' }}>~</span>
|
||
<input type="date" value={dateTo}
|
||
onChange={(e) => { 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 }}
|
||
/>
|
||
<button onClick={resetPage} style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--cyan),var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: '11px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)', whiteSpace: 'nowrap' }}>조회</button>
|
||
</div>
|
||
|
||
{/* Period Presets */}
|
||
<div style={{ padding: '6px 16px', borderBottom: '1px solid var(--bd)', display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||
{PERIOD_PRESETS.map(p => (
|
||
<button key={p} onClick={() => handlePeriodClick(p)} style={{
|
||
padding: '3px 8px', borderRadius: '14px', border: selectedPeriod === p ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||
background: selectedPeriod === p ? 'rgba(6,182,212,0.1)' : 'transparent',
|
||
color: selectedPeriod === p ? 'var(--cyan)' : 'var(--t3)', fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)',
|
||
}}>{p}</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Today Summary */}
|
||
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--bd)', background: 'rgba(6,182,212,0.03)', flexShrink: 0 }}>
|
||
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t3)', letterSpacing: '0.8px', marginBottom: '8px', fontFamily: 'var(--fK)' }}>
|
||
📅 오늘 ({todayLabel}) 사고 현황
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{REGIONS.map(r => {
|
||
const count = regionCounts[r] ?? 0
|
||
const isActive = selectedRegion === r
|
||
return (
|
||
<button key={r} onClick={() => { setSelectedRegion(r); resetPage() }} style={{
|
||
padding: '4px 10px', borderRadius: 'var(--rS)', fontSize: '11px', fontFamily: 'var(--fK)', cursor: 'pointer',
|
||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg3)',
|
||
border: isActive ? '1px solid rgba(6,182,212,0.25)' : '1px solid var(--bd)',
|
||
color: isActive ? 'var(--cyan)' : 'var(--t2)', fontWeight: isActive ? 700 : 400,
|
||
}}>
|
||
{r === '전체' ? '전체 ' : `${r} `}
|
||
<span style={{ fontWeight: 700, fontFamily: 'var(--fM)', color: isActive ? 'var(--cyan)' : 'var(--t1)' }}>
|
||
{r === '전체' ? count : `(${count})`}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div style={{ display: 'flex', gap: '5px', padding: '8px 16px', borderBottom: '1px solid var(--bd)', flexShrink: 0 }}>
|
||
{[
|
||
{ 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 => (
|
||
<button key={s.id} onClick={() => { setSelectedStatus(s.id); resetPage() }} style={{
|
||
padding: '4px 10px', borderRadius: '12px', border: selectedStatus === s.id ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||
background: 'transparent', color: selectedStatus === s.id ? 'var(--t2)' : 'var(--t3)',
|
||
fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)',
|
||
display: 'flex', alignItems: 'center', gap: '4px',
|
||
}}>
|
||
{s.dot && <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.dot }} />}
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Count */}
|
||
<div style={{ padding: '6px 16px', fontSize: '11px', color: 'var(--t3)', fontFamily: 'var(--fK)', borderBottom: '1px solid rgba(30,42,74,0.3)', flexShrink: 0 }}>
|
||
총 {filteredIncidents.length}건
|
||
</div>
|
||
|
||
{/* Incident List */}
|
||
<div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||
{pagedIncidents.length === 0 ? (
|
||
<div style={{ padding: '40px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '11px', fontFamily: 'var(--fK)' }}>
|
||
검색 결과가 없습니다.
|
||
</div>
|
||
) : pagedIncidents.map(inc => {
|
||
const isSel = selectedIncidentId === inc.id
|
||
const dotStyle: Record<string, string> = {
|
||
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
|
||
}
|
||
const dotShadow: Record<string, string> = {
|
||
active: '0 0 6px var(--red)', investigating: '0 0 6px var(--orange)', closed: 'none',
|
||
}
|
||
const stBg: Record<string, string> = {
|
||
active: 'rgba(239,68,68,0.15)', investigating: 'rgba(249,115,22,0.15)', closed: 'rgba(100,116,139,0.15)',
|
||
}
|
||
const stColor: Record<string, string> = {
|
||
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
|
||
}
|
||
const stLabel: Record<string, string> = {
|
||
active: '대응중', investigating: '조사중', closed: '종료',
|
||
}
|
||
return (
|
||
<div key={inc.id} onClick={() => 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 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}>
|
||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: dotStyle[inc.status], boxShadow: dotShadow[inc.status], flexShrink: 0 }} />
|
||
{inc.name}
|
||
</div>
|
||
<span style={{ padding: '2px 10px', borderRadius: '10px', fontSize: '10px', fontWeight: 600, background: stBg[inc.status], color: stColor[inc.status], flexShrink: 0 }}>
|
||
{stLabel[inc.status]}
|
||
</span>
|
||
</div>
|
||
{/* Row 2: meta */}
|
||
<div style={{ fontSize: '10px', color: 'var(--t3)', marginBottom: '5px', fontFamily: 'var(--fK)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span>📅 {inc.date} {inc.time}</span>
|
||
<span>🏛 {inc.office}</span>
|
||
</div>
|
||
{/* Row 3: tags + buttons */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||
{inc.causeType && (
|
||
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)', color: 'var(--t2)' }}>
|
||
{inc.causeType}
|
||
</span>
|
||
)}
|
||
{inc.oilType && (
|
||
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)', color: 'var(--orange)' }}>
|
||
{inc.oilType}
|
||
</span>
|
||
)}
|
||
{inc.prediction && (
|
||
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)', color: 'var(--green)' }}>
|
||
{inc.prediction}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '4px' }}>
|
||
<button className="inc-wx-btn" onClick={(e) => handleWeatherClick(e, inc.id)} title="사고 위치 기상정보" style={{
|
||
padding: '3px 7px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', lineHeight: 1,
|
||
border: '1px solid rgba(59,130,246,0.25)', background: weatherPopupId === inc.id ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.08)', color: '#60a5fa',
|
||
transition: '0.15s',
|
||
}}>🌤</button>
|
||
{(inc.mediaCount ?? 0) > 0 && (
|
||
<button onClick={(e) => { e.stopPropagation(); setMediaModalIncident(inc) }} title="현장정보 조회" style={{
|
||
padding: '3px 7px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', lineHeight: 1,
|
||
border: '1px solid rgba(59,130,246,0.25)', background: 'rgba(59,130,246,0.08)', color: '#60a5fa',
|
||
transition: '0.15s',
|
||
}}>📹 <span style={{ fontSize: '8px' }}>{inc.mediaCount}</span></button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Media Modal */}
|
||
{mediaModalIncident && (
|
||
<MediaModal incident={mediaModalIncident} onClose={() => setMediaModalIncident(null)} />
|
||
)}
|
||
|
||
{/* Weather Popup (fixed position) */}
|
||
{weatherPopupId && weatherInfo && (
|
||
<WeatherPopup
|
||
ref={weatherRef}
|
||
data={weatherInfo}
|
||
position={weatherPos}
|
||
onClose={() => setWeatherPopupId(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
<div style={{
|
||
padding: '8px 12px', borderTop: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'var(--bg1)',
|
||
}}>
|
||
<div style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||
총 <b style={{ color: 'var(--t1)' }}>{filteredIncidents.length}</b>건 중 {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||
<PgBtn label="⏮" disabled={safePage <= 1} onClick={() => setCurrentPage(1)} />
|
||
<PgBtn label="◀" disabled={safePage <= 1} onClick={() => setCurrentPage(Math.max(1, safePage - 1))} />
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).filter(p => Math.abs(p - safePage) <= 2).map(p => (
|
||
<PgBtn key={p} label={String(p)} active={p === safePage} onClick={() => setCurrentPage(p)} />
|
||
))}
|
||
<PgBtn label="▶" disabled={safePage >= totalPages} onClick={() => setCurrentPage(Math.min(totalPages, safePage + 1))} />
|
||
<PgBtn label="⏭" disabled={safePage >= totalPages} onClick={() => setCurrentPage(totalPages)} />
|
||
</div>
|
||
<select onChange={(e) => { /* page size change placeholder */ void e }} style={{
|
||
padding: '3px 6px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: '4px', color: 'var(--t2)', fontSize: '9px', fontFamily: 'var(--fK)', outline: 'none',
|
||
}}>
|
||
<option>6건</option><option>10건</option><option>20건</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PgBtn({ label, active, disabled, onClick }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void }) {
|
||
return (
|
||
<button onClick={onClick} disabled={disabled} style={{
|
||
minWidth: '24px', height: '24px', padding: '0 5px', borderRadius: '4px', fontSize: '9px', fontWeight: active ? 700 : 600,
|
||
fontFamily: 'var(--fM)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: disabled ? 'default' : 'pointer',
|
||
background: active ? 'rgba(6,182,212,0.15)' : 'var(--bg3)',
|
||
border: active ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--bd)',
|
||
color: active ? 'var(--cyan)' : 'var(--t3)',
|
||
opacity: disabled ? 0.4 : 1, pointerEvents: disabled ? 'none' : undefined,
|
||
}}>{label}</button>
|
||
)
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
WeatherPopup – 사고 위치 기상정보 팝업
|
||
════════════════════════════════════════════════════ */
|
||
const WeatherPopup = forwardRef<HTMLDivElement, {
|
||
data: WeatherInfo
|
||
position: { top: number; left: number }
|
||
onClose: () => void
|
||
}>(({ data, position, onClose }, ref) => {
|
||
return (
|
||
<div ref={ref} style={{
|
||
position: 'fixed', zIndex: 9990, width: 280,
|
||
top: position.top, left: position.left,
|
||
background: 'var(--bg1)', border: '1px solid rgba(59,130,246,0.3)', borderRadius: 12,
|
||
boxShadow: '0 12px 40px rgba(0,0,0,0.5)', overflow: 'hidden', backdropFilter: 'blur(12px)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
padding: '10px 14px',
|
||
background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(6,182,212,0.04))',
|
||
borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 14 }}>🌤</span>
|
||
<div>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>{data.locNm}</div>
|
||
<div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{data.obsDtm}</div>
|
||
</div>
|
||
</div>
|
||
<span onClick={onClose} style={{ fontSize: 14, cursor: 'pointer', color: 'var(--t3)', padding: 2 }}>✕</span>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div style={{ padding: '12px 14px' }}>
|
||
{/* Main weather */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||
<div style={{ fontSize: 28 }}>{data.icon}</div>
|
||
<div>
|
||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{data.temp}</div>
|
||
<div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{data.weatherDc}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, fontSize: 9, fontFamily: 'var(--fK)' }}>
|
||
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
|
||
<WxCell icon="🌊" label="파고" value={data.wave} />
|
||
<WxCell icon="💧" label="습도" value={data.humid} />
|
||
<WxCell icon="👁" label="시정" value={data.vis} />
|
||
<WxCell icon="🌡" label="수온" value={data.sst} />
|
||
<WxCell icon="🔄" label="조류" value={data.tide} />
|
||
</div>
|
||
|
||
{/* Tide info */}
|
||
<div style={{ marginTop: 8, display: 'flex', gap: 6 }}>
|
||
<div style={{
|
||
flex: 1, padding: '6px 8px', background: 'rgba(59,130,246,0.06)',
|
||
border: '1px solid rgba(59,130,246,0.1)', borderRadius: 6,
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
}}>
|
||
<span style={{ fontSize: 12 }}>⬆</span>
|
||
<div>
|
||
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}>고조 (만조)</div>
|
||
<div style={{ color: '#60a5fa', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.highTide}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
flex: 1, padding: '6px 8px', background: 'rgba(6,182,212,0.06)',
|
||
border: '1px solid rgba(6,182,212,0.1)', borderRadius: 6,
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
}}>
|
||
<span style={{ fontSize: 12 }}>⬇</span>
|
||
<div>
|
||
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}>저조 (간조)</div>
|
||
<div style={{ color: 'var(--cyan)', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.lowTide}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 24h Forecast */}
|
||
<div style={{ marginTop: 10, padding: '8px 10px', background: 'var(--bg0)', borderRadius: 6 }}>
|
||
<div style={{ fontSize: 8, fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 6 }}>24h 예보</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 8, fontFamily: 'var(--fM)', color: 'var(--t2)' }}>
|
||
{data.forecast.map((f, i) => (
|
||
<div key={i} style={{ textAlign: 'center' }}>
|
||
<div>{f.hour}</div>
|
||
<div style={{ fontSize: 12, margin: '2px 0' }}>{f.icon}</div>
|
||
<div style={{ fontWeight: 600 }}>{f.temp}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Impact */}
|
||
<div style={{
|
||
marginTop: 8, padding: '6px 10px',
|
||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)', borderRadius: 6,
|
||
}}>
|
||
<div style={{ fontSize: 8, fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: 3 }}>⚠ 방제 작업 영향</div>
|
||
<div style={{ fontSize: 8, color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.5 }}>{data.impactDc}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
WeatherPopup.displayName = 'WeatherPopup'
|
||
|
||
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||
return (
|
||
<div style={{
|
||
padding: '6px 8px', background: 'var(--bg0)', borderRadius: 6,
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
}}>
|
||
<span style={{ fontSize: 12 }}>{icon}</span>
|
||
<div>
|
||
<div style={{ color: 'var(--t3)', fontSize: 7 }}>{label}</div>
|
||
<div style={{ color: 'var(--t1)', fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|