diff --git a/backend/src/assets/assetsRouter.ts b/backend/src/assets/assetsRouter.ts index 468f06b..9de8afc 100644 --- a/backend/src/assets/assetsRouter.ts +++ b/backend/src/assets/assetsRouter.ts @@ -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 — 업로드 이력 // ============================================================ diff --git a/backend/src/assets/assetsService.ts b/backend/src/assets/assetsService.ts index 79adebc..8ee4197 100644 --- a/backend/src/assets/assetsService.ts +++ b/backend/src/assets/assetsService.ts @@ -162,6 +162,106 @@ export async function getOrganization(orgSn: number): Promise }; } +// ============================================================ +// 선박보험(유류오염보장계약) 조회 +// ============================================================ + +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) => ({ + 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, + })), + }; +} + // ============================================================ // 업로드 이력 조회 // ============================================================ diff --git a/database/migration/019_ship_insurance.sql b/database/migration/019_ship_insurance.sql new file mode 100644 index 0000000..ff3486a --- /dev/null +++ b/database/migration/019_ship_insurance.sql @@ -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); diff --git a/frontend/src/tabs/assets/components/ShipInsurance.tsx b/frontend/src/tabs/assets/components/ShipInsurance.tsx index 8f787b2..a90fd33 100644 --- a/frontend/src/tabs/assets/components/ShipInsurance.tsx +++ b/frontend/src/tabs/assets/components/ShipInsurance.tsx @@ -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([]) - const [lastSync, setLastSync] = useState('—') + const [rows, setRows] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) - const placeholderMap: Record = { - 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 ( + + {isY ? 'Y' : 'N'} + + ) } - 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 (
- {/* ── 헤더 ── */} + {/* 헤더 */}
-
🛡 선박 보험정보 조회
+
유류오염보장계약 관리
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)'}`, }}> - - {apiConnected ? 'API 연결됨' : 'API 미연결'} + 0 ? 'var(--green)' : 'var(--red)' }} /> + {total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
-
한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회
+
해양수산부 해운항만물류정보 공공데이터 기반
- - + +
- {/* ── API 설정 패널 ── */} - {showConfig && ( -
-
⚙ 한국해운조합 API 연동 설정
-
-
- - 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" /> -
-
- - 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" /> -
-
- - -
-
- - -
-
-
- - -
- {/* API 연동 안내 */} -
- 📋 한국해운조합 API 발급 안내
- • 한국해운조합 공공데이터포털 또는 해운조합 IT지원팀에 API 키 신청
- • 해양경찰청 기관 계정으로 신청 시 전용 엔드포인트 및 키 발급
- • 조회 가능 데이터: P&I 보험, 선주책임보험, 해상보험 가입 여부, 증권번호, 보험기간, 보상한도 -
-
- )} - - {/* ── 검색 영역 ── */} + {/* 필터 */}
-
🔍 보험정보 조회
-
-
- - -
-
- - 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" /> +
+
+ + 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" + />
- - setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border"> + + +
- - +
+ + +
+ +
- {/* ── 결과 영역 ── */} - - {/* 초기 안내 상태 */} - {viewState === 'empty' && ( -
-
🛡
-
한국해운조합 API 연동 대기 중
-
- API 설정에서 한국해운조합 API Key를 등록하거나
- MMSI·IMO·선박명으로 직접 조회하세요.
- 자산목록 일괄조회 시 등록된 방제자산 전체의 보험 현황을 한번에 확인할 수 있습니다. -
-
- - -
-
- )} - {/* 로딩 */} - {viewState === 'loading' && ( + {isLoading && (
-
한국해운조합 API 조회 중...
+
보험 데이터 조회 중...
)} - {/* 결과 테이블 */} - {viewState === 'result' && ( - <> - {/* 요약 카드 */} -
- {[ - { 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) => ( -
-
{c.val}
-
{c.label}
-
- ))} -
+ {/* 에러 */} + {error && !isLoading && ( +
+
조회 실패
+
{error}
+
+ )} - {/* 테이블 */} + {/* 테이블 */} + {!isLoading && !error && ( + <>
-
조회 결과 {resultData.length}
-
- - +
+ 조회 결과 {total.toLocaleString()}건 + {totalPages > 1 && ({page}/{totalPages} 페이지)}
- +
- {[ - { 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) => ( - + {['No', '선박명', '호출부호', 'IMO', '선박종류', '선주', '총톤수', '보험사', '책임', '유류', '연료유', '난파물', '유효기간', '발급기관', '상태'].map((h, i) => ( + ))} - {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 ( - - - - - - - - - - + + + + + + + + + + + + + +
{h.label}{h}
{r.shipName}{r.mmsi || '—'}{r.imo || '—'}{r.insType}{r.insurer}{r.policyNo}{r.start} ~ {r.expiry}{r.limit} - + {rowNum}{r.shipNm}{r.callSign || '—'}{r.imoNo || '—'} + {r.shipTp} + {r.shipTpDetail && ({r.shipTpDetail})} + {r.ownerNm}{r.grossTon ? Number(r.grossTon).toLocaleString() : '—'}{r.insurerNm}{ynBadge(r.liabilityYn)}{ynBadge(r.oilPollutionYn)}{ynBadge(r.fuelOilYn)}{ynBadge(r.wreckRemovalYn)} + {r.validStart} ~ {r.validEnd} + {r.issueOrg} + @@ -294,30 +228,50 @@ function ShipInsurance() { - {/* 경고 */} - {(expiredList.length > 0 || soonList.length > 0) && ( -
- {expiredList.length > 0 && ( - <>⛔ 만료 {expiredList.length}건: {expiredList.map(r => r.shipName).join(', ')}
- )} - {soonList.length > 0 && ( - <>⚠ 만료임박(30일) {soonList.length}건: {soonList.map(r => r.shipName).join(', ')} - )} + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + {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 ( + + ) + })} +
)} )} - {/* ── API 연동 정보 푸터 ── */} -
+ {/* 푸터 */} +
- 데이터 출처: 한국해운조합(KSA) · haewoon.or.kr
- 연동 방식: REST API (JSON) · 실시간 조회 · 캐시 TTL 1시간 -
-
- 마지막 동기화: - {lastSync} - + 데이터 출처: 해양수산부 해운항만물류정보 · 유류오염보장계약관리 공공데이터
+ 보장항목: 책임보험, 유류오염, 연료유오염, 난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
diff --git a/frontend/src/tabs/assets/services/assetsApi.ts b/frontend/src/tabs/assets/services/assetsApi.ts index ddc9be2..ba149e7 100644 --- a/frontend/src/tabs/assets/services/assetsApi.ts +++ b/frontend/src/tabs/assets/services/assetsApi.ts @@ -113,3 +113,52 @@ export async function fetchUploadLogs(limit = 20): Promise { const { data } = await api.get(`/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 { + 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(`/assets/insurance${qs ? '?' + qs : ''}`); + return data; +}