wing-ops/frontend/src/tabs/hns/components/HNSRecalcModal.tsx
htlee ff085252b0 feat(phase4): Board/HNS/Prediction/Aerial/Rescue Mock → API 전환
- 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>
2026-03-01 01:17:10 +09:00

280 lines
12 KiB
TypeScript
Executable File

import { useState, useRef, useEffect } from 'react'
import { createHnsAnalysis } from '../services/hnsApi'
interface HNSRecalcModalProps {
isOpen: boolean
onClose: () => void
onSubmit: () => void
}
type RecalcPhase = 'editing' | 'running' | 'done'
const HNS_SUBSTANCES = ['톨루엔', '암모니아', '메탄올', '수소', '벤젠', '스티렌', 'LNG', '염소', '황화수소']
const RELEASE_TYPES = ['순간 유출', '연속 유출', '반연속']
const MODELS = ['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian']
const STABILITIES = ['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)']
const PRED_TIMES = [1, 3, 6, 12, 24, 48]
export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProps) {
const backdropRef = useRef<HTMLDivElement>(null)
const [substance, setSubstance] = useState('톨루엔')
const [releaseType, setReleaseType] = useState('순간 유출')
const [amount, setAmount] = useState(2.5)
const [unit, setUnit] = useState<'t' | 'kg' | 'm³' | 'L'>('t')
const [windDir, setWindDir] = useState('SW')
const [windSpeed, setWindSpeed] = useState(5.2)
const [temp, setTemp] = useState(18.5)
const [stability, setStability] = useState('D (중립)')
const [model, setModel] = useState('ALOHA')
const [predTime, setPredTime] = useState(6)
const [lat, setLat] = useState(35.4215)
const [lon, setLon] = useState(129.3542)
const [phase, setPhase] = useState<RecalcPhase>('editing')
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (isOpen) setPhase('editing')
}, [isOpen])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (e.target === backdropRef.current) onClose()
}
if (isOpen) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [isOpen, onClose])
const handleRun = async () => {
setPhase('running')
try {
await createHnsAnalysis({
anlysNm: `HNS 재계산 — ${substance}`,
lon,
lat,
sbstNm: substance,
spilQty: amount,
spilUnitCd: unit,
fcstHr: predTime,
algoCd: model,
windSpd: windSpeed,
windDir,
temp,
atmStblCd: stability.charAt(0),
})
setPhase('done')
setTimeout(() => {
onSubmit()
onClose()
}, 800)
} catch (err) {
console.error('[hns] 재계산 실패:', err)
setPhase('editing')
alert('재계산 실행에 실패했습니다.')
}
}
if (!isOpen) return null
return (
<div
ref={backdropRef}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div style={{
width: '400px', maxHeight: 'calc(100vh - 100px)',
background: 'var(--bg1)', border: '1px solid var(--bd)',
borderRadius: '14px', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}>
{/* Header */}
<div style={{
padding: '16px 20px', borderBottom: '1px solid var(--bd)',
display: 'flex', alignItems: 'center', gap: '12px',
}}>
<div style={{
width: '36px', height: '36px', borderRadius: '10px',
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))',
border: '1px solid rgba(249,115,22,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '16px',
}}>🔄</div>
<div style={{ flex: 1 }}>
<h2 style={{ fontSize: '15px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', margin: 0 }}>
HNS
</h2>
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
·
</div>
</div>
<button onClick={onClose} style={{
width: '28px', height: '28px', borderRadius: '6px',
border: '1px solid var(--bd)', background: 'var(--bg3)',
color: 'var(--t3)', fontSize: '12px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
</div>
{/* Content */}
<div style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: '14px',
scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent',
}}>
{/* 현재 분석 정보 */}
<div style={{
padding: '10px 12px', background: 'rgba(249,115,22,0.04)',
border: '1px solid rgba(249,115,22,0.15)', borderRadius: '8px',
}}>
<div style={{ fontSize: '9px', fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '9px' }}>
<InfoRow label="사고명" value="울산 온산항 톨루엔 유출" />
<InfoRow label="물질" value="톨루엔 (Toluene)" />
<InfoRow label="유출량" value="2.5 ton" />
<InfoRow label="확산 모델" value="ALOHA v5.4.7" />
</div>
</div>
{/* HNS 물질 */}
<FG label="HNS 물질">
<select className="prd-i" value={substance} onChange={e => setSubstance(e.target.value)}>
{HNS_SUBSTANCES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</FG>
{/* 유출 유형 + 유출량 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<FG label="유출 유형">
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
{RELEASE_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</FG>
<FG label="유출량">
<div style={{ display: 'flex', gap: '4px' }}>
<input className="prd-i" type="number" value={amount} onChange={e => setAmount(Number(e.target.value))} step={0.1} style={{ flex: 1 }} />
<select className="prd-i" value={unit} onChange={e => setUnit(e.target.value as typeof unit)} style={{ width: '55px' }}>
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</FG>
</div>
{/* 풍향 / 풍속 / 기온 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '10px' }}>
<FG label="풍향">
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FG>
<FG label="풍속 (m/s)">
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(Number(e.target.value))} step={0.1} />
</FG>
<FG label="기온 (°C)">
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(Number(e.target.value))} step={0.1} />
</FG>
</div>
{/* 대기안정도 + 확산 모델 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<FG label="대기안정도 (Pasquill)">
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
{STABILITIES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</FG>
<FG label="확산 모델">
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
{MODELS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</FG>
</div>
{/* 예측 시간 */}
<FG label="예측 시간">
<select className="prd-i" value={predTime} onChange={e => setPredTime(Number(e.target.value))}>
{PRED_TIMES.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</FG>
{/* 유출 위치 */}
<FG label="유출 위치 (좌표)">
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '3px' }}> (N)</div>
<input className="prd-i" type="number" value={lat} step={0.0001} onChange={e => setLat(Number(e.target.value))} style={{ fontFamily: 'var(--fM)' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '3px' }}> (E)</div>
<input className="prd-i" type="number" value={lon} step={0.0001} onChange={e => setLon(Number(e.target.value))} style={{ fontFamily: 'var(--fM)' }} />
</div>
</div>
</FG>
</div>
{/* Footer */}
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--bd)', display: 'flex', gap: '8px' }}>
<button
onClick={onClose}
disabled={phase !== 'editing'}
style={{
flex: 1, padding: '10px', fontSize: '12px', fontWeight: 600,
fontFamily: 'var(--fK)', borderRadius: '8px', cursor: 'pointer',
background: 'var(--bg3)', border: '1px solid var(--bd)',
color: 'var(--t2)', opacity: phase !== 'editing' ? 0.5 : 1,
}}
></button>
<button
onClick={handleRun}
disabled={phase !== 'editing'}
style={{
flex: 2, padding: '10px', fontSize: '12px', fontWeight: 700,
fontFamily: 'var(--fK)', borderRadius: '8px',
cursor: phase === 'editing' ? 'pointer' : 'wait',
background: phase === 'done'
? 'rgba(34,197,94,0.15)'
: phase === 'running'
? 'var(--bg3)'
: 'linear-gradient(135deg, var(--orange), #ef4444)',
border: phase === 'done'
? '1px solid rgba(34,197,94,0.4)'
: phase === 'running'
? '1px solid var(--bd)'
: 'none',
color: phase === 'done'
? '#22c55e'
: phase === 'running'
? 'var(--orange)'
: '#fff',
}}
>
{phase === 'done' ? '✅ 재계산 완료!' : phase === 'running' ? '⏳ 재계산 실행중...' : '🔄 재계산 실행'}
</button>
</div>
</div>
</div>
)
}
function FG({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>{label}</div>
{children}
</div>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
<span style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{label}</span>
<span style={{ color: 'var(--t1)', fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</span>
</div>
)
}