|
|
|
|
@ -1,285 +1,219 @@
|
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
import type { InsuranceRow } from './assetTypes'
|
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
import { fetchInsurance } from '../services/assetsApi'
|
|
|
|
|
import type { ShipInsuranceItem } from '../services/assetsApi'
|
|
|
|
|
|
|
|
|
|
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억' },
|
|
|
|
|
]
|
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
|
|
|
|
|
|
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 [rows, setRows] = useState<ShipInsuranceItem[]>([])
|
|
|
|
|
const [total, setTotal] = useState(0)
|
|
|
|
|
const [page, setPage] = useState(1)
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
const placeholderMap: Record<string, string> = {
|
|
|
|
|
mmsi: 'MMSI 번호 입력 (예: 440123456)',
|
|
|
|
|
imo: 'IMO 번호 입력 (예: 9876543)',
|
|
|
|
|
shipname: '선박명 입력 (예: 한라호)',
|
|
|
|
|
callsign: '호출부호 입력 (예: HLXX1)',
|
|
|
|
|
}
|
|
|
|
|
// 필터
|
|
|
|
|
const [search, setSearch] = useState('')
|
|
|
|
|
const [shipTpFilter, setShipTpFilter] = useState('')
|
|
|
|
|
const [issueOrgFilter, setIssueOrgFilter] = useState('')
|
|
|
|
|
|
|
|
|
|
const getStatus = (expiry: string) => {
|
|
|
|
|
const loadData = useCallback(async (p: number) => {
|
|
|
|
|
setIsLoading(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetchInsurance({
|
|
|
|
|
search: search || undefined,
|
|
|
|
|
shipTp: shipTpFilter || undefined,
|
|
|
|
|
issueOrg: issueOrgFilter || undefined,
|
|
|
|
|
page: p,
|
|
|
|
|
limit: PAGE_SIZE,
|
|
|
|
|
})
|
|
|
|
|
setRows(res.rows)
|
|
|
|
|
setTotal(res.total)
|
|
|
|
|
setPage(p)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as { message?: string })?.message || '조회 실패')
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}, [search, shipTpFilter, issueOrgFilter])
|
|
|
|
|
|
|
|
|
|
useEffect(() => { loadData(1) }, [loadData])
|
|
|
|
|
|
|
|
|
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
|
|
|
|
|
|
|
|
|
const getStatus = (endDate: 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
|
|
|
|
|
const end = new Date(endDate)
|
|
|
|
|
const daysLeft = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
|
if (end < 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 ynBadge = (yn: string) => {
|
|
|
|
|
const isY = yn === 'Y'
|
|
|
|
|
return (
|
|
|
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold" style={{
|
|
|
|
|
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
|
|
|
|
|
color: isY ? 'var(--green)' : 'var(--text-3)',
|
|
|
|
|
}}>
|
|
|
|
|
{isY ? 'Y' : 'N'}
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleTestConnect = async () => {
|
|
|
|
|
await new Promise(r => setTimeout(r, 1200))
|
|
|
|
|
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
|
|
|
|
|
const handleSearch = () => loadData(1)
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
setSearch('')
|
|
|
|
|
setShipTpFilter('')
|
|
|
|
|
setIssueOrgFilter('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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="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)'}`,
|
|
|
|
|
background: total > 0 ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
|
|
|
|
color: total > 0 ? 'var(--green)' : 'var(--red)',
|
|
|
|
|
border: `1px solid ${total > 0 ? '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 미연결'}
|
|
|
|
|
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: total > 0 ? 'var(--green)' : 'var(--red)' }} />
|
|
|
|
|
{total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-text-3">한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회</div>
|
|
|
|
|
<div className="text-xs text-text-3">해양수산부 해운항만물류정보 공공데이터 기반</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>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
|
|
|
|
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
|
|
|
|
style={{ background: 'rgba(59,130,246,.12)', color: 'var(--blue)', border: '1px solid rgba(59,130,246,.3)' }}
|
|
|
|
|
>
|
|
|
|
|
한국해운조합 API
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
|
|
|
|
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
|
|
|
|
style={{ background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)' }}
|
|
|
|
|
>
|
|
|
|
|
PortMIS
|
|
|
|
|
</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 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 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] 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 className="flex gap-2.5 items-end flex-wrap">
|
|
|
|
|
<div className="flex-1 min-w-[200px]">
|
|
|
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">검색 (선박명/호출부호/IMO/선주)</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text" value={search} onChange={e => setSearch(e.target.value)}
|
|
|
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
|
|
|
placeholder="선박명, 호출부호, IMO, 선주명"
|
|
|
|
|
className="w-full px-3.5 py-2 bg-bg-0 border border-border rounded-sm text-xs 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] border-border">
|
|
|
|
|
<option>전체</option>
|
|
|
|
|
<option>P&I 보험</option>
|
|
|
|
|
<option>선주책임보험</option>
|
|
|
|
|
<option>해상보험(선박)</option>
|
|
|
|
|
<option>방제보증보험</option>
|
|
|
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">선박종류</label>
|
|
|
|
|
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border">
|
|
|
|
|
<option value="">전체</option>
|
|
|
|
|
<option value="일반선박">일반선박</option>
|
|
|
|
|
<option value="유조선">유조선</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>
|
|
|
|
|
<label className="block text-[10px] font-semibold text-text-3 mb-1">발급기관</label>
|
|
|
|
|
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border">
|
|
|
|
|
<option value="">전체</option>
|
|
|
|
|
<option>부산지방해양수산청</option>
|
|
|
|
|
<option>인천지방해양수산청</option>
|
|
|
|
|
<option>여수지방해양수산청</option>
|
|
|
|
|
<option>울산지방해양수산청</option>
|
|
|
|
|
<option>동해지방해양수산청</option>
|
|
|
|
|
<option>목포지방해양수산청</option>
|
|
|
|
|
<option>군산지방해양수산청</option>
|
|
|
|
|
<option>마산지방해양수산청</option>
|
|
|
|
|
<option>대산지방해양수산청</option>
|
|
|
|
|
<option>평택지방해양수산청</option>
|
|
|
|
|
<option>제주지방해양수산청</option>
|
|
|
|
|
<option>포항지방해양수산청</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={handleSearch} 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={handleReset} className="px-4 py-2 bg-bg-0 text-text-2 border border-border rounded-sm text-xs cursor-pointer">초기화</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' && (
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<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 className="text-[13px] text-text-2">보험 데이터 조회 중...</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>
|
|
|
|
|
{/* 에러 */}
|
|
|
|
|
{error && !isLoading && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-3 border border-border rounded-md">
|
|
|
|
|
<div className="text-sm font-bold text-status-red mb-2">조회 실패</div>
|
|
|
|
|
<div className="text-xs text-text-3">{error}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 테이블 */}
|
|
|
|
|
{/* 테이블 */}
|
|
|
|
|
{!isLoading && !error && (
|
|
|
|
|
<>
|
|
|
|
|
<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 className="text-xs font-bold">
|
|
|
|
|
조회 결과 <span className="text-primary-cyan">{total.toLocaleString()}</span>건
|
|
|
|
|
{totalPages > 1 && <span className="text-text-3 font-normal ml-2">({page}/{totalPages} 페이지)</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-[11px] border-collapse">
|
|
|
|
|
<table className="w-full text-[11px] border-collapse whitespace-nowrap">
|
|
|
|
|
<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>
|
|
|
|
|
{['No', '선박명', '호출부호', 'IMO', '선박종류', '선주', '총톤수', '보험사', '책임', '유류', '연료유', '난파물', '유효기간', '발급기관', '상태'].map((h, i) => (
|
|
|
|
|
<th key={i} className="px-3 py-2.5 font-bold text-text-2 border-b border-border text-center">{h}</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{resultData.map((r, i) => {
|
|
|
|
|
const st = getStatus(r.expiry)
|
|
|
|
|
{rows.map((r, i) => {
|
|
|
|
|
const st = getStatus(r.validEnd)
|
|
|
|
|
const isExp = st === 'expired'
|
|
|
|
|
const isSoon = st === 'soon'
|
|
|
|
|
const rowNum = (page - 1) * PAGE_SIZE + i + 1
|
|
|
|
|
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={{
|
|
|
|
|
<tr key={r.insSn} className="border-b border-border" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
|
|
|
|
<td className="px-3 py-2 text-center text-text-3 font-mono">{rowNum}</td>
|
|
|
|
|
<td className="px-3 py-2 font-semibold">{r.shipNm}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center font-mono">{r.callSign || '—'}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">
|
|
|
|
|
<span className="text-[10px]">{r.shipTp}</span>
|
|
|
|
|
{r.shipTpDetail && <span className="text-text-3 text-[9px] ml-1">({r.shipTpDetail})</span>}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2 max-w-[150px] truncate">{r.ownerNm}</td>
|
|
|
|
|
<td className="px-3 py-2 text-right font-mono">{r.grossTon ? Number(r.grossTon).toLocaleString() : '—'}</td>
|
|
|
|
|
<td className="px-3 py-2 max-w-[180px] truncate">{r.insurerNm}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">{ynBadge(r.liabilityYn)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">{ynBadge(r.oilPollutionYn)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">{ynBadge(r.fuelOilYn)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center font-mono text-[10px]" style={{ color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>
|
|
|
|
|
{r.validStart} ~ {r.validEnd}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2 text-center text-[10px]">{r.issueOrg}</td>
|
|
|
|
|
<td className="px-3 py-2 text-center">
|
|
|
|
|
<span className="px-2 py-0.5 rounded-full text-[9px] 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)',
|
|
|
|
|
}}>
|
|
|
|
|
@ -294,30 +228,50 @@ function ShipInsurance() {
|
|
|
|
|
</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(', ')}</>
|
|
|
|
|
)}
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
<div className="flex items-center justify-center gap-1.5 mb-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => loadData(page - 1)} disabled={page <= 1}
|
|
|
|
|
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
|
|
|
|
>
|
|
|
|
|
이전
|
|
|
|
|
</button>
|
|
|
|
|
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
|
|
|
|
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9))
|
|
|
|
|
const p = startPage + i
|
|
|
|
|
if (p > totalPages) return null
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={p} onClick={() => loadData(p)}
|
|
|
|
|
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
|
|
|
|
|
style={{
|
|
|
|
|
background: p === page ? 'var(--cyan)' : 'var(--bg-0)',
|
|
|
|
|
color: p === page ? '#fff' : 'var(--text-2)',
|
|
|
|
|
borderColor: p === page ? 'var(--cyan)' : 'var(--bd)',
|
|
|
|
|
fontWeight: p === page ? 700 : 400,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{p}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => loadData(page + 1)} disabled={page >= totalPages}
|
|
|
|
|
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
|
|
|
|
>
|
|
|
|
|
다음
|
|
|
|
|
</button>
|
|
|
|
|
</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="mt-auto px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
|
|
|
|
<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>
|
|
|
|
|
<span className="text-text-2 font-bold">데이터 출처:</span> 해양수산부 해운항만물류정보 · 유류오염보장계약관리 공공데이터<br />
|
|
|
|
|
<span className="text-text-2 font-bold">보장항목:</span> 책임보험, 유류오염, 연료유오염, 난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|