323 lines
12 KiB
TypeScript
Executable File
323 lines
12 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-label-2 font-medium rounded-md bg-[rgba(34,197,94,0.15)] text-color-success">
|
||
완료
|
||
</span>
|
||
);
|
||
case 'running':
|
||
return (
|
||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(249,115,22,0.15)] text-color-warning">
|
||
실행중
|
||
</span>
|
||
);
|
||
case 'pending':
|
||
return (
|
||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(138,150,168,0.15)] text-fg-default">
|
||
대기
|
||
</span>
|
||
);
|
||
case 'error':
|
||
return (
|
||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(239,68,68,0.15)] text-color-danger">
|
||
오류
|
||
</span>
|
||
);
|
||
case 'failed':
|
||
return (
|
||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(239,68,68,0.15)] text-color-danger">
|
||
실패
|
||
</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-fg-default">
|
||
...
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page as number)}
|
||
className={`px-3 py-1 text-body-2 font-medium rounded transition-colors ${
|
||
currentPage === page ? 'bg-color-accent text-bg-0' : 'text-fg-sub hover:bg-bg-elevated'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full bg-bg-base">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||
<div>
|
||
<h1 className="text-heading-3 text-fg">유출유 확산 예측 목록</h1>
|
||
<p className="text-body-2 text-fg-default 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-body-2 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => onTabChange('analysis')}
|
||
className="px-4 py-2 text-body-2 font-semibold rounded-sm cursor-pointer text-color-accent"
|
||
style={{
|
||
border: '1px solid rgba(6,182,212,.3)',
|
||
background: 'rgba(6,182,212,.08)',
|
||
}}
|
||
>
|
||
+ 새 분석
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 */}
|
||
<div className="flex-1 overflow-auto">
|
||
{loading ? (
|
||
<div className="text-center py-20 text-fg-default text-body-2">로딩 중...</div>
|
||
) : (
|
||
<table className="w-full">
|
||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
번호
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
사고명
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
사고일시
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
예측 실행
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
예측시간
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
유종
|
||
</th>
|
||
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
유출량
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
KOSPS
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
POSEIDON
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
OpenDrift
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
역추적
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
담당자
|
||
</th>
|
||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||
소속
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stroke">
|
||
{currentAnalyses.map((analysis) => (
|
||
<tr
|
||
key={analysis.predRunSn ?? analysis.acdntSn}
|
||
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
||
>
|
||
<td className="px-4 py-3 text-body-2 text-fg-sub 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-color-danger animate-pulse" />
|
||
<span
|
||
className="text-body-2 font-medium text-color-accent 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-body-2 text-fg-sub 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-body-2 text-fg-sub font-mono">
|
||
{analysis.runDtm
|
||
? new Date(analysis.runDtm).toLocaleString('ko-KR', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
: '—'}
|
||
</td>
|
||
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
|
||
{analysis.duration}
|
||
</td>
|
||
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.oilType}</td>
|
||
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-medium">
|
||
{analysis.volume != null
|
||
? analysis.volume >= 0.01
|
||
? analysis.volume.toFixed(2)
|
||
: analysis.volume.toExponential(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-body-2 text-fg-sub">{analysis.analyst}</td>
|
||
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.officeName}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
{!loading && analyses.length === 0 && (
|
||
<div className="text-center py-20 text-fg-default text-body-2">
|
||
분석 데이터가 없습니다.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 페이지네이션 */}
|
||
<div className="flex items-center justify-center gap-2 px-5 py-4 border-t border-stroke">
|
||
<button
|
||
onClick={() => setCurrentPage(1)}
|
||
disabled={currentPage === 1}
|
||
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated 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-body-2 font-medium text-fg-sub hover:bg-bg-elevated 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-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
disabled={currentPage === totalPages}
|
||
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
≫
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|