wing-ops/frontend/src/tabs/prediction/components/AnalysisListTable.tsx
htlee f099ff29b1 refactor(frontend): 탭 단위 패키지 구조 전환 (tabs/)
- 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>
2026-02-28 14:08:34 +09:00

421 lines
15 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 } 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>
)
}