wing-ops/frontend/src/tabs/prediction/components/AnalysisListTable.tsx
jeonghyo.k 2640d882da feat(incidents): 이미지 분석 연동 강화 및 사고 팝업 리뉴얼
- 사고별 이미지 분석 API 및 항공 미디어 조회 연동
- 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시
- 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경
- 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031)
- OpenDrift 유종 매핑 수정 (원유, 등유)
2026-04-13 16:41:56 +09:00

323 lines
13 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-disabled">
</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-disabled">
...
</span>
);
}
return (
<button
key={page}
onClick={() => setCurrentPage(page as number)}
className={`px-3 py-1 text-title-3 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 font-bold text-fg"> </h1>
<p className="text-title-3 text-fg-disabled 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-title-3 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-title-3 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-disabled text-title-3"> ...</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-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
KOSPS
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
POSEIDON
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
OpenDrift
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled 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-title-3 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-title-3 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-title-3 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-title-3 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-title-3 text-fg-sub font-mono">
{analysis.duration}
</td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.oilType}</td>
<td className="px-4 py-3 text-title-3 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-title-3 text-fg-sub">{analysis.analyst}</td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.officeName}</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && analyses.length === 0 && (
<div className="text-center py-20 text-fg-disabled text-title-3">
.
</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-title-3 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-title-3 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-title-3 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-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
}