- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) - HNS: 분석 CRUD 5개 API (013_hns_analysis.sql) - Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql) - Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql) - Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql) - backtrackMockData.ts 삭제 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
10 KiB
TypeScript
Executable File
250 lines
10 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback } from 'react'
|
||
import { fetchPredictionAnalyses } from '../services/predictionApi'
|
||
import type { PredictionAnalysis } from '../services/predictionApi'
|
||
|
||
export type Analysis = PredictionAnalysis
|
||
|
||
interface AnalysisListTableProps {
|
||
onTabChange: (tab: string) => void
|
||
onSelectAnalysis?: (analysis: Analysis) => void
|
||
}
|
||
|
||
export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisListTableProps) {
|
||
const [analyses, setAnalyses] = useState<Analysis[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const items = await fetchPredictionAnalyses({ search: searchTerm || undefined })
|
||
setAnalyses(items)
|
||
} catch (err) {
|
||
console.error('[prediction] 분석 목록 조회 실패:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [searchTerm])
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [loadData])
|
||
|
||
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">
|
||
{loading ? (
|
||
<div className="text-center py-20 text-text-3 text-sm">로딩 중...</div>
|
||
) : (
|
||
<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-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">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.acdntSn}
|
||
className="hover:bg-bg-2 transition-colors cursor-pointer group"
|
||
>
|
||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.acdntSn}</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.acdntNm}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</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 != null ? analysis.volume.toFixed(2) : '—'}
|
||
</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.backtrackStatus)}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analyst}</td>
|
||
<td className="px-4 py-3 text-sm text-text-2">{analysis.officeName}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
{!loading && analyses.length === 0 && (
|
||
<div className="text-center py-20 text-text-3 text-sm">분석 데이터가 없습니다.</div>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|