833 lines
30 KiB
TypeScript
Executable File
833 lines
30 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;
|
||
hasImgAnalysis?: boolean;
|
||
}
|
||
|
||
interface IncidentsLeftPanelProps {
|
||
incidents: Incident[];
|
||
selectedIncidentId: string | null;
|
||
onIncidentSelect: (id: string | null) => void;
|
||
onFilteredChange?: (filtered: Incident[]) => 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,
|
||
onFilteredChange,
|
||
}: 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 });
|
||
// undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음
|
||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null | undefined>(undefined);
|
||
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(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]);
|
||
|
||
useEffect(() => {
|
||
onFilteredChange?.(filteredIncidents);
|
||
}, [filteredIncidents, onFilteredChange]);
|
||
|
||
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 h-full bg-bg-surface border-r border-stroke overflow-hidden shrink-0 w-[360px]">
|
||
{/* Search */}
|
||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||
<div className="relative">
|
||
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-caption">🔍</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-base border border-stroke text-caption outline-none"
|
||
style={{ borderRadius: 'var(--radius-sm)' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date Range */}
|
||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke shrink-0">
|
||
<input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => {
|
||
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)' }}
|
||
/>
|
||
<span className="text-fg-disabled text-label-2">~</span>
|
||
<input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => {
|
||
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)' }}
|
||
/>
|
||
<button
|
||
onClick={resetPage}
|
||
className="rounded-sm text-label-2 font-semibold cursor-pointer whitespace-nowrap text-color-accent"
|
||
style={{
|
||
padding: '5px 12px',
|
||
border: '1px solid rgba(6,182,212,.3)',
|
||
background: 'rgba(6,182,212,.08)',
|
||
}}
|
||
>
|
||
조회
|
||
</button>
|
||
</div>
|
||
|
||
{/* Period Presets */}
|
||
<div className="flex gap-1 px-4 py-1.5 border-b border-stroke shrink-0">
|
||
{PERIOD_PRESETS.map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => handlePeriodClick(p)}
|
||
className="text-caption font-semibold cursor-pointer"
|
||
style={{
|
||
padding: '3px 8px',
|
||
borderRadius: '14px',
|
||
border:
|
||
selectedPeriod === p
|
||
? '1px solid rgba(6,182,212,0.3)'
|
||
: '1px solid var(--stroke-default)',
|
||
background: selectedPeriod === p ? 'rgba(6,182,212,0.1)' : 'transparent',
|
||
color: selectedPeriod === p ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Today Summary */}
|
||
<div
|
||
className="px-4 py-2.5 border-b border-stroke shrink-0"
|
||
style={{ background: 'rgba(6,182,212,0.03)' }}
|
||
>
|
||
<div
|
||
className="text-caption font-bold text-fg-disabled 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-label-2 cursor-pointer"
|
||
style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 'var(--radius-sm)',
|
||
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg-card)',
|
||
border: isActive
|
||
? '1px solid rgba(6,182,212,0.25)'
|
||
: '1px solid var(--stroke-default)',
|
||
color: isActive ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||
fontWeight: isActive ? 700 : 400,
|
||
}}
|
||
>
|
||
{r === '전체' ? '전체 ' : `${r} `}
|
||
<span
|
||
className="font-bold font-mono"
|
||
style={{ color: isActive ? 'var(--color-accent)' : undefined }}
|
||
>
|
||
{r === '전체' ? count : `(${count})`}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div className="flex gap-[5px] px-4 py-2 border-b border-stroke shrink-0">
|
||
{[
|
||
{ 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) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => {
|
||
setSelectedStatus(s.id);
|
||
resetPage();
|
||
}}
|
||
className="flex items-center gap-1 text-caption cursor-pointer"
|
||
style={{
|
||
padding: '4px 10px',
|
||
borderRadius: '12px',
|
||
border:
|
||
selectedStatus === s.id
|
||
? '1px solid rgba(6,182,212,0.3)'
|
||
: '1px solid var(--stroke-default)',
|
||
background: 'transparent',
|
||
color: selectedStatus === s.id ? 'var(--fg-sub)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
{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-label-2 text-fg-disabled shrink-0 border-b border-stroke">
|
||
총 {filteredIncidents.length}건
|
||
</div>
|
||
|
||
{/* Incident List */}
|
||
<div
|
||
className="flex-1 overflow-y-auto"
|
||
style={{
|
||
scrollbarWidth: 'thin' as const,
|
||
scrollbarColor: 'var(--stroke-light) transparent',
|
||
}}
|
||
>
|
||
{pagedIncidents.length === 0 ? (
|
||
<div className="px-4 py-10 text-center text-fg-disabled text-label-2">
|
||
검색 결과가 없습니다.
|
||
</div>
|
||
) : (
|
||
pagedIncidents.map((inc) => {
|
||
const isSel = selectedIncidentId === inc.id;
|
||
const dotStyle: Record<string, string> = {
|
||
active: 'var(--color-danger)',
|
||
investigating: 'var(--color-warning)',
|
||
closed: 'var(--fg-disabled)',
|
||
};
|
||
const dotShadow: Record<string, string> = {
|
||
active: '0 0 6px var(--color-danger)',
|
||
investigating: '0 0 6px var(--color-warning)',
|
||
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(--color-danger)',
|
||
investigating: 'var(--color-warning)',
|
||
closed: 'var(--fg-disabled)',
|
||
};
|
||
const stLabel: Record<string, string> = {
|
||
active: '대응중',
|
||
investigating: '조사중',
|
||
closed: '종료',
|
||
};
|
||
return (
|
||
<div
|
||
key={inc.id}
|
||
onClick={() => 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 */}
|
||
<div className="flex items-center justify-between mb-[5px]">
|
||
<div className="flex items-center gap-1.5 text-caption">
|
||
<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-caption"
|
||
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-caption text-fg-disabled 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-caption font-medium text-fg-sub"
|
||
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-caption font-medium text-fg-sub"
|
||
style={{
|
||
padding: '2px 8px',
|
||
borderRadius: '3px',
|
||
background: 'rgba(100,116,139,0.08)',
|
||
border: '1px solid rgba(100,116,139,0.2)',
|
||
}}
|
||
>
|
||
{inc.oilType}
|
||
</span>
|
||
)}
|
||
{inc.prediction && (
|
||
<span
|
||
className="text-caption font-medium text-fg-sub"
|
||
style={{
|
||
padding: '2px 8px',
|
||
borderRadius: '3px',
|
||
background: 'rgba(100,116,139,0.08)',
|
||
border: '1px solid rgba(100,116,139,0.2)',
|
||
}}
|
||
>
|
||
{inc.prediction}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<button
|
||
className="inc-wx-btn cursor-pointer text-label-2"
|
||
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-label-2"
|
||
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-caption">{inc.mediaCount}</span>
|
||
</button>
|
||
)}
|
||
{inc.hasImgAnalysis && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setMediaModalIncident(inc);
|
||
}}
|
||
title="현장 이미지 보기"
|
||
className="cursor-pointer text-label-2"
|
||
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',
|
||
}}
|
||
>
|
||
📷
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Media Modal */}
|
||
{mediaModalIncident && (
|
||
<MediaModal incident={mediaModalIncident} onClose={() => setMediaModalIncident(null)} />
|
||
)}
|
||
|
||
{/* Weather Popup (fixed position) */}
|
||
{weatherPopupId && weatherInfo !== undefined && (
|
||
<WeatherPopup
|
||
ref={weatherRef}
|
||
data={weatherInfo}
|
||
position={weatherPos}
|
||
onClose={() => setWeatherPopupId(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
<div className="flex items-center justify-between bg-bg-surface shrink-0 border-t border-stroke px-3 py-2">
|
||
<div className="text-caption text-fg-disabled">
|
||
총 <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-base border border-stroke text-fg-sub text-caption 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-caption"
|
||
style={{
|
||
minWidth: '24px',
|
||
height: '24px',
|
||
padding: '0 5px',
|
||
borderRadius: '4px',
|
||
fontWeight: active ? 700 : 600,
|
||
background: active ? 'rgba(6,182,212,0.15)' : 'var(--bg-card)',
|
||
border: active ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--stroke-default)',
|
||
color: active ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
opacity: disabled ? 0.4 : 1,
|
||
pointerEvents: disabled ? 'none' : undefined,
|
||
cursor: disabled ? 'default' : 'pointer',
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
WeatherPopup – 사고 위치 기상정보 팝업
|
||
════════════════════════════════════════════════════ */
|
||
const WeatherPopup = forwardRef<
|
||
HTMLDivElement,
|
||
{
|
||
data: WeatherInfo | null;
|
||
position: { top: number; left: number };
|
||
onClose: () => void;
|
||
}
|
||
>(({ data, position, onClose }, ref) => {
|
||
const forecast = data?.forecast ?? [];
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className="fixed overflow-hidden rounded-xl border border-stroke bg-bg-surface"
|
||
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-stroke 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-body-2">🌤</span>
|
||
<div>
|
||
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
|
||
</div>
|
||
</div>
|
||
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-body-2 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-fg-disabled text-caption">{data?.weatherDc || '-'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail grid */}
|
||
<div className="grid grid-cols-2 gap-1.5 text-caption">
|
||
<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-caption">⬆</span>
|
||
<div>
|
||
<div className="text-fg-disabled text-caption">고조 (만조)</div>
|
||
<div className="font-bold font-mono text-caption text-color-info">
|
||
{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-caption">⬇</span>
|
||
<div>
|
||
<div className="text-fg-disabled text-caption">저조 (간조)</div>
|
||
<div className="text-color-accent font-bold font-mono text-caption">
|
||
{data?.lowTide || '-'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 24h Forecast */}
|
||
<div className="bg-bg-base mt-2.5 px-2.5 py-2 rounded-md">
|
||
<div className="font-bold text-fg-disabled text-caption mb-1.5">24h 예보</div>
|
||
{forecast.length > 0 ? (
|
||
<div className="flex justify-between font-mono text-fg-sub text-caption">
|
||
{forecast.map((f, i) => (
|
||
<div key={i} className="text-center">
|
||
<div>{f.hour}</div>
|
||
<div className="text-caption my-0.5">{f.icon}</div>
|
||
<div className="font-semibold">{f.temp}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-fg-disabled text-center text-caption py-1">예보 데이터 없음</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-color-warning text-caption mb-[3px]">⚠ 방제 작업 영향</div>
|
||
<div className="text-fg-sub text-caption leading-[1.5]">{data?.impactDc || '-'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
WeatherPopup.displayName = 'WeatherPopup';
|
||
|
||
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||
return (
|
||
<div className="flex items-center bg-bg-base rounded gap-[6px] py-1.5 px-2">
|
||
<span className="text-label-1">{icon}</span>
|
||
<div>
|
||
<div className="text-fg-disabled text-caption">{label}</div>
|
||
<div className="font-semibold font-mono">{value || '-'}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|