wing-ops/frontend/src/tabs/prediction/components/AnalysisListTable.tsx

323 lines
12 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, 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>
);
}