대형 파일 집중 변환: - SatelliteRequest: 134→66 (hex 색상 일괄 변환) - IncidentsView: 141→90, MediaModal: 97→38 - HNSScenarioView: 78→38, HNSView: 49→31 - LoginPage, MapView, PredictionInputSection 등 중소 파일 8개 변환 패턴: hex 색상→text-[#hex], CSS 변수→Tailwind 유틸리티, flex/grid/padding/fontSize/fontWeight/overflow 등 정적 속성 className 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
328 lines
20 KiB
TypeScript
328 lines
20 KiB
TypeScript
import { useState } from 'react'
|
|
import type { InsuranceRow } from './assetTypes'
|
|
|
|
const DEFAULT_HAEWOON_API = import.meta.env.VITE_HAEWOON_API_URL || 'https://api.haewoon.or.kr/v1/insurance'
|
|
|
|
// 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용)
|
|
const INSURANCE_DEMO_DATA: InsuranceRow[] = [
|
|
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
|
|
{ shipName: '화학물질운반선 제주호', mmsi: '440345678', imo: '9876002', insType: '선주책임보험', insurer: '삼성화재', policyNo: 'SF-2025-9012', start: '2025-09-16', expiry: '2026-09-15', limit: '80억' },
|
|
{ shipName: '방제선 OCEAN STAR', mmsi: '440123789', imo: '9876003', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-3456', start: '2025-11-21', expiry: '2026-11-20', limit: '120억' },
|
|
{ shipName: 'LNG운반선 부산호', mmsi: '440567890', imo: '9876004', insType: '해상보험', insurer: 'DB손해보험', policyNo: 'DB-2025-7890', start: '2025-08-02', expiry: '2026-08-01', limit: '200억' },
|
|
{ shipName: '유조선 백두호', mmsi: '440789012', imo: '9876005', insType: 'P&I 보험', insurer: 'SK해운보험', policyNo: 'MH-2025-5678', start: '2025-01-01', expiry: '2025-12-31', limit: '30억' },
|
|
]
|
|
|
|
function ShipInsurance() {
|
|
const [apiConnected, setApiConnected] = useState(false)
|
|
const [showConfig, setShowConfig] = useState(false)
|
|
const [configEndpoint, setConfigEndpoint] = useState(DEFAULT_HAEWOON_API)
|
|
const [configApiKey, setConfigApiKey] = useState('')
|
|
const [configKeyType, setConfigKeyType] = useState('mmsi')
|
|
const [configRespType, setConfigRespType] = useState('json')
|
|
const [searchType, setSearchType] = useState('mmsi')
|
|
const [searchVal, setSearchVal] = useState('')
|
|
const [insTypeFilter, setInsTypeFilter] = useState('전체')
|
|
const [viewState, setViewState] = useState<'empty' | 'loading' | 'result'>('empty')
|
|
const [resultData, setResultData] = useState<InsuranceRow[]>([])
|
|
const [lastSync, setLastSync] = useState('—')
|
|
|
|
const placeholderMap: Record<string, string> = {
|
|
mmsi: 'MMSI 번호 입력 (예: 440123456)',
|
|
imo: 'IMO 번호 입력 (예: 9876543)',
|
|
shipname: '선박명 입력 (예: 한라호)',
|
|
callsign: '호출부호 입력 (예: HLXX1)',
|
|
}
|
|
|
|
const getStatus = (expiry: string) => {
|
|
const now = new Date()
|
|
const exp = new Date(expiry)
|
|
const daysLeft = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
if (exp < now) return 'expired' as const
|
|
if (daysLeft <= 30) return 'soon' as const
|
|
return 'valid' as const
|
|
}
|
|
|
|
const handleSaveConfig = () => {
|
|
if (!configApiKey) { alert('API Key를 입력하세요.'); return }
|
|
setShowConfig(false)
|
|
alert('API 설정이 저장되었습니다.')
|
|
}
|
|
|
|
const handleTestConnect = async () => {
|
|
await new Promise(r => setTimeout(r, 1200))
|
|
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
|
|
}
|
|
|
|
const loadDemoData = () => {
|
|
setResultData(INSURANCE_DEMO_DATA)
|
|
setViewState('result')
|
|
setApiConnected(false)
|
|
setLastSync(new Date().toLocaleString('ko-KR'))
|
|
}
|
|
|
|
const handleQuery = async () => {
|
|
if (!searchVal.trim()) { alert('조회값을 입력하세요.'); return }
|
|
setViewState('loading')
|
|
await new Promise(r => setTimeout(r, 900))
|
|
loadDemoData()
|
|
}
|
|
|
|
const handleBatchQuery = async () => {
|
|
setViewState('loading')
|
|
await new Promise(r => setTimeout(r, 1400))
|
|
loadDemoData()
|
|
}
|
|
|
|
const handleFullSync = async () => {
|
|
setLastSync('동기화 중...')
|
|
await new Promise(r => setTimeout(r, 1000))
|
|
setLastSync(new Date().toLocaleString('ko-KR'))
|
|
alert('전체 동기화는 API 연동 후 활성화됩니다.')
|
|
}
|
|
|
|
// summary computation
|
|
const validCount = resultData.filter(r => getStatus(r.expiry) !== 'expired').length
|
|
const soonList = resultData.filter(r => getStatus(r.expiry) === 'soon')
|
|
const expiredList = resultData.filter(r => getStatus(r.expiry) === 'expired')
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 overflow-auto">
|
|
|
|
{/* ── 헤더 ── */}
|
|
<div className="flex items-start justify-between mb-5">
|
|
<div>
|
|
<div className="flex items-center gap-2.5 mb-1">
|
|
<div className="text-[18px] font-bold">🛡 선박 보험정보 조회</div>
|
|
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold"
|
|
style={{
|
|
background: apiConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
|
color: apiConnected ? 'var(--green)' : 'var(--red)',
|
|
border: `1px solid ${apiConnected ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
|
}}>
|
|
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: apiConnected ? 'var(--green)' : 'var(--red)' }} />
|
|
{apiConnected ? 'API 연결됨' : 'API 미연결'}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-text-3">한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleTestConnect} className="px-4 py-2 text-xs font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)' }}>🔌 연결 테스트</button>
|
|
<button onClick={() => setShowConfig(v => !v)} className="px-4 py-2 text-xs font-semibold cursor-pointer rounded-sm bg-bg-3 text-text-2 border border-border">⚙ API 설정</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── API 설정 패널 ── */}
|
|
{showConfig && (
|
|
<div className="bg-bg-3 border border-border rounded-md p-5 mb-5">
|
|
<div className="text-[13px] font-bold mb-3.5 text-primary-cyan">⚙ 한국해운조합 API 연동 설정</div>
|
|
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">API Endpoint URL</label>
|
|
<input type="text" value={configEndpoint} onChange={e => setConfigEndpoint(e.target.value)} placeholder="https://api.haewoon.or.kr/v1/..."
|
|
className="w-full px-3 py-2 bg-bg-0 border border-border rounded-sm font-mono text-xs outline-none box-border" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">API Key</label>
|
|
<input type="password" value={configApiKey} onChange={e => setConfigApiKey(e.target.value)} placeholder="발급받은 API Key 입력"
|
|
className="w-full px-3 py-2 bg-bg-0 border border-border rounded-sm font-mono text-xs outline-none box-border" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">조회 기본값 — 조회 키 타입</label>
|
|
<select value={configKeyType} onChange={e => setConfigKeyType(e.target.value)} className="prd-i w-full" className="border-border">
|
|
<option value="mmsi">MMSI</option>
|
|
<option value="imo">IMO 번호</option>
|
|
<option value="shipname">선박명</option>
|
|
<option value="callsign">호출부호</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">응답 형식</label>
|
|
<select value={configRespType} onChange={e => setConfigRespType(e.target.value)} className="prd-i w-full" className="border-border">
|
|
<option value="json">JSON</option>
|
|
<option value="xml">XML</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleSaveConfig} className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>💾 저장</button>
|
|
<button onClick={() => setShowConfig(false)} className="px-4 py-2 bg-bg-0 text-text-2 border border-border rounded-sm text-xs cursor-pointer">취소</button>
|
|
</div>
|
|
{/* API 연동 안내 */}
|
|
<div className="mt-4 px-4 py-3 rounded-sm text-[10px] text-text-3 leading-[1.8]" style={{ background: 'rgba(6,182,212,.05)', border: '1px solid rgba(6,182,212,.15)' }}>
|
|
<span className="text-primary-cyan font-bold">📋 한국해운조합 API 발급 안내</span><br />
|
|
• 한국해운조합 공공데이터포털 또는 해운조합 IT지원팀에 API 키 신청<br />
|
|
• 해양경찰청 기관 계정으로 신청 시 전용 엔드포인트 및 키 발급<br />
|
|
• 조회 가능 데이터: P&I 보험, 선주책임보험, 해상보험 가입 여부, 증권번호, 보험기간, 보상한도
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 검색 영역 ── */}
|
|
<div className="bg-bg-3 border border-border rounded-md px-5 py-4 mb-4">
|
|
<div className="text-xs font-bold mb-3 text-text-2">🔍 보험정보 조회</div>
|
|
<div className="flex gap-2 items-end flex-wrap">
|
|
<div>
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">조회 키 타입</label>
|
|
<select value={searchType} onChange={e => setSearchType(e.target.value)} className="prd-i min-w-[120px]" className="border-border">
|
|
<option value="mmsi">MMSI</option>
|
|
<option value="imo">IMO 번호</option>
|
|
<option value="shipname">선박명</option>
|
|
<option value="callsign">호출부호</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex-1 min-w-[220px]">
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">조회값</label>
|
|
<input type="text" value={searchVal} onChange={e => setSearchVal(e.target.value)} placeholder={placeholderMap[searchType]}
|
|
className="w-full px-3.5 py-2 bg-bg-0 border border-border rounded-sm font-mono text-[13px] outline-none box-border" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">보험 종류</label>
|
|
<select value={insTypeFilter} onChange={e => setInsTypeFilter(e.target.value)} className="prd-i min-w-[140px]" className="border-border">
|
|
<option>전체</option>
|
|
<option>P&I 보험</option>
|
|
<option>선주책임보험</option>
|
|
<option>해상보험(선박)</option>
|
|
<option>방제보증보험</option>
|
|
</select>
|
|
</div>
|
|
<button onClick={handleQuery} className="px-6 py-2 text-white border-none rounded-sm text-[13px] font-bold cursor-pointer shrink-0" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>🔍 조회</button>
|
|
<button onClick={handleBatchQuery} className="px-4 py-2 text-[12px] font-semibold cursor-pointer shrink-0 rounded-sm" style={{ background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)' }}>📋 자산목록 일괄조회</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 결과 영역 ── */}
|
|
|
|
{/* 초기 안내 상태 */}
|
|
{viewState === 'empty' && (
|
|
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-3 border border-border rounded-md">
|
|
<div className="text-[48px] mb-4 opacity-30">🛡</div>
|
|
<div className="text-sm font-bold text-text-2 mb-2">한국해운조합 API 연동 대기 중</div>
|
|
<div className="text-xs text-text-3 text-center leading-[1.8]">
|
|
API 설정에서 한국해운조합 API Key를 등록하거나<br />
|
|
MMSI·IMO·선박명으로 직접 조회하세요.<br />
|
|
<span className="text-primary-cyan">자산목록 일괄조회</span> 시 등록된 방제자산 전체의 보험 현황을 한번에 확인할 수 있습니다.
|
|
</div>
|
|
<div className="mt-5 flex gap-2.5">
|
|
<button onClick={() => setShowConfig(true)} className="px-5 py-2.5 text-xs font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)' }}>⚙ API 설정</button>
|
|
<button onClick={loadDemoData} className="px-5 py-2.5 bg-bg-0 text-text-2 border border-border rounded-sm text-xs font-semibold cursor-pointer">📊 샘플 데이터 보기</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 로딩 */}
|
|
{viewState === 'loading' && (
|
|
<div className="flex flex-col items-center justify-center p-16 bg-bg-3 border border-border rounded-md">
|
|
<div className="w-9 h-9 rounded-full mb-3.5" style={{ border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', animation: 'spin 0.8s linear infinite' }} />
|
|
<div className="text-[13px] text-text-2">한국해운조합 API 조회 중...</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 결과 테이블 */}
|
|
{viewState === 'result' && (
|
|
<>
|
|
{/* 요약 카드 */}
|
|
<div className="grid gap-2.5 mb-3.5" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
|
{[
|
|
{ label: '전체', val: resultData.length, color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)' },
|
|
{ label: '유효', val: validCount, color: 'var(--green)', bg: 'rgba(34,197,94,.08)' },
|
|
{ label: '만료임박(30일)', val: soonList.length, color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)' },
|
|
{ label: '만료/미가입', val: resultData.length - validCount, color: 'var(--red)', bg: 'rgba(239,68,68,.08)' },
|
|
].map((c, i) => (
|
|
<div key={i} className="px-4 py-3.5 text-center rounded-sm" style={{ background: c.bg, border: `1px solid ${c.color}33` }}>
|
|
<div className="text-[22px] font-extrabold font-mono" style={{ color: c.color }}>{c.val}</div>
|
|
<div className="text-[10px] text-text-3 mt-0.5">{c.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="bg-bg-3 border border-border rounded-md overflow-hidden mb-3">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="text-xs font-bold">조회 결과 <span className="text-primary-cyan">{resultData.length}</span>건</div>
|
|
<div className="flex gap-1.5">
|
|
<button onClick={() => alert('엑셀 내보내기 기능은 실제 API 연동 후 활성화됩니다.')} className="px-3 py-1 text-[11px] font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(34,197,94,.1)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.25)' }}>📥 엑셀 내보내기</button>
|
|
<button onClick={handleQuery} className="px-3 py-1 bg-bg-0 text-text-2 border border-border rounded-sm text-[11px] cursor-pointer">🔄 새로고침</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-[11px] border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-0">
|
|
{[
|
|
{ label: '선박명', align: 'left' },
|
|
{ label: 'MMSI', align: 'center' },
|
|
{ label: 'IMO', align: 'center' },
|
|
{ label: '보험종류', align: 'center' },
|
|
{ label: '보험사', align: 'center' },
|
|
{ label: '증권번호', align: 'center' },
|
|
{ label: '보험기간', align: 'center' },
|
|
{ label: '보상한도', align: 'right' },
|
|
{ label: '상태', align: 'center' },
|
|
].map((h, i) => (
|
|
<th key={i} className="px-3.5 py-2.5 font-bold text-text-2 border-b border-border whitespace-nowrap" style={{ textAlign: h.align as 'left' | 'center' | 'right' }}>{h.label}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{resultData.map((r, i) => {
|
|
const st = getStatus(r.expiry)
|
|
const isExp = st === 'expired'
|
|
const isSoon = st === 'soon'
|
|
return (
|
|
<tr key={i} className="border-b border-border" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
|
<td className="px-3.5 py-2.5 font-semibold">{r.shipName}</td>
|
|
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]">{r.mmsi || '—'}</td>
|
|
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]">{r.imo || '—'}</td>
|
|
<td className="px-3.5 py-2.5 text-center">{r.insType}</td>
|
|
<td className="px-3.5 py-2.5 text-center">{r.insurer}</td>
|
|
<td className="px-3.5 py-2.5 text-center font-mono text-[10px] text-text-3">{r.policyNo}</td>
|
|
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]" style={{ color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>{r.start} ~ {r.expiry}</td>
|
|
<td className="px-3.5 py-2.5 text-right font-bold font-mono">{r.limit}</td>
|
|
<td className="px-3.5 py-2.5 text-center">
|
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold" style={{
|
|
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
|
|
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
|
|
}}>
|
|
{isExp ? '만료' : isSoon ? '만료임박' : '유효'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 경고 */}
|
|
{(expiredList.length > 0 || soonList.length > 0) && (
|
|
<div className="px-4 py-3 text-xs text-text-2 mb-3 rounded-sm" style={{ background: 'rgba(234,179,8,.06)', border: '1px solid rgba(234,179,8,.25)' }}>
|
|
{expiredList.length > 0 && (
|
|
<><span className="text-status-red font-bold">⛔ 만료 {expiredList.length}건:</span> {expiredList.map(r => r.shipName).join(', ')}<br /></>
|
|
)}
|
|
{soonList.length > 0 && (
|
|
<><span className="font-bold text-status-yellow">⚠ 만료임박(30일) {soonList.length}건:</span> {soonList.map(r => r.shipName).join(', ')}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── API 연동 정보 푸터 ── */}
|
|
<div className="mt-4 px-4 py-3 bg-bg-3 border border-border rounded-sm flex items-center justify-between">
|
|
<div className="text-[10px] text-text-3 leading-[1.7]">
|
|
<span className="text-text-2 font-bold">데이터 출처:</span> 한국해운조합(KSA) · haewoon.or.kr<br />
|
|
<span className="text-text-2 font-bold">연동 방식:</span> REST API (JSON) · 실시간 조회 · 캐시 TTL 1시간
|
|
</div>
|
|
<div className="flex gap-1.5 items-center">
|
|
<span className="text-[10px] text-text-3">마지막 동기화:</span>
|
|
<span className="text-[10px] text-text-2 font-mono">{lastSync}</span>
|
|
<button onClick={handleFullSync} className="px-2.5 py-1 bg-bg-0 text-text-2 border border-border rounded text-[10px] cursor-pointer">전체 동기화</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ShipInsurance
|