wing-ops/frontend/src/tabs/scat/components/PreScatView.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

176 lines
5.7 KiB
TypeScript
Executable File

import { useState, useCallback, useEffect } from 'react';
import type { ScatSegment, ScatDetail } from './scatTypes';
import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
import type { ApiZoneItem } from '../services/scatApi';
import ScatLeftPanel from './ScatLeftPanel';
import ScatMap from './ScatMap';
import ScatTimeline from './ScatTimeline';
import ScatPopup from './ScatPopup';
import ScatRightPanel from './ScatRightPanel';
// ═══ Main PreScatView ═══
export function PreScatView() {
const [segments, setSegments] = useState<ScatSegment[]>([]);
const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
const [areaFilter, setAreaFilter] = useState('전체');
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
const [statusFilter, setStatusFilter] = useState('전체');
const [searchTerm, setSearchTerm] = useState('');
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false);
const [timelineIdx, setTimelineIdx] = useState(6);
// API에서 구역 및 구간 데이터 로딩
useEffect(() => {
let cancelled = false;
async function loadData() {
try {
setLoading(true);
const [zonesData, sectionsData] = await Promise.all([fetchZones(), fetchSections()]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
}
} catch (err) {
console.error('[SCAT] 데이터 로딩 오류:', err);
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
} finally {
if (!cancelled) setLoading(false);
}
}
loadData();
return () => {
cancelled = true;
};
}, []);
// 선택 구간 변경 시 우측 패널 상세 로딩
useEffect(() => {
if (!selectedSeg) {
setPanelDetail(null);
return;
}
let cancelled = false;
setPanelLoading(true);
fetchSectionDetail(selectedSeg.id)
.then(detail => { if (!cancelled) setPanelDetail(detail); })
.catch(err => console.error('[SCAT] 패널 상세 로딩 오류:', err))
.finally(() => { if (!cancelled) setPanelLoading(false); });
return () => { cancelled = true; };
}, [selectedSeg]);
// 관할 기반 세그먼트 필터링
const filteredSegments = segments.filter((s) => {
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
return true; // 전체
});
const handleOpenPopup = useCallback(async (sn: number) => {
try {
const detail = await fetchSectionDetail(sn);
setPopupData(detail);
} catch (err) {
console.error('[SCAT] 상세 데이터 로딩 오류:', err);
}
}, []);
const handleClosePopup = useCallback(() => {
setPopupData(null);
}, []);
const handleTimelineSeek = useCallback(
(idx: number) => {
if (idx === -1) {
// advance signal from play
setTimelineIdx((prev) => {
const next = (prev + 1) % Math.min(filteredSegments.length, 12);
if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
return next;
});
} else {
setTimelineIdx(idx);
if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
}
},
[filteredSegments],
);
if (error) {
return (
<div className="flex w-full h-full bg-bg-0 items-center justify-center flex-col gap-3">
<div className="text-status-red text-sm font-korean">{error}</div>
<button
onClick={() => { setError(null); setLoading(true); }}
className="px-4 py-1.5 bg-primary-cyan text-white text-xs rounded font-korean"
>
</button>
</div>
);
}
if (loading || !selectedSeg) {
return (
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
<div className="text-text-2 text-sm font-korean">SCAT ...</div>
</div>
);
}
return (
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
<ScatLeftPanel
segments={filteredSegments}
zones={zones}
selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
jurisdictionFilter={jurisdictionFilter}
onJurisdictionChange={setJurisdictionFilter}
areaFilter={areaFilter}
onAreaChange={setAreaFilter}
phaseFilter={phaseFilter}
onPhaseChange={setPhaseFilter}
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<div className="flex-1 relative">
<ScatMap
segments={filteredSegments}
selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
/>
<ScatTimeline
segments={filteredSegments}
currentIdx={timelineIdx}
onSeek={handleTimelineSeek}
/>
</div>
<ScatRightPanel
detail={panelDetail}
loading={panelLoading}
/>
{popupData && (
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
)}
</div>
);
}