release: 2026-03-31 (40건 커밋) #118
@ -67,6 +67,43 @@ export interface ChangeHistoryResponse {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// 선박 기본 정보
|
||||
export interface ShipInfoResponse {
|
||||
imoNo: string;
|
||||
shipName: string;
|
||||
shipStatus: string;
|
||||
nationality: string;
|
||||
shipType: string;
|
||||
dwt: string;
|
||||
gt: string;
|
||||
buildYear: string;
|
||||
mmsiNo: string;
|
||||
callSign: string;
|
||||
shipTypeGroup: string;
|
||||
}
|
||||
|
||||
// 회사 기본 정보
|
||||
export interface CompanyInfoResponse {
|
||||
companyCode: string;
|
||||
fullName: string;
|
||||
abbreviation: string;
|
||||
country: string;
|
||||
city: string;
|
||||
status: string;
|
||||
registrationCountry: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
// 지표 현재 상태
|
||||
export interface IndicatorStatusResponse {
|
||||
columnName: string;
|
||||
fieldName: string;
|
||||
category: string;
|
||||
value: string | null;
|
||||
narrative: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const BASE = '/snp-api/api/screening-guide';
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
@ -88,4 +125,14 @@ export const screeningGuideApi = {
|
||||
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`),
|
||||
getCompanyComplianceHistory: (companyCode: string, lang = 'KO') =>
|
||||
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`),
|
||||
getShipInfo: (imoNo: string) =>
|
||||
fetchJson<ApiResponse<ShipInfoResponse>>(`${BASE}/ship-info?imoNo=${imoNo}`),
|
||||
getShipRiskStatus: (imoNo: string, lang = 'KO') =>
|
||||
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`),
|
||||
getShipComplianceStatus: (imoNo: string, lang = 'KO') =>
|
||||
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`),
|
||||
getCompanyInfo: (companyCode: string) =>
|
||||
fetchJson<ApiResponse<CompanyInfoResponse>>(`${BASE}/company-info?companyCode=${companyCode}`),
|
||||
getCompanyComplianceStatus: (companyCode: string, lang = 'KO') =>
|
||||
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`),
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { screeningGuideApi, type ChangeHistoryResponse } from '../../api/screeningGuideApi';
|
||||
import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi';
|
||||
|
||||
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
|
||||
|
||||
@ -73,6 +73,15 @@ function StatusBadge({ value }: { value: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-wing-muted mb-0.5">{label}</div>
|
||||
<div className="font-medium text-wing-text">{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) {
|
||||
if (value == null || value === '') return null;
|
||||
const color = STATUS_COLORS[value] ?? '#6b7280';
|
||||
@ -85,6 +94,228 @@ function RiskValueCell({ value, narrative }: { value: string | null; narrative?:
|
||||
);
|
||||
}
|
||||
|
||||
const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> = {
|
||||
'0': { label: 'No', className: 'bg-green-100 text-green-800' },
|
||||
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800' },
|
||||
'2': { label: 'Yes', className: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
function getRiskLabel(item: IndicatorStatusResponse): string {
|
||||
// IUU Fishing: All Clear -> None recorded
|
||||
if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded';
|
||||
// Risk Data Maintained: 0 -> Yes, 1 -> Not Maintained
|
||||
if (item.columnName === 'risk_data_maint') {
|
||||
if (item.value === '0') return 'Yes';
|
||||
if (item.value === '1') return 'Not Maintained';
|
||||
}
|
||||
return item.narrative || STATUS_LABELS[item.value ?? ''] || item.value || '-';
|
||||
}
|
||||
|
||||
function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
|
||||
// Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시
|
||||
const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint');
|
||||
const isNotMaintained = riskDataMaintained?.value === '1';
|
||||
|
||||
const displayItems = isNotMaintained
|
||||
? items.filter((i) => i.columnName === 'risk_data_maint')
|
||||
: items;
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||
for (const item of displayItems) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(item);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [displayItems]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isNotMaintained && (
|
||||
<div className="mb-3 px-3 py-2 bg-yellow-50 border border-yellow-300 rounded-lg text-xs text-yellow-800 font-medium">
|
||||
Risk Data is not maintained for this vessel. Only the maintenance status is shown.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{categories.map(([category, catItems]) => (
|
||||
<div key={category}>
|
||||
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||
{category}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{catItems.map((item) => {
|
||||
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
|
||||
const label = getRiskLabel(item);
|
||||
return (
|
||||
<div key={item.columnName} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||
<span style={{ color }} className="shrink-0 text-sm">●</span>
|
||||
<span
|
||||
className="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-wing-text text-center bg-wing-surface border border-wing-border"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선박 Compliance 탭 분류
|
||||
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [
|
||||
{ key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') },
|
||||
{ key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' },
|
||||
{ key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' },
|
||||
{ key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' },
|
||||
];
|
||||
|
||||
// Compliance 예외 처리
|
||||
function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean): string | null {
|
||||
// Parent Company 관련: null -> No Parent
|
||||
if (item.value == null || item.value === '') {
|
||||
if (item.fieldName.includes('Parent Company') || item.fieldName.includes('Parent company')) return 'No Parent';
|
||||
if (isCompany && item.columnName === 'prnt_company_compliance_risk') return 'No Parent';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 제외할 컬럼명
|
||||
const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시
|
||||
const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시
|
||||
|
||||
function ComplianceStatusItem({ item, isCompany }: { item: IndicatorStatusResponse; isCompany: boolean }) {
|
||||
const overrideLabel = getComplianceLabel(item, isCompany);
|
||||
const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? ''];
|
||||
const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-'));
|
||||
const displayClass = overrideLabel
|
||||
? 'bg-wing-surface text-wing-muted border border-wing-border'
|
||||
: status ? status.className : 'bg-wing-surface text-wing-muted border border-wing-border';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-center ${displayClass}`}
|
||||
style={{ minWidth: '80px' }}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResponse[]; isCompany: boolean }) {
|
||||
const [activeTab, setActiveTab] = useState('sanctions');
|
||||
|
||||
// 제외 항목 필터링
|
||||
const excludeList = isCompany ? COMPANY_COMPLIANCE_EXCLUDE : SHIP_COMPLIANCE_EXCLUDE;
|
||||
const filteredItems = items.filter((i) => !excludeList.includes(i.columnName));
|
||||
|
||||
// 회사: 탭 없이 2컬럼 그리드
|
||||
if (isCompany) {
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||
for (const item of filteredItems) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(item);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [filteredItems]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{categories.map(([category, catItems]) => (
|
||||
<div key={category}>
|
||||
{categories.length > 1 && (
|
||||
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||
{category}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{catItems.map((item) => (
|
||||
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선박: 탭 기반 분류
|
||||
const tabData = useMemo(() => {
|
||||
const result: Record<string, IndicatorStatusResponse[]> = {};
|
||||
for (const tab of SHIP_COMPLIANCE_TABS) {
|
||||
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
|
||||
}
|
||||
return result;
|
||||
}, [filteredItems]);
|
||||
|
||||
const currentItems = tabData[activeTab] ?? [];
|
||||
|
||||
// 현재 탭 내 카테고리별 그룹핑
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||
for (const item of currentItems) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(item);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [currentItems]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 탭 버튼 */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{SHIP_COMPLIANCE_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${
|
||||
activeTab === tab.key
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tabData[tab.key]?.length ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 현재 탭 내용 */}
|
||||
{currentItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{categories.map(([category, catItems]) => (
|
||||
<div key={category}>
|
||||
{categories.length > 1 && (
|
||||
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||
{category}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{catItems.map((item) => (
|
||||
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-wing-muted py-4">해당 항목이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||
const [historyType, setHistoryType] = useState<HistoryType>('ship-risk');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
@ -93,10 +324,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||
const [shipInfo, setShipInfo] = useState<ShipInfoResponse | null>(null);
|
||||
const [companyInfo, setCompanyInfo] = useState<CompanyInfoResponse | null>(null);
|
||||
const [riskStatusCache, setRiskStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||
const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['info', 'risk', 'compliance', 'history']));
|
||||
|
||||
const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!;
|
||||
const isRisk = historyType === 'ship-risk';
|
||||
const data = cache[lang] ?? [];
|
||||
const riskStatus = riskStatusCache[lang] ?? [];
|
||||
const complianceStatus = complianceStatusCache[lang] ?? [];
|
||||
|
||||
const grouped: GroupedHistory[] = useMemo(() => {
|
||||
const map = new Map<string, ChangeHistoryResponse[]>();
|
||||
@ -127,16 +365,62 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||
setError(null);
|
||||
setSearched(true);
|
||||
setExpandedDates(new Set());
|
||||
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||
|
||||
const getApiCall = (l: string) =>
|
||||
const isShip = historyType !== 'company-compliance';
|
||||
const showRisk = historyType === 'ship-risk';
|
||||
|
||||
const getHistoryCall = (l: string) =>
|
||||
historyType === 'ship-risk'
|
||||
? screeningGuideApi.getShipRiskHistory(trimmed, l)
|
||||
: historyType === 'ship-compliance'
|
||||
? screeningGuideApi.getShipComplianceHistory(trimmed, l)
|
||||
: screeningGuideApi.getCompanyComplianceHistory(trimmed, l);
|
||||
|
||||
Promise.all([getApiCall('KO'), getApiCall('EN')])
|
||||
.then(([ko, en]) => setCache({ KO: ko.data ?? [], EN: en.data ?? [] }))
|
||||
const promises: Promise<any>[] = [
|
||||
getHistoryCall('KO'),
|
||||
getHistoryCall('EN'),
|
||||
];
|
||||
|
||||
if (isShip) {
|
||||
promises.push(
|
||||
screeningGuideApi.getShipInfo(trimmed),
|
||||
screeningGuideApi.getShipComplianceStatus(trimmed, 'KO'),
|
||||
screeningGuideApi.getShipComplianceStatus(trimmed, 'EN'),
|
||||
);
|
||||
if (showRisk) {
|
||||
promises.push(
|
||||
screeningGuideApi.getShipRiskStatus(trimmed, 'KO'),
|
||||
screeningGuideApi.getShipRiskStatus(trimmed, 'EN'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
promises.push(
|
||||
screeningGuideApi.getCompanyInfo(trimmed),
|
||||
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'KO'),
|
||||
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'EN'),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.all(promises)
|
||||
.then((results) => {
|
||||
setCache({ KO: results[0].data ?? [], EN: results[1].data ?? [] });
|
||||
if (isShip) {
|
||||
setShipInfo(results[2].data);
|
||||
setCompanyInfo(null);
|
||||
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||
if (showRisk) {
|
||||
setRiskStatusCache({ KO: results[5].data ?? [], EN: results[6].data ?? [] });
|
||||
} else {
|
||||
setRiskStatusCache({});
|
||||
}
|
||||
} else {
|
||||
setShipInfo(null);
|
||||
setCompanyInfo(results[2].data);
|
||||
setRiskStatusCache({});
|
||||
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
@ -152,6 +436,20 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||
setError(null);
|
||||
setSearched(false);
|
||||
setExpandedDates(new Set());
|
||||
setShipInfo(null);
|
||||
setCompanyInfo(null);
|
||||
setRiskStatusCache({});
|
||||
setComplianceStatusCache({});
|
||||
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||
}
|
||||
|
||||
function toggleSection(section: string) {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDate(date: string) {
|
||||
@ -231,160 +529,263 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과: 날짜별 토글 */}
|
||||
{/* 결과: 3개 섹션 */}
|
||||
{searched && !loading && !error && (
|
||||
<>
|
||||
<div className="text-xs text-wing-muted">
|
||||
조회 결과: <strong className="text-wing-text">{grouped.length}</strong>개 일시,{' '}
|
||||
<strong className="text-wing-text">{data.length}</strong>건 변동
|
||||
</div>
|
||||
{grouped.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = expandedDates.has(group.lastModifiedDate);
|
||||
const hasOverall =
|
||||
group.overallBefore != null || group.overallAfter != null;
|
||||
const displayItems = (currentType.overallColumn
|
||||
? group.items.filter(
|
||||
(item) => item.changedColumnName !== currentType.overallColumn,
|
||||
)
|
||||
: [...group.items]
|
||||
).sort((a, b) => (a.sortOrder ?? Infinity) - (b.sortOrder ?? Infinity));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.lastModifiedDate}
|
||||
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleDate(group.lastModifiedDate)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span
|
||||
className={`text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span className="text-sm text-wing-muted">Change Date :</span>
|
||||
<span className="text-sm font-semibold text-wing-text">
|
||||
{group.lastModifiedDate}
|
||||
</span>
|
||||
{hasOverall ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge value={group.overallBefore} />
|
||||
<span className="text-xs text-wing-muted">→</span>
|
||||
<StatusBadge value={group.overallAfter} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-wing-muted">
|
||||
{displayItems.length}건 변동
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasOverall && (
|
||||
<span className="text-xs text-wing-muted">
|
||||
{displayItems.length}건 변동
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && displayItems.length > 0 && (
|
||||
<div className="border-t border-wing-border">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900 dark:bg-slate-700 text-white">
|
||||
<th
|
||||
style={{ width: '50%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
필드명
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
이전값
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
이후값
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((row) => (
|
||||
<tr
|
||||
key={row.rowIndex}
|
||||
className="border-b border-wing-border even:bg-wing-card"
|
||||
>
|
||||
<td
|
||||
style={{ width: '50%' }}
|
||||
className="px-4 py-2.5 text-wing-text"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{row.fieldName || row.changedColumnName}
|
||||
</div>
|
||||
{row.fieldName && (
|
||||
<div className="text-wing-muted mt-0.5">
|
||||
{row.changedColumnName}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{isRisk ? (
|
||||
<>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} />
|
||||
</td>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<RiskValueCell
|
||||
value={row.afterValue}
|
||||
narrative={row.narrative ?? undefined}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<StatusBadge value={row.beforeValue} />
|
||||
</td>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<StatusBadge value={row.afterValue} />
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-wing-muted">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📭</div>
|
||||
<div className="text-sm">조회 결과가 없습니다.</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Section 1: 기본 정보 */}
|
||||
{(shipInfo || companyInfo) && (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('info')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<span className={`text-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="text-sm font-semibold text-wing-text">
|
||||
{shipInfo ? '선박 기본 정보' : '회사 기본 정보'}
|
||||
</span>
|
||||
</button>
|
||||
{expandedSections.has('info') && (
|
||||
<div className="border-t border-wing-border px-4 py-4">
|
||||
{shipInfo && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<InfoField label="선박명" value={shipInfo.shipName} />
|
||||
<InfoField label="IMO" value={shipInfo.imoNo} />
|
||||
<InfoField label="MMSI" value={shipInfo.mmsiNo} />
|
||||
<InfoField label="호출부호" value={shipInfo.callSign} />
|
||||
<InfoField label="국적" value={shipInfo.nationality} />
|
||||
<InfoField label="선종" value={shipInfo.shipType} />
|
||||
<InfoField label="DWT" value={shipInfo.dwt} />
|
||||
<InfoField label="GT" value={shipInfo.gt} />
|
||||
<InfoField label="건조연도" value={shipInfo.buildYear} />
|
||||
<InfoField label="상태" value={shipInfo.shipStatus} />
|
||||
</div>
|
||||
)}
|
||||
{companyInfo && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<InfoField label="회사명" value={companyInfo.fullName} />
|
||||
<InfoField label="회사코드" value={companyInfo.companyCode} />
|
||||
<InfoField label="약칭" value={companyInfo.abbreviation} />
|
||||
<InfoField label="국가" value={companyInfo.country} />
|
||||
<InfoField label="도시" value={companyInfo.city} />
|
||||
<InfoField label="등록국가" value={companyInfo.registrationCountry} />
|
||||
<InfoField label="상태" value={companyInfo.status} />
|
||||
<InfoField label="주소" value={companyInfo.address} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Section 2: Current Risk Indicators (선박 탭만) */}
|
||||
{riskStatus.length > 0 && (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('risk')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<span className={`text-xs transition-transform ${expandedSections.has('risk') ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="text-sm font-semibold text-wing-text">Current Risk Indicators</span>
|
||||
</button>
|
||||
{expandedSections.has('risk') && (
|
||||
<div className="border-t border-wing-border px-4 py-4">
|
||||
<RiskStatusGrid items={riskStatus} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Current Compliance (선박 제재/회사 제재 탭만) */}
|
||||
{complianceStatus.length > 0 && !isRisk && (() => {
|
||||
const isCompany = historyType === 'company-compliance';
|
||||
const overallItem = isCompany
|
||||
? complianceStatus.find((i) => i.columnName === 'company_snths_compliance_status')
|
||||
: null;
|
||||
return (
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('compliance')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<span className={`text-xs transition-transform ${expandedSections.has('compliance') ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="text-sm font-semibold text-wing-text">Current Compliance</span>
|
||||
{overallItem && (
|
||||
<StatusBadge value={overallItem.value} />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.has('compliance') && (
|
||||
<div className="border-t border-wing-border px-4 py-4">
|
||||
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Section 4: 값 변경 이력 */}
|
||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('history')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<span className={`text-xs transition-transform ${expandedSections.has('history') ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="text-sm font-semibold text-wing-text">값 변경 이력</span>
|
||||
<span className="text-xs text-wing-muted">
|
||||
{grouped.length}개 일시, {data.length}건 변동
|
||||
</span>
|
||||
</button>
|
||||
{expandedSections.has('history') && (
|
||||
<div className="border-t border-wing-border">
|
||||
{grouped.length > 0 ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = expandedDates.has(group.lastModifiedDate);
|
||||
const hasOverall =
|
||||
group.overallBefore != null || group.overallAfter != null;
|
||||
const displayItems = (currentType.overallColumn
|
||||
? group.items.filter(
|
||||
(item) => item.changedColumnName !== currentType.overallColumn,
|
||||
)
|
||||
: [...group.items]
|
||||
).sort((a, b) => (a.sortOrder ?? Infinity) - (b.sortOrder ?? Infinity));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.lastModifiedDate}
|
||||
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleDate(group.lastModifiedDate)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span
|
||||
className={`text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span className="text-sm text-wing-muted">Change Date :</span>
|
||||
<span className="text-sm font-semibold text-wing-text">
|
||||
{group.lastModifiedDate}
|
||||
</span>
|
||||
{hasOverall ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge value={group.overallBefore} />
|
||||
<span className="text-xs text-wing-muted">→</span>
|
||||
<StatusBadge value={group.overallAfter} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-wing-muted">
|
||||
{displayItems.length}건 변동
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasOverall && (
|
||||
<span className="text-xs text-wing-muted">
|
||||
{displayItems.length}건 변동
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && displayItems.length > 0 && (
|
||||
<div className="border-t border-wing-border">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900 dark:bg-slate-700 text-white">
|
||||
<th
|
||||
style={{ width: '50%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
필드명
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
이전값
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
이후값
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((row) => (
|
||||
<tr
|
||||
key={row.rowIndex}
|
||||
className="border-b border-wing-border even:bg-wing-card"
|
||||
>
|
||||
<td
|
||||
style={{ width: '50%' }}
|
||||
className="px-4 py-2.5 text-wing-text"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{row.fieldName || row.changedColumnName}
|
||||
</div>
|
||||
{row.fieldName && (
|
||||
<div className="text-wing-muted mt-0.5">
|
||||
{row.changedColumnName}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{isRisk ? (
|
||||
<>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} />
|
||||
</td>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<RiskValueCell
|
||||
value={row.afterValue}
|
||||
narrative={row.narrative ?? undefined}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<StatusBadge value={row.beforeValue} />
|
||||
</td>
|
||||
<td
|
||||
style={{ width: '25%' }}
|
||||
className="px-4 py-2.5 text-center"
|
||||
>
|
||||
<StatusBadge value={row.afterValue} />
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-wing-muted">
|
||||
<div className="text-center">
|
||||
<div className="text-xl mb-1">📭</div>
|
||||
<div className="text-sm">변경 이력이 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 초기 상태 */}
|
||||
|
||||
@ -2,9 +2,12 @@ package com.snp.batch.global.controller;
|
||||
|
||||
import com.snp.batch.common.web.ApiResponse;
|
||||
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
|
||||
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
|
||||
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
|
||||
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
|
||||
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
|
||||
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
|
||||
import com.snp.batch.global.dto.screening.ShipInfoResponse;
|
||||
import com.snp.batch.service.ScreeningGuideService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@ -86,4 +89,50 @@ public class ScreeningGuideController {
|
||||
@RequestParam(defaultValue = "KO") String lang) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang)));
|
||||
}
|
||||
|
||||
@Operation(summary = "선박 기본 정보", description = "IMO 번호로 선박 기본 정보를 조회합니다.")
|
||||
@GetMapping("/ship-info")
|
||||
public ResponseEntity<ApiResponse<ShipInfoResponse>> getShipInfo(
|
||||
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||
@RequestParam String imoNo) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipInfo(imoNo)));
|
||||
}
|
||||
|
||||
@Operation(summary = "선박 현재 Risk 지표 상태", description = "IMO 번호로 선박의 현재 Risk 지표 상태를 조회합니다.")
|
||||
@GetMapping("/ship-risk-status")
|
||||
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipRiskStatus(
|
||||
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||
@RequestParam String imoNo,
|
||||
@Parameter(description = "언어 코드", example = "KO")
|
||||
@RequestParam(defaultValue = "KO") String lang) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskStatus(imoNo, lang)));
|
||||
}
|
||||
|
||||
@Operation(summary = "선박 현재 Compliance 상태", description = "IMO 번호로 선박의 현재 Compliance 상태를 조회합니다.")
|
||||
@GetMapping("/ship-compliance-status")
|
||||
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipComplianceStatus(
|
||||
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||
@RequestParam String imoNo,
|
||||
@Parameter(description = "언어 코드", example = "KO")
|
||||
@RequestParam(defaultValue = "KO") String lang) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceStatus(imoNo, lang)));
|
||||
}
|
||||
|
||||
@Operation(summary = "회사 기본 정보", description = "회사 코드로 회사 기본 정보를 조회합니다.")
|
||||
@GetMapping("/company-info")
|
||||
public ResponseEntity<ApiResponse<CompanyInfoResponse>> getCompanyInfo(
|
||||
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||
@RequestParam String companyCode) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyInfo(companyCode)));
|
||||
}
|
||||
|
||||
@Operation(summary = "회사 현재 Compliance 상태", description = "회사 코드로 회사의 현재 Compliance 상태를 조회합니다.")
|
||||
@GetMapping("/company-compliance-status")
|
||||
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getCompanyComplianceStatus(
|
||||
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||
@RequestParam String companyCode,
|
||||
@Parameter(description = "언어 코드", example = "KO")
|
||||
@RequestParam(defaultValue = "KO") String lang) {
|
||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceStatus(companyCode, lang)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
package com.snp.batch.global.dto.screening;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CompanyInfoResponse {
|
||||
private String companyCode;
|
||||
private String fullName;
|
||||
private String abbreviation;
|
||||
private String country;
|
||||
private String city;
|
||||
private String status;
|
||||
private String registrationCountry;
|
||||
private String address;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.snp.batch.global.dto.screening;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IndicatorStatusResponse {
|
||||
private String columnName;
|
||||
private String fieldName;
|
||||
private String category;
|
||||
private String value;
|
||||
private String narrative;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.snp.batch.global.dto.screening;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ShipInfoResponse {
|
||||
private String imoNo;
|
||||
private String shipName;
|
||||
private String shipStatus;
|
||||
private String nationality;
|
||||
private String shipType;
|
||||
private String dwt;
|
||||
private String gt;
|
||||
private String buildYear;
|
||||
private String mmsiNo;
|
||||
private String callSign;
|
||||
private String shipTypeGroup;
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package com.snp.batch.global.model.screening;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tb_company_dtl_info", schema = "std_snp_svc")
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class CompanyDetailInfo {
|
||||
|
||||
@Id
|
||||
@Column(name = "company_cd", length = 14)
|
||||
private String companyCode;
|
||||
|
||||
@Column(name = "full_nm", length = 280)
|
||||
private String fullName;
|
||||
|
||||
@Column(name = "company_name_abbr", length = 60)
|
||||
private String abbreviation;
|
||||
|
||||
@Column(name = "country_nm", length = 40)
|
||||
private String country;
|
||||
|
||||
@Column(name = "cty_nm", length = 200)
|
||||
private String city;
|
||||
|
||||
@Column(name = "company_status", length = 8)
|
||||
private String status;
|
||||
|
||||
@Column(name = "country_reg", length = 40)
|
||||
private String registrationCountry;
|
||||
|
||||
@Column(name = "oa_addr", length = 512)
|
||||
private String address;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.snp.batch.global.model.screening;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tb_ship_info_mst", schema = "std_snp_svc")
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class ShipInfoMaster {
|
||||
|
||||
@Id
|
||||
@Column(name = "imo_no", length = 10)
|
||||
private String imoNo;
|
||||
|
||||
@Column(name = "ship_nm", length = 75)
|
||||
private String shipName;
|
||||
|
||||
@Column(name = "ship_status", length = 50)
|
||||
private String shipStatus;
|
||||
|
||||
@Column(name = "ship_ntnlty", length = 57)
|
||||
private String nationality;
|
||||
|
||||
@Column(name = "ship_type_lv_five", length = 73)
|
||||
private String shipType;
|
||||
|
||||
@Column(name = "dwt", length = 50)
|
||||
private String dwt;
|
||||
|
||||
@Column(name = "gt", length = 50)
|
||||
private String gt;
|
||||
|
||||
@Column(name = "build_yy", length = 50)
|
||||
private String buildYear;
|
||||
|
||||
@Column(name = "mmsi_no", length = 50)
|
||||
private String mmsiNo;
|
||||
|
||||
@Column(name = "clsgn_no", length = 50)
|
||||
private String callSign;
|
||||
|
||||
@Column(name = "ship_type_lv_two", length = 50)
|
||||
private String shipTypeGroup;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.snp.batch.global.repository.screening;
|
||||
|
||||
import com.snp.batch.global.model.screening.CompanyDetailInfo;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface CompanyDetailInfoRepository extends JpaRepository<CompanyDetailInfo, String> {
|
||||
Optional<CompanyDetailInfo> findByCompanyCode(String companyCode);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.snp.batch.global.repository.screening;
|
||||
|
||||
import com.snp.batch.global.model.screening.ShipInfoMaster;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface ShipInfoMasterRepository extends JpaRepository<ShipInfoMaster, String> {
|
||||
Optional<ShipInfoMaster> findByImoNo(String imoNo);
|
||||
}
|
||||
@ -1,11 +1,14 @@
|
||||
package com.snp.batch.service;
|
||||
|
||||
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
|
||||
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
|
||||
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
|
||||
import com.snp.batch.global.dto.screening.ComplianceIndicatorResponse;
|
||||
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
|
||||
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
|
||||
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
|
||||
import com.snp.batch.global.dto.screening.RiskIndicatorResponse;
|
||||
import com.snp.batch.global.dto.screening.ShipInfoResponse;
|
||||
import com.snp.batch.global.model.screening.ChangeTypeLang;
|
||||
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
|
||||
import com.snp.batch.global.model.screening.ComplianceIndicator;
|
||||
@ -18,6 +21,7 @@ import com.snp.batch.global.model.screening.RiskIndicatorLang;
|
||||
import com.snp.batch.global.model.screening.ShipComplianceHistory;
|
||||
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
|
||||
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
|
||||
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository;
|
||||
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
|
||||
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
|
||||
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
|
||||
@ -26,13 +30,18 @@ import com.snp.batch.global.repository.screening.RiskIndicatorCategoryLangReposi
|
||||
import com.snp.batch.global.repository.screening.RiskIndicatorLangRepository;
|
||||
import com.snp.batch.global.repository.screening.RiskIndicatorRepository;
|
||||
import com.snp.batch.global.repository.screening.ShipComplianceHistoryRepository;
|
||||
import com.snp.batch.global.repository.screening.ShipInfoMasterRepository;
|
||||
import com.snp.batch.global.repository.screening.ShipRiskDetailHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -57,6 +66,9 @@ public class ScreeningGuideService {
|
||||
private final ShipRiskDetailHistoryRepository shipRiskDetailHistoryRepo;
|
||||
private final ShipComplianceHistoryRepository shipComplianceHistoryRepo;
|
||||
private final CompanyComplianceHistoryRepository companyComplianceHistoryRepo;
|
||||
private final ShipInfoMasterRepository shipInfoMasterRepo;
|
||||
private final CompanyDetailInfoRepository companyDetailInfoRepo;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 카테고리별 Risk 지표 목록 조회
|
||||
@ -253,6 +265,149 @@ public class ScreeningGuideService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 선박 기본 정보 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ShipInfoResponse getShipInfo(String imoNo) {
|
||||
return shipInfoMasterRepo.findByImoNo(imoNo)
|
||||
.map(s -> ShipInfoResponse.builder()
|
||||
.imoNo(s.getImoNo())
|
||||
.shipName(s.getShipName())
|
||||
.shipStatus(s.getShipStatus())
|
||||
.nationality(s.getNationality())
|
||||
.shipType(s.getShipType())
|
||||
.dwt(s.getDwt())
|
||||
.gt(s.getGt())
|
||||
.buildYear(s.getBuildYear())
|
||||
.mmsiNo(s.getMmsiNo())
|
||||
.callSign(s.getCallSign())
|
||||
.shipTypeGroup(s.getShipTypeGroup())
|
||||
.build())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 기본 정보 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public CompanyInfoResponse getCompanyInfo(String companyCode) {
|
||||
return companyDetailInfoRepo.findByCompanyCode(companyCode)
|
||||
.map(c -> CompanyInfoResponse.builder()
|
||||
.companyCode(c.getCompanyCode())
|
||||
.fullName(c.getFullName())
|
||||
.abbreviation(c.getAbbreviation())
|
||||
.country(c.getCountry())
|
||||
.city(c.getCity())
|
||||
.status(c.getStatus())
|
||||
.registrationCountry(c.getRegistrationCountry())
|
||||
.address(c.getAddress())
|
||||
.build())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선박 현재 Risk 지표 상태 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<IndicatorStatusResponse> getShipRiskStatus(String imoNo, String lang) {
|
||||
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
|
||||
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
|
||||
Map<String, String> categoryMap = getRiskCategoryMap(lang);
|
||||
|
||||
try {
|
||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM std_snp_svc.tb_ship_risk_detail_info WHERE imo_no = ?", imoNo);
|
||||
|
||||
List<IndicatorStatusResponse> result = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
|
||||
String colName = entry.getKey();
|
||||
Object codeVal = row.get(colName);
|
||||
String descColName = "ais_up_imo_desc".equals(colName) ? "ais_up_imo_desc_val" : colName + "_desc";
|
||||
Object descVal = row.get(descColName);
|
||||
|
||||
result.add(IndicatorStatusResponse.builder()
|
||||
.columnName(colName)
|
||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||
.category(categoryMap.getOrDefault(colName, ""))
|
||||
.value(codeVal != null ? codeVal.toString() : null)
|
||||
.narrative(descVal != null ? descVal.toString() : null)
|
||||
.sortOrder(entry.getValue())
|
||||
.build());
|
||||
}
|
||||
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
|
||||
return result;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선박 현재 Compliance 상태 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
|
||||
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
|
||||
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
|
||||
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP");
|
||||
|
||||
try {
|
||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM std_snp_svc.tb_ship_compliance_info WHERE imo_no = ?", imoNo);
|
||||
|
||||
List<IndicatorStatusResponse> result = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
|
||||
String colName = entry.getKey();
|
||||
Object codeVal = row.get(colName);
|
||||
|
||||
result.add(IndicatorStatusResponse.builder()
|
||||
.columnName(colName)
|
||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||
.category(categoryMap.getOrDefault(colName, ""))
|
||||
.value(codeVal != null ? codeVal.toString() : null)
|
||||
.sortOrder(entry.getValue())
|
||||
.build());
|
||||
}
|
||||
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
|
||||
return result;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 현재 Compliance 상태 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
|
||||
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
|
||||
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
|
||||
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY");
|
||||
|
||||
try {
|
||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM std_snp_svc.tb_company_compliance_info WHERE company_cd = ?", companyCode);
|
||||
|
||||
List<IndicatorStatusResponse> result = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : sortOrderMap.entrySet()) {
|
||||
String colName = entry.getKey();
|
||||
Object codeVal = row.get(colName);
|
||||
|
||||
result.add(IndicatorStatusResponse.builder()
|
||||
.columnName(colName)
|
||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||
.category(categoryMap.getOrDefault(colName, ""))
|
||||
.value(codeVal != null ? codeVal.toString() : null)
|
||||
.sortOrder(entry.getValue())
|
||||
.build());
|
||||
}
|
||||
result.sort(Comparator.comparingInt(IndicatorStatusResponse::getSortOrder));
|
||||
return result;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk 지표 컬럼명 → 필드명 매핑 조회
|
||||
*/
|
||||
@ -267,6 +422,17 @@ public class ScreeningGuideService {
|
||||
(a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<String, String> getRiskCategoryMap(String lang) {
|
||||
Map<String, String> catNameMap = riskCategoryLangRepo.findByLangCode(lang).stream()
|
||||
.collect(Collectors.toMap(RiskIndicatorCategoryLang::getCategoryCode, RiskIndicatorCategoryLang::getCategoryName));
|
||||
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
|
||||
.filter(ri -> ri.getColumnName() != null)
|
||||
.collect(Collectors.toMap(
|
||||
RiskIndicator::getColumnName,
|
||||
ri -> catNameMap.getOrDefault(ri.getCategoryCode(), ri.getCategoryCode()),
|
||||
(a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<String, Integer> getRiskSortOrderMap() {
|
||||
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
|
||||
.filter(ri -> ri.getColumnName() != null)
|
||||
@ -293,6 +459,18 @@ public class ScreeningGuideService {
|
||||
(a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<String, String> getComplianceCategoryMap(String type) {
|
||||
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
||||
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
||||
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
||||
return indicators.stream()
|
||||
.filter(ci -> ci.getColumnName() != null)
|
||||
.collect(Collectors.toMap(
|
||||
ComplianceIndicator::getColumnName,
|
||||
ComplianceIndicator::getCategory,
|
||||
(a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<String, Integer> getComplianceSortOrderMap(String type) {
|
||||
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
||||
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user