feat(assets): 유류오염보장계약 DB 임포트 + 선박보험 조회 연동

해양수산부 공공데이터(유류오염보장계약관리) 1,391건을 SHIP_INSURANCE 테이블에 임포트하고,
백엔드 API 및 프론트엔드 ShipInsurance 컴포넌트를 실제 DB 데이터 기반으로 전환.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-05 14:26:56 +09:00
부모 c3d3b82b60
커밋 91d0832963
5개의 변경된 파일415개의 추가작업 그리고 242개의 파일을 삭제

파일 보기

@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listOrganizations, getOrganization, listUploadLogs } from './assetsService.js';
import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js';
const router = Router();
@ -44,6 +44,34 @@ router.get('/orgs/:sn', requireAuth, async (req, res) => {
}
});
// ============================================================
// GET /api/assets/insurance — 선박보험(유류오염보장계약) 목록
// ============================================================
router.get('/insurance', requireAuth, async (req, res) => {
try {
const { search, shipTp, issueOrg, insurer, page, limit } = req.query as {
search?: string;
shipTp?: string;
issueOrg?: string;
insurer?: string;
page?: string;
limit?: string;
};
const data = await listInsurance({
search,
shipTp,
issueOrg,
insurer,
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
res.json(data);
} catch (err) {
console.error('[assets] 선박보험 조회 오류:', err);
res.status(500).json({ error: '선박보험 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/assets/upload-logs — 업로드 이력
// ============================================================

파일 보기

@ -162,6 +162,106 @@ export async function getOrganization(orgSn: number): Promise<OrgDetail | null>
};
}
// ============================================================
// 선박보험(유류오염보장계약) 조회
// ============================================================
interface InsuranceListItem {
insSn: number;
shipNo: string;
shipNm: string;
callSign: string;
imoNo: string;
shipTp: string;
shipTpDetail: string;
ownerNm: string;
grossTon: string;
insurerNm: string;
liabilityYn: string;
oilPollutionYn: string;
fuelOilYn: string;
wreckRemovalYn: string;
validStart: string;
validEnd: string;
issueOrg: string;
}
export async function listInsurance(filters: {
search?: string;
shipTp?: string;
issueOrg?: string;
insurer?: string;
page?: number;
limit?: number;
}): Promise<{ rows: InsuranceListItem[]; total: number }> {
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (filters.search) {
conditions.push(`(ship_nm LIKE '%' || $${idx} || '%' OR call_sign LIKE '%' || $${idx} || '%' OR imo_no LIKE '%' || $${idx} || '%' OR owner_nm LIKE '%' || $${idx} || '%')`);
params.push(filters.search);
idx++;
}
if (filters.shipTp) {
conditions.push(`ship_tp = $${idx++}`);
params.push(filters.shipTp);
}
if (filters.issueOrg) {
conditions.push(`issue_org LIKE '%' || $${idx++} || '%'`);
params.push(filters.issueOrg);
}
if (filters.insurer) {
conditions.push(`insurer_nm LIKE '%' || $${idx++} || '%'`);
params.push(filters.insurer);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const limit = Math.min(filters.limit || 50, 200);
const offset = ((filters.page || 1) - 1) * limit;
const countSql = `SELECT COUNT(*) AS cnt FROM wing.SHIP_INSURANCE ${where}`;
const { rows: countRows } = await wingPool.query(countSql, params);
const total = parseInt(countRows[0].cnt as string, 10);
const sql = `
SELECT ins_sn, ship_no, ship_nm, call_sign, imo_no, ship_tp, ship_tp_detail,
owner_nm, gross_ton, insurer_nm,
liability_yn, oil_pollution_yn, fuel_oil_yn, wreck_removal_yn,
valid_start, valid_end, issue_org
FROM wing.SHIP_INSURANCE
${where}
ORDER BY valid_end DESC, ins_sn
LIMIT $${idx++} OFFSET $${idx++}
`;
params.push(limit, offset);
const { rows } = await wingPool.query(sql, params);
return {
total,
rows: rows.map((r: Record<string, unknown>) => ({
insSn: r.ins_sn as number,
shipNo: r.ship_no as string,
shipNm: r.ship_nm as string,
callSign: r.call_sign as string,
imoNo: r.imo_no as string,
shipTp: r.ship_tp as string,
shipTpDetail: r.ship_tp_detail as string,
ownerNm: r.owner_nm as string,
grossTon: r.gross_ton as string,
insurerNm: r.insurer_nm as string,
liabilityYn: r.liability_yn as string,
oilPollutionYn: r.oil_pollution_yn as string,
fuelOilYn: r.fuel_oil_yn as string,
wreckRemovalYn: r.wreck_removal_yn as string,
validStart: (r.valid_start as Date)?.toISOString().slice(0, 10) ?? '',
validEnd: (r.valid_end as Date)?.toISOString().slice(0, 10) ?? '',
issueOrg: r.issue_org as string,
})),
};
}
// ============================================================
// 업로드 이력 조회
// ============================================================

파일 보기

@ -0,0 +1,42 @@
-- 019_ship_insurance.sql
-- 유류오염보장계약 테이블 (해양수산부 공공데이터)
CREATE TABLE IF NOT EXISTS SHIP_INSURANCE (
ins_sn SERIAL PRIMARY KEY,
ship_no VARCHAR(30),
nation_tp VARCHAR(20),
ship_tp VARCHAR(30),
ship_tp_detail VARCHAR(50),
hns_yn VARCHAR(2),
call_sign VARCHAR(20),
imo_no VARCHAR(20),
oper_tp VARCHAR(20),
ship_nm VARCHAR(200),
owner_nm VARCHAR(200),
gross_ton VARCHAR(30),
intl_gross_ton VARCHAR(30),
deadweight_ton VARCHAR(30),
insurer_nm VARCHAR(200),
liability_yn VARCHAR(2),
oil_pollution_yn VARCHAR(2),
fuel_oil_yn VARCHAR(2),
wreck_removal_yn VARCHAR(2),
crew_damage_yn VARCHAR(2),
pax_damage_yn VARCHAR(2),
hull_damage_yn VARCHAR(2),
dock_damage_yn VARCHAR(2),
valid_start DATE,
valid_end DATE,
issue_country VARCHAR(50),
issue_org VARCHAR(100),
reg_dtm TIMESTAMP,
mod_dtm TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_ship_ins_ship_nm ON SHIP_INSURANCE(ship_nm);
CREATE INDEX idx_ship_ins_imo ON SHIP_INSURANCE(imo_no);
CREATE INDEX idx_ship_ins_call_sign ON SHIP_INSURANCE(call_sign);
CREATE INDEX idx_ship_ins_insurer ON SHIP_INSURANCE(insurer_nm);
CREATE INDEX idx_ship_ins_issue_org ON SHIP_INSURANCE(issue_org);
CREATE INDEX idx_ship_ins_ship_tp ON SHIP_INSURANCE(ship_tp);

파일 보기

@ -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 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])
const getStatus = (expiry: string) => {
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>
))}
{/* 에러 */}
{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>

파일 보기

@ -113,3 +113,52 @@ export async function fetchUploadLogs(limit = 20): Promise<UploadLogItem[]> {
const { data } = await api.get<UploadLogItem[]>(`/assets/upload-logs?limit=${limit}`);
return data;
}
// ============================================================
// 선박보험(유류오염보장계약)
// ============================================================
export interface ShipInsuranceItem {
insSn: number;
shipNo: string;
shipNm: string;
callSign: string;
imoNo: string;
shipTp: string;
shipTpDetail: string;
ownerNm: string;
grossTon: string;
insurerNm: string;
liabilityYn: string;
oilPollutionYn: string;
fuelOilYn: string;
wreckRemovalYn: string;
validStart: string;
validEnd: string;
issueOrg: string;
}
interface InsuranceResponse {
rows: ShipInsuranceItem[];
total: number;
}
export async function fetchInsurance(filters?: {
search?: string;
shipTp?: string;
issueOrg?: string;
insurer?: string;
page?: number;
limit?: number;
}): Promise<InsuranceResponse> {
const params = new URLSearchParams();
if (filters?.search) params.set('search', filters.search);
if (filters?.shipTp) params.set('shipTp', filters.shipTp);
if (filters?.issueOrg) params.set('issueOrg', filters.issueOrg);
if (filters?.insurer) params.set('insurer', filters.insurer);
if (filters?.page) params.set('page', String(filters.page));
if (filters?.limit) params.set('limit', String(filters.limit));
const qs = params.toString();
const { data } = await api.get<InsuranceResponse>(`/assets/insurance${qs ? '?' + qs : ''}`);
return data;
}