304 lines
14 KiB
TypeScript
Executable File
304 lines
14 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback } from 'react';
|
|
import type { Dispatch, SetStateAction } from 'react';
|
|
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi';
|
|
|
|
interface HNSAnalysisListTableProps {
|
|
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>;
|
|
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
|
|
}
|
|
|
|
const RISK_LABEL: Record<string, string> = {
|
|
CRITICAL: '심각',
|
|
HIGH: '위험',
|
|
MEDIUM: '경고',
|
|
LOW: '관찰',
|
|
};
|
|
|
|
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
|
|
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)' },
|
|
MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' },
|
|
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
|
};
|
|
|
|
function formatDate(dtm: string | null, mode: 'full' | 'date') {
|
|
if (!dtm) return '—';
|
|
const d = new Date(dtm);
|
|
if (mode === 'date') return d.toISOString().slice(0, 10);
|
|
return d.toISOString().slice(0, 16).replace('T', ' ');
|
|
}
|
|
|
|
function substanceTag(sbstNm: string | null): string {
|
|
if (!sbstNm) return '—';
|
|
const match = sbstNm.match(/\(([^)]+)\)/);
|
|
if (match) return match[1];
|
|
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm;
|
|
}
|
|
|
|
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
|
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const items = await fetchHnsAnalyses();
|
|
setAnalyses(items);
|
|
} catch (err) {
|
|
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
|
|
// DB 실패 시 localStorage에서 불러오기
|
|
try {
|
|
const localRaw = localStorage.getItem('hns_saved_analyses');
|
|
if (localRaw) {
|
|
const localItems = JSON.parse(localRaw) as Record<string, unknown>[];
|
|
const mapped: HnsAnalysisItem[] = localItems.map((entry) => {
|
|
const rslt = entry.rsltData as Record<string, unknown> | null;
|
|
const inputP = rslt?.inputParams as Record<string, unknown> | null;
|
|
const coord = rslt?.coord as { lon: number; lat: number } | null;
|
|
const weather = rslt?.weather as Record<string, unknown> | null;
|
|
return {
|
|
hnsAnlysSn: (entry.id as number) || 0,
|
|
anlysNm: (entry.anlysNm as string) || '로컬 저장 분석',
|
|
acdntDtm:
|
|
(entry.acdntDtm as string) ||
|
|
(inputP?.accidentDate && inputP?.accidentTime
|
|
? `${inputP.accidentDate as string}T${inputP.accidentTime as string}:00`
|
|
: (inputP?.accidentDate as string)) ||
|
|
null,
|
|
locNm: coord ? `${coord.lat.toFixed(4)} / ${coord.lon.toFixed(4)}` : null,
|
|
lon: coord?.lon ?? null,
|
|
lat: coord?.lat ?? null,
|
|
sbstNm: (entry.sbstNm as string) || null,
|
|
spilQty: (entry.spilQty as number) ?? null,
|
|
spilUnitCd: (entry.spilUnitCd as string) || null,
|
|
fcstHr: (entry.fcstHr as number) ?? null,
|
|
algoCd: (inputP?.algorithm as string) || null,
|
|
critMdlCd: (inputP?.criteriaModel as string) || null,
|
|
windSpd: (weather?.windSpeed as number) ?? null,
|
|
windDir: weather?.windDirection != null ? String(weather.windDirection) : null,
|
|
execSttsCd: 'COMPLETED',
|
|
riskCd: (entry.riskCd as string) || null,
|
|
analystNm: (entry.analystNm as string) || null,
|
|
rsltData: rslt ?? null,
|
|
regDtm: (entry.regDtm as string) || new Date().toISOString(),
|
|
_isLocal: true,
|
|
} as HnsAnalysisItem & { _isLocal?: boolean };
|
|
});
|
|
setAnalyses(mapped);
|
|
}
|
|
} catch {
|
|
// localStorage 파싱 실패 무시
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-bg-base">
|
|
{/* Header */}
|
|
<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">HNS 대기확산 분석 목록</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="검색..."
|
|
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>
|
|
|
|
{/* Table */}
|
|
<div className="flex-1 overflow-auto">
|
|
{loading ? (
|
|
<div className="text-center text-fg-disabled text-label-1 py-20">로딩 중...</div>
|
|
) : (
|
|
<table className="w-full text-label-2 border-collapse">
|
|
<thead className="sticky top-0 z-10">
|
|
<tr className="bg-bg-elevated border-b border-stroke">
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
번호
|
|
</th>
|
|
<th className="text-left text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
분석명
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
물질
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
사고일시
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
분석날짜
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
사고지점
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
유출량
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
알고리즘
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
예측시간
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
<span>AEGL-3</span>
|
|
<span className="text-caption text-fg-disabled">생명위협</span>
|
|
</div>
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
<span>AEGL-2</span>
|
|
<span className="text-caption text-fg-disabled">건강피해</span>
|
|
</div>
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
<span>AEGL-1</span>
|
|
<span className="text-caption text-fg-disabled">불쾌감</span>
|
|
</div>
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
위험등급
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
피해반경
|
|
</th>
|
|
<th className="text-center text-label-1 font-bold text-fg-disabled px-4 py-3">
|
|
분석자
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{analyses.map((item) => {
|
|
const rslt = item.rsltData as Record<string, unknown> | null;
|
|
const isLocal = !!(item as HnsAnalysisItem & { _isLocal?: boolean })._isLocal;
|
|
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—';
|
|
const riskStyle = RISK_STYLE[item.riskCd || ''] || {
|
|
bg: 'rgba(100,100,100,0.1)',
|
|
color: 'var(--fg-disabled)',
|
|
};
|
|
const aegl3 = rslt?.aegl3 as boolean | undefined;
|
|
const aegl2 = rslt?.aegl2 as boolean | undefined;
|
|
const aegl1 = rslt?.aegl1 as boolean | undefined;
|
|
const damageRadius = (rslt?.damageRadius as string) || '—';
|
|
const amount =
|
|
item.spilQty != null ? `${item.spilQty} ${item.spilUnitCd || 'KL'}` : '—';
|
|
|
|
return (
|
|
<tr
|
|
key={item.hnsAnlysSn}
|
|
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
|
onClick={() =>
|
|
onSelectAnalysis?.(item.hnsAnlysSn, isLocal && rslt ? rslt : undefined)
|
|
}
|
|
>
|
|
<td className="text-center text-fg-disabled font-mono px-4 py-3">
|
|
{item.hnsAnlysSn}
|
|
</td>
|
|
<td className="font-medium px-4 py-3">{item.anlysNm}</td>
|
|
<td className="text-center px-4 py-3">
|
|
<span
|
|
className="text-caption font-semibold text-color-accent px-2 py-1 rounded"
|
|
style={{ background: 'rgba(6,182,212,0.12)' }}
|
|
>
|
|
{substanceTag(item.sbstNm)}
|
|
</span>
|
|
</td>
|
|
<td className="text-center text-fg-sub font-mono text-label-2 px-4 py-3">
|
|
{formatDate(item.acdntDtm, 'full')}
|
|
</td>
|
|
<td className="text-center text-fg-disabled font-mono text-label-2 px-4 py-3">
|
|
{formatDate(item.regDtm, 'date')}
|
|
</td>
|
|
<td className="text-center text-fg-sub px-4 py-3">{item.locNm || '—'}</td>
|
|
<td className="text-center text-fg-sub font-mono px-4 py-3">{amount}</td>
|
|
<td className="text-center px-4 py-3">
|
|
<span
|
|
className="text-caption font-semibold text-color-accent px-2 py-1 rounded"
|
|
style={{ background: 'rgba(6,182,212,0.12)' }}
|
|
>
|
|
{item.algoCd || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="text-center text-fg-sub font-mono px-4 py-3">
|
|
{item.fcstHr ? `${item.fcstHr}H` : '—'}
|
|
</td>
|
|
<td className="text-center px-4 py-3">
|
|
<div
|
|
className="w-6 h-6 rounded-full mx-auto"
|
|
style={{
|
|
background: aegl3 ? 'var(--color-danger)' : 'rgba(255,255,255,0.1)',
|
|
border: aegl3 ? 'none' : '1px solid var(--stroke-default)',
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="text-center px-4 py-3">
|
|
<div
|
|
className="w-6 h-6 rounded-full mx-auto"
|
|
style={{
|
|
background: aegl2 ? 'var(--color-warning)' : 'rgba(255,255,255,0.1)',
|
|
border: aegl2 ? 'none' : '1px solid var(--stroke-default)',
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="text-center px-4 py-3">
|
|
<div
|
|
className="w-6 h-6 rounded-full mx-auto"
|
|
style={{
|
|
background: aegl1 ? 'var(--color-caution)' : 'rgba(255,255,255,0.1)',
|
|
border: aegl1 ? 'none' : '1px solid var(--stroke-default)',
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="text-center px-4 py-3">
|
|
<span
|
|
className="text-caption font-semibold px-[10px] py-1 rounded"
|
|
style={{ background: riskStyle.bg, color: riskStyle.color }}
|
|
>
|
|
{riskLabel}
|
|
</span>
|
|
</td>
|
|
<td className="text-center text-fg-sub font-mono px-4 py-3">{damageRadius}</td>
|
|
<td className="text-center text-fg-disabled text-label-2 px-4 py-3">
|
|
{item.analystNm || '—'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{!loading && analyses.length === 0 && (
|
|
<div className="text-center text-fg-disabled text-label-1 py-20">
|
|
분석 데이터가 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|