snp-batch-validation/frontend/src/components/screening/HistoryTab.tsx
HYOJIN e3465401a2 feat(screening): Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:52:00 +09:00

912 lines
52 KiB
TypeScript

import { useState, useMemo } from 'react';
import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi';
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
interface HistoryTabProps {
lang: string;
}
const HISTORY_TYPES: {
key: HistoryType;
label: string;
searchLabel: string;
searchPlaceholder: string;
overallColumn: string | null;
}[] = [
{
key: 'ship-risk',
label: '선박 위험지표',
searchLabel: 'IMO 번호',
searchPlaceholder: 'IMO 번호 : 9672533',
overallColumn: null,
},
{
key: 'ship-compliance',
label: '선박 제재',
searchLabel: 'IMO 번호',
searchPlaceholder: 'IMO 번호 : 9672533',
overallColumn: 'lgl_snths_sanction',
},
{
key: 'company-compliance',
label: '회사 제재',
searchLabel: '회사 코드',
searchPlaceholder: '회사 코드 : 1288896',
overallColumn: 'company_snths_compliance_status',
},
];
interface GroupedHistory {
lastModifiedDate: string;
items: ChangeHistoryResponse[];
overallBefore: string | null;
overallAfter: string | null;
}
const STATUS_MAP: Record<string, { label: string; className: string }> = {
'0': { label: 'All Clear', className: 'bg-green-100 text-green-800 border-green-300' },
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
'2': { label: 'Severe', className: 'bg-red-100 text-red-800 border-red-300' },
};
const STATUS_COLORS: Record<string, string> = {
'0': '#22c55e',
'1': '#eab308',
'2': '#ef4444',
};
const STATUS_LABELS: Record<string, string> = {
'0': 'All Clear',
'1': 'Warning',
'2': 'Severe',
};
function StatusBadge({ value }: { value: string | null }) {
if (value == null || value === '') return null;
const status = STATUS_MAP[value];
if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
return (
<span className={`inline-block rounded-full px-3 py-0.5 text-xs font-bold border ${status.className}`}>
{status.label}
</span>
);
}
function countryFlag(code: string | null | undefined): string {
if (!code || code.length < 2) return '';
const cc = code.slice(0, 2).toUpperCase();
const codePoints = [...cc].map((c) => 0x1F1E6 + c.charCodeAt(0) - 65);
return String.fromCodePoint(...codePoints);
}
function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) {
if (value == null || value === '') return null;
const color = STATUS_COLORS[value] ?? '#6b7280';
const label = narrative || STATUS_LABELS[value] || value;
return (
<div className="inline-flex items-start gap-1.5">
<span style={{ color }} className="text-sm leading-tight"></span>
<span className="text-xs text-wing-text leading-relaxed text-left">{label}</span>
</div>
);
}
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 탭 분류 (categoryCode 기반)
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') },
{ key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
{ key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' },
];
// 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.categoryCode));
}
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('');
const [cache, setCache] = useState<Record<string, ChangeHistoryResponse[]>>({});
const [loading, setLoading] = useState(false);
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[]>();
for (const item of data) {
const key = item.lastModifiedDate;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return Array.from(map.entries()).map(([date, items]) => {
const overallColumn = currentType.overallColumn;
if (overallColumn) {
const overallItem = items.find((item) => item.changedColumnName === overallColumn);
return {
lastModifiedDate: date,
items,
overallBefore: overallItem?.beforeValue ?? null,
overallAfter: overallItem?.afterValue ?? null,
};
}
return { lastModifiedDate: date, items, overallBefore: null, overallAfter: null };
});
}, [data, currentType.overallColumn]);
function handleSearch() {
const trimmed = searchValue.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
setSearched(true);
setExpandedDates(new Set());
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
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);
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));
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleSearch();
}
function handleTypeChange(type: HistoryType) {
setHistoryType(type);
setSearchValue('');
setCache({});
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) {
setExpandedDates((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
}
return (
<div className="space-y-4">
{/* 이력 유형 선택 (언더라인 탭) */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{HISTORY_TYPES.map((t) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
historyType === t.key
? 'text-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* 검색 */}
<div className="flex gap-3 items-center justify-center">
<div className="relative flex-1 min-w-[200px]">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</span>
<input
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={currentType.searchPlaceholder}
className="w-full pl-10 pr-8 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
/>
{searchValue && (
<button
onClick={() => setSearchValue('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button
onClick={handleSearch}
disabled={!searchValue.trim() || loading}
className="px-5 py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '조회 중...' : '조회'}
</button>
</div>
{/* 에러 */}
{error && (
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm">
<strong> :</strong> {error}
</div>
)}
{/* 결과: 3개 섹션 */}
{searched && !loading && !error && (
<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' : ''}`}>&#9654;</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="flex gap-6">
{/* 좌측: 핵심 식별 정보 */}
<div className="min-w-[220px] space-y-2">
<div>
<div className="text-lg font-bold text-wing-text">{shipInfo.shipName || '-'}</div>
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<span className="text-wing-muted w-12">IMO</span>
<span className="font-mono font-medium text-wing-text">{shipInfo.imoNo}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-12">MMSI</span>
<span className="font-mono font-medium text-wing-text">{shipInfo.mmsiNo || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-12">Status</span>
<span className="font-medium text-wing-text">{shipInfo.shipStatus || '-'}</span>
</div>
</div>
</div>
{/* 구분선 */}
<div className="w-px bg-wing-border" />
{/* 우측: 스펙 정보 */}
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">
{countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">{shipInfo.shipType || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16">DWT</span>
<span className="font-medium text-wing-text">{shipInfo.dwt || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16">GT</span>
<span className="font-medium text-wing-text">{shipInfo.gt || '-'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">{shipInfo.buildYear || '-'}</span>
</div>
</div>
</div>
)}
{companyInfo && (
<div className="flex gap-6">
{/* 좌측: 핵심 식별 정보 */}
<div className="min-w-[220px] space-y-2">
<div>
<div className="text-lg font-bold text-wing-text">{companyInfo.fullName || '-'}</div>
{companyInfo.abbreviation && (
<div className="text-xs text-wing-muted">{companyInfo.abbreviation}</div>
)}
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16">Code</span>
<span className="font-mono font-medium text-wing-text">{companyInfo.companyCode}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16">Status</span>
<span className="font-medium text-wing-text">{companyInfo.status || '-'}</span>
</div>
{companyInfo.parentCompanyName && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">{companyInfo.parentCompanyName}</span>
</div>
)}
</div>
</div>
{/* 구분선 */}
<div className="w-px bg-wing-border" />
{/* 우측: 상세 정보 */}
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">
{countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'}
</span>
</div>
{companyInfo.controlCountry && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">
{countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry}
</span>
</div>
)}
{companyInfo.foundedDate && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">{companyInfo.foundedDate}</span>
</div>
)}
{companyInfo.email && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text truncate">{companyInfo.email}</span>
</div>
)}
{companyInfo.phone && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="font-medium text-wing-text">{companyInfo.phone}</span>
</div>
)}
{companyInfo.website && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<a
href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:underline truncate"
>
{companyInfo.website}
</a>
</div>
)}
</div>
</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' : ''}`}>&#9654;</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' : ''}`}>&#9654;</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' : ''}`}>&#9654;</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' : ''}`}
>
&#9654;
</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>
)}
{/* 초기 상태 */}
{!searched && (
<div className="flex items-center justify-center py-16 text-wing-muted">
<div className="text-center">
<div className="text-3xl mb-3">🔍</div>
<div className="text-sm">{currentType.searchLabel}() .</div>
</div>
</div>
)}
</div>
);
}