wing-ops/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx
htlee 34cf046787 fix(css): CSS 회귀 버그 3건 수정 + SCAT 우측 패널 구현
- className 중복 속성 31건 수정 (12파일)
- KOSPS codeBox spread TypeError 해결
- HNS 페놀(C₆H₅OH) 물질 데이터 추가
- ScatRightPanel 280px 우측 패널 신규 구현 (3탭+액션버튼)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:11:21 +09:00

519 lines
24 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 shrink-0 w-[360px]">
{/* Search */}
<div className="px-4 py-3 border-b border-border shrink-0">
<div className="relative">
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-xs">🔍</span>
<input
type="text"
placeholder="사고명, 선박명 검색..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); resetPage() }}
className="w-full py-2 pr-3 pl-8 bg-bg-0 border border-border text-xs outline-none"
style={{ borderRadius: 'var(--rS)' }}
/>
</div>
</div>
{/* Date Range */}
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border shrink-0">
<input type="date" value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setSelectedPeriod(''); resetPage() }}
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
/>
<span className="text-text-3 text-[11px]">~</span>
<input type="date" value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setSelectedPeriod(''); resetPage() }}
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
/>
<button onClick={resetPage} className="text-[11px] font-semibold cursor-pointer whitespace-nowrap text-white border-none" style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--cyan),var(--blue))', borderRadius: 'var(--rS)' }}></button>
</div>
{/* Period Presets */}
<div className="flex gap-1 px-4 py-1.5 border-b border-border shrink-0">
{PERIOD_PRESETS.map(p => (
<button key={p} onClick={() => handlePeriodClick(p)} className="text-[10px] font-semibold cursor-pointer"
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)',
}}>{p}</button>
))}
</div>
{/* Today Summary */}
<div className="px-4 py-2.5 border-b border-border shrink-0" style={{ background: 'rgba(6,182,212,0.03)' }}>
<div className="text-[10px] font-bold text-text-3 mb-2" style={{ letterSpacing: '0.8px' }}>
📅 ({todayLabel})
</div>
<div className="flex gap-1.5 flex-wrap">
{REGIONS.map(r => {
const count = regionCounts[r] ?? 0
const isActive = selectedRegion === r
return (
<button key={r} onClick={() => { setSelectedRegion(r); resetPage() }} className="text-[11px] cursor-pointer"
style={{
padding: '4px 10px', borderRadius: 'var(--rS)',
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 className="font-bold font-mono" style={{ color: isActive ? 'var(--cyan)' : undefined }}>
{r === '전체' ? count : `(${count})`}
</span>
</button>
)
})}
</div>
</div>
{/* Status Filter */}
<div className="flex gap-[5px] px-4 py-2 border-b border-border shrink-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() }}
className="flex items-center gap-1 text-[10px] font-semibold cursor-pointer"
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)',
}}>
{s.dot && <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.dot }} />}
{s.label}
</button>
))}
</div>
{/* Count */}
<div className="px-4 py-1.5 text-[11px] text-text-3 shrink-0 border-b border-border">
{filteredIncidents.length}
</div>
{/* Incident List */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin' as const, scrollbarColor: 'var(--bdL) transparent' }}>
{pagedIncidents.length === 0 ? (
<div className="px-4 py-10 text-center text-text-3 text-[11px]">
.
</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)}
className="px-4 py-3 border-b border-border cursor-pointer"
style={{
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 className="flex items-center justify-between mb-[5px]">
<div className="flex items-center gap-1.5 text-xs font-bold">
<span className="shrink-0" style={{ width: '8px', height: '8px', borderRadius: '50%', background: dotStyle[inc.status], boxShadow: dotShadow[inc.status] }} />
{inc.name}
</div>
<span className="shrink-0 text-[10px] font-semibold" style={{ padding: '2px 10px', borderRadius: '10px', background: stBg[inc.status], color: stColor[inc.status] }}>
{stLabel[inc.status]}
</span>
</div>
{/* Row 2: meta */}
<div className="flex items-center gap-2 text-[10px] text-text-3 mb-[5px]">
<span>📅 {inc.date} {inc.time}</span>
<span>🏛 {inc.office}</span>
</div>
{/* Row 3: tags + buttons */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{inc.causeType && (
<span className="text-[10px] font-medium text-text-2" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)' }}>
{inc.causeType}
</span>
)}
{inc.oilType && (
<span className="text-[10px] font-medium text-status-orange" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)' }}>
{inc.oilType}
</span>
)}
{inc.prediction && (
<span className="text-[10px] font-medium text-status-green" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)' }}>
{inc.prediction}
</span>
)}
</div>
<div className="flex gap-1">
<button className="inc-wx-btn cursor-pointer text-[11px]" onClick={(e) => handleWeatherClick(e, inc.id)} title="사고 위치 기상정보"
style={{
padding: '3px 7px', borderRadius: '4px', 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="현장정보 조회" className="cursor-pointer text-[11px]"
style={{
padding: '3px 7px', borderRadius: '4px', lineHeight: 1,
border: '1px solid rgba(59,130,246,0.25)', background: 'rgba(59,130,246,0.08)', color: '#60a5fa',
transition: '0.15s',
}}>📹 <span className="text-[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 className="flex items-center justify-between bg-bg-1 shrink-0 border-t border-border px-3 py-2">
<div className="text-[9px] text-text-3">
<b>{filteredIncidents.length}</b> {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
</div>
<div className="flex items-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 }}
className="bg-bg-0 border border-border text-text-2 text-[9px] outline-none rounded px-1.5 py-[3px]">
<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}
className="flex items-center justify-center font-mono text-[9px]"
style={{
minWidth: '24px', height: '24px', padding: '0 5px', borderRadius: '4px', fontWeight: active ? 700 : 600,
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,
cursor: disabled ? 'default' : 'pointer',
}}>{label}</button>
)
}
/* ════════════════════════════════════════════════════
WeatherPopup 사고 위치 기상정보 팝업
════════════════════════════════════════════════════ */
const WeatherPopup = forwardRef<HTMLDivElement, {
data: WeatherInfo
position: { top: number; left: number }
onClose: () => void
}>(({ data, position, onClose }, ref) => {
return (
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
zIndex: 9990, width: 280,
top: position.top, left: position.left,
borderColor: 'rgba(59,130,246,0.3)',
boxShadow: '0 12px 40px rgba(0,0,0,0.5)', backdropFilter: 'blur(12px)',
}}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3.5 py-2.5"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(6,182,212,0.04))' }}>
<div className="flex items-center gap-1.5">
<span className="text-sm">🌤</span>
<div>
<div className="text-[11px] font-bold">{data.locNm}</div>
<div className="text-text-3 font-mono text-[8px]">{data.obsDtm}</div>
</div>
</div>
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5"></span>
</div>
{/* Body */}
<div className="px-3.5 py-3">
{/* Main weather */}
<div className="flex items-center gap-3 mb-2.5">
<div className="text-[28px]">{data.icon}</div>
<div>
<div className="font-bold font-mono text-[20px]">{data.temp}</div>
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
</div>
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
<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 className="flex gap-1.5 mt-2">
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
style={{ background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.1)' }}>
<span className="text-xs"></span>
<div>
<div className="text-text-3 text-[7px]"> ()</div>
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data.highTide}</div>
</div>
</div>
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}>
<span className="text-xs"></span>
<div>
<div className="text-text-3 text-[7px]"> ()</div>
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data.lowTide}</div>
</div>
</div>
</div>
{/* 24h Forecast */}
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h </div>
<div className="flex justify-between font-mono text-text-2 text-[8px]">
{data.forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}
</div>
</div>
{/* Impact */}
<div className="mt-2 rounded" style={{
padding: '6px 10px',
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
}}>
<div className="font-bold text-status-orange text-[8px] mb-[3px]"> </div>
<div className="text-text-2 text-[8px] leading-[1.5]">{data.impactDc}</div>
</div>
</div>
</div>
)
})
WeatherPopup.displayName = 'WeatherPopup'
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
<span className="text-[12px]">{icon}</span>
<div>
<div className="text-text-3 text-[7px]">{label}</div>
<div className="font-semibold font-mono">{value}</div>
</div>
</div>
)
}