- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
421 lines
15 KiB
TypeScript
Executable File
421 lines
15 KiB
TypeScript
Executable File
import { useState } from 'react'
|
||
|
||
export interface Analysis {
|
||
id: number
|
||
name: string
|
||
occurredAt: string
|
||
analysisDate: string
|
||
requestor: string
|
||
duration: string
|
||
oilType: string
|
||
volume: number
|
||
incidentStatus: string
|
||
kospsStatus: 'completed' | 'running' | 'pending' | 'error'
|
||
poseidonStatus: 'completed' | 'running' | 'pending' | 'error'
|
||
opendriftStatus: 'completed' | 'running' | 'pending' | 'error'
|
||
backtracking: 'completed' | 'running' | 'pending' | 'error'
|
||
analyst: string
|
||
lat: number
|
||
lon: number
|
||
location: string
|
||
}
|
||
|
||
// Mock 데이터
|
||
const mockAnalyses: Analysis[] = [
|
||
{
|
||
id: 1,
|
||
name: '여수 유조선 충돌',
|
||
occurredAt: '2025-02-18 06:30',
|
||
analysisDate: '2025-02-18',
|
||
requestor: '김정훈',
|
||
duration: '72H',
|
||
oilType: 'BUNKER_C',
|
||
volume: 350.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'completed',
|
||
analyst: '남해청, 방재과',
|
||
lat: 34.7312, lon: 127.6845, location: '여수 돌산 남방 5NM',
|
||
},
|
||
{
|
||
id: 2,
|
||
name: '통영 화물선 파손',
|
||
occurredAt: '2025-02-08 14:20',
|
||
analysisDate: '2025-02-08',
|
||
requestor: '박민수',
|
||
duration: '48H',
|
||
oilType: 'DIESEL',
|
||
volume: 120.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'running',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'completed',
|
||
analyst: '남해청, 통영지',
|
||
lat: 34.8342, lon: 128.4331, location: '통영항 동방 3NM',
|
||
},
|
||
{
|
||
id: 3,
|
||
name: '군산항 송유관 파열',
|
||
occurredAt: '2025-02-09 09:15',
|
||
analysisDate: '2025-02-09',
|
||
requestor: '이승호',
|
||
duration: '72H',
|
||
oilType: 'CRUDE_OIL',
|
||
volume: 580.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'running',
|
||
backtracking: 'completed',
|
||
analyst: '서해청, 군산지',
|
||
lat: 35.9838, lon: 126.5650, location: '군산항 내항 부두',
|
||
},
|
||
{
|
||
id: 4,
|
||
name: '인천항 기름선 파손',
|
||
occurredAt: '2025-02-05 11:40',
|
||
analysisDate: '2025-02-05',
|
||
requestor: '최영진',
|
||
duration: '48H',
|
||
oilType: 'BUNKER_C',
|
||
volume: 85.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'running',
|
||
analyst: '중부청, 인천지',
|
||
lat: 37.4563, lon: 126.5922, location: '인천항 남방 2NM',
|
||
},
|
||
{
|
||
id: 5,
|
||
name: '제주 담배 해양사',
|
||
occurredAt: '2025-01-28 07:50',
|
||
analysisDate: '2025-01-28',
|
||
requestor: '한지원',
|
||
duration: '24H',
|
||
oilType: 'DIESEL',
|
||
volume: 45.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'error',
|
||
backtracking: 'running',
|
||
analyst: '제주청, 제주지',
|
||
lat: 33.5097, lon: 126.5312, location: '제주항 북방 1NM',
|
||
},
|
||
{
|
||
id: 6,
|
||
name: '포항 영일만 탱커',
|
||
occurredAt: '2025-01-25 16:00',
|
||
analysisDate: '2025-01-25',
|
||
requestor: '정우성',
|
||
duration: '72H',
|
||
oilType: 'CRUDE_OIL',
|
||
volume: 220.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'completed',
|
||
analyst: '동해청, 포항지',
|
||
lat: 36.0190, lon: 129.3650, location: '영일만 입구 동방 4NM',
|
||
},
|
||
{
|
||
id: 7,
|
||
name: '목포 벙커링 유출',
|
||
occurredAt: '2025-01-20 13:10',
|
||
analysisDate: '2025-01-20',
|
||
requestor: '송태호',
|
||
duration: '48H',
|
||
oilType: 'BUNKER_C',
|
||
volume: 95.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'completed',
|
||
analyst: '서해청, 목포지',
|
||
lat: 34.7936, lon: 126.3815, location: '목포항 외항 남방',
|
||
},
|
||
{
|
||
id: 8,
|
||
name: '부산 감천항 충돌',
|
||
occurredAt: '2025-01-15 22:10',
|
||
analysisDate: '2025-01-14',
|
||
requestor: '윤서연',
|
||
duration: '12H',
|
||
oilType: 'BUNKER_C',
|
||
volume: 28.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'running',
|
||
backtracking: 'running',
|
||
analyst: '남해청, 부산지',
|
||
lat: 35.0761, lon: 129.0148, location: '감천항 내항',
|
||
},
|
||
{
|
||
id: 9,
|
||
name: '태안 해역 유출',
|
||
occurredAt: '2025-01-12 04:45',
|
||
analysisDate: '2025-01-12',
|
||
requestor: '강민재',
|
||
duration: '72H',
|
||
oilType: 'CRUDE_OIL',
|
||
volume: 1200.00,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'completed',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'completed',
|
||
analyst: '중부청, 태안지',
|
||
lat: 36.7765, lon: 126.1320, location: '태안 만리포 서방 8NM',
|
||
},
|
||
{
|
||
id: 10,
|
||
name: '울산항 윤활유 유출',
|
||
occurredAt: '2025-01-08 10:30',
|
||
analysisDate: '2025-01-08',
|
||
requestor: '오현수',
|
||
duration: '24H',
|
||
oilType: 'LUBE_OIL',
|
||
volume: 12.50,
|
||
incidentStatus: '진행중',
|
||
kospsStatus: 'completed',
|
||
poseidonStatus: 'error',
|
||
opendriftStatus: 'completed',
|
||
backtracking: 'running',
|
||
analyst: '남해청, 울산지',
|
||
lat: 35.5040, lon: 129.3870, location: '울산항 남방 1NM',
|
||
},
|
||
]
|
||
|
||
interface AnalysisListTableProps {
|
||
onTabChange: (tab: string) => void
|
||
onSelectAnalysis?: (analysis: Analysis) => void
|
||
}
|
||
|
||
export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisListTableProps) {
|
||
const [analyses] = useState<Analysis[]>(mockAnalyses)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'completed':
|
||
return (
|
||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(34,197,94,0.15)] text-green-400">
|
||
완료
|
||
</span>
|
||
)
|
||
case 'running':
|
||
return (
|
||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(249,115,22,0.15)] text-orange-400">
|
||
실행중
|
||
</span>
|
||
)
|
||
case 'pending':
|
||
return (
|
||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(138,150,168,0.15)] text-text-3">
|
||
대기
|
||
</span>
|
||
)
|
||
case 'error':
|
||
return (
|
||
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(239,68,68,0.15)] text-status-red">
|
||
오류
|
||
</span>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
const totalPages = Math.ceil(analyses.length / itemsPerPage)
|
||
const startIndex = (currentPage - 1) * itemsPerPage
|
||
const endIndex = startIndex + itemsPerPage
|
||
const currentAnalyses = analyses.slice(startIndex, endIndex)
|
||
|
||
const renderPageNumbers = () => {
|
||
const pages = []
|
||
const maxVisible = 5
|
||
|
||
if (totalPages <= maxVisible) {
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
pages.push(i)
|
||
}
|
||
} else {
|
||
if (currentPage <= 3) {
|
||
for (let i = 1; i <= 5; i++) pages.push(i)
|
||
pages.push('...')
|
||
pages.push(totalPages)
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pages.push(1)
|
||
pages.push('...')
|
||
for (let i = totalPages - 4; i <= totalPages; i++) pages.push(i)
|
||
} else {
|
||
pages.push(1)
|
||
pages.push('...')
|
||
for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i)
|
||
pages.push('...')
|
||
pages.push(totalPages)
|
||
}
|
||
}
|
||
|
||
return pages.map((page, index) => {
|
||
if (page === '...') {
|
||
return (
|
||
<span key={`ellipsis-${index}`} className="px-3 py-1 text-text-3">
|
||
...
|
||
</span>
|
||
)
|
||
}
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page as number)}
|
||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||
currentPage === page
|
||
? 'bg-primary-cyan text-bg-0'
|
||
: 'text-text-2 hover:bg-bg-2'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full bg-bg-0">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-text-1">유출유 확산 예측 목록</h1>
|
||
<p className="text-sm text-text-3 mt-1">총 {analyses.length}건</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||
/>
|
||
</div>
|
||
<button onClick={() => onTabChange('analysis')} className="px-4 py-2 text-sm font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||
+ 새 분석
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 */}
|
||
<div className="flex-1 overflow-auto">
|
||
<table className="w-full">
|
||
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">번호</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고명</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고일시</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">분석일</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">예측시간</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">유종</th>
|
||
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider">유출량</th>
|
||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">사고상태</th>
|
||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">KOSPS</th>
|
||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">POSEIDON</th>
|
||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">OpenDrift</th>
|
||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">역추적</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">분석담당자</th>
|
||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">소속</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{currentAnalyses.map((analysis) => (
|
||
<tr
|
||
key={analysis.id}
|
||
className="hover:bg-bg-2 transition-colors cursor-pointer group"
|
||
>
|
||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.id}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
|
||
<span
|
||
className="text-sm font-semibold text-primary-cyan hover:underline transition-all cursor-pointer"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (onSelectAnalysis) {
|
||
onSelectAnalysis(analysis)
|
||
}
|
||
}}
|
||
>
|
||
{analysis.name}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analysisDate}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.duration}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.oilType}</td>
|
||
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
|
||
{analysis.volume.toFixed(2)}
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<span className="w-2 h-2 rounded-full bg-status-red inline-block animate-pulse" />
|
||
</td>
|
||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
|
||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.poseidonStatus)}</td>
|
||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.opendriftStatus)}</td>
|
||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtracking)}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.requestor}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analyst}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 페이지네이션 */}
|
||
<div className="flex items-center justify-center gap-2 px-5 py-4 border-t border-border">
|
||
<button
|
||
onClick={() => setCurrentPage(1)}
|
||
disabled={currentPage === 1}
|
||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
≪
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
‹
|
||
</button>
|
||
|
||
{renderPageNumbers()}
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
disabled={currentPage === totalPages}
|
||
className="px-3 py-1 text-sm font-medium text-text-2 hover:bg-bg-2 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
≫
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|