- UI 고정 텍스트 다국어 메타 파일(screeningTexts.ts) 추가 - -999/null 값 'No Data'/'데이터 없음' 표시 처리 - Screening Guide 탭 분리 (Ship/Company Compliance) - Change History ↔ Screening Guide 간 언어 설정 공유 - 섹션 헤더에 Screening Guide 연결 링크 추가
231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
screeningGuideApi,
|
|
type ComplianceCategoryResponse,
|
|
type ComplianceIndicatorResponse,
|
|
} from '../../api/screeningGuideApi';
|
|
import { t } from '../../constants/screeningTexts';
|
|
|
|
interface ComplianceTabProps {
|
|
lang: string;
|
|
indicatorType?: 'SHIP' | 'COMPANY';
|
|
}
|
|
|
|
type IndicatorType = 'SHIP' | 'COMPANY';
|
|
type CacheKey = string;
|
|
|
|
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
|
|
// SHIP
|
|
'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
|
|
'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
|
|
'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
|
|
'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
|
|
'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
|
|
'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
|
|
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
|
|
'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' },
|
|
'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' },
|
|
'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' },
|
|
'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' },
|
|
// COMPANY
|
|
'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' },
|
|
'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' },
|
|
'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' },
|
|
'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' },
|
|
'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' },
|
|
};
|
|
|
|
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
|
|
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
|
|
}
|
|
|
|
export default function ComplianceTab({ lang, indicatorType: fixedType }: ComplianceTabProps) {
|
|
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [indicatorType, setIndicatorType] = useState<IndicatorType>(fixedType ?? 'SHIP');
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
|
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
|
|
|
|
const fetchData = useCallback((fetchLang: string, type: IndicatorType) => {
|
|
return screeningGuideApi
|
|
.getComplianceIndicators(fetchLang, type)
|
|
.then((res) => {
|
|
const data = res.data ?? [];
|
|
cache.current.set(`${type}_${fetchLang}`, data);
|
|
return data;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setExpandedCategories(new Set());
|
|
cache.current.clear();
|
|
|
|
Promise.all([
|
|
fetchData('KO', indicatorType),
|
|
fetchData('EN', indicatorType),
|
|
])
|
|
.then(() => {
|
|
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
|
|
})
|
|
.catch((err: Error) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
}, [indicatorType, fetchData]);
|
|
|
|
useEffect(() => {
|
|
const cached = cache.current.get(`${indicatorType}_${lang}`);
|
|
if (cached) {
|
|
setCategories(cached);
|
|
}
|
|
}, [lang, indicatorType]);
|
|
|
|
const toggleCategory = (category: string) => {
|
|
setExpandedCategories((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(category)) {
|
|
next.delete(category);
|
|
} else {
|
|
next.add(category);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Ship / Company 토글 (fixedType이 없을 때만 표시) */}
|
|
{!fixedType && (
|
|
<div className="flex gap-2">
|
|
{(['SHIP', 'COMPANY'] as const).map((type) => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setIndicatorType(type)}
|
|
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
|
indicatorType === type
|
|
? 'bg-wing-text text-wing-bg shadow-sm'
|
|
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
|
|
}`}
|
|
>
|
|
{type === 'SHIP' ? 'Ship' : 'Company'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
|
<div className="text-center">
|
|
<div className="text-2xl mb-2">⏳</div>
|
|
<div className="text-sm">{t(lang, 'loading')}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
|
<strong>{t(lang, 'loadError')}</strong> {error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<div className="space-y-2">
|
|
{categories.map((cat) => {
|
|
const isExpanded = expandedCategories.has(cat.categoryCode);
|
|
const badge = getBadgeColor(cat.categoryCode);
|
|
return (
|
|
<div
|
|
key={cat.categoryCode}
|
|
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
|
|
>
|
|
{/* 아코디언 헤더 */}
|
|
<button
|
|
onClick={() => toggleCategory(cat.categoryCode)}
|
|
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
|
|
>
|
|
<span
|
|
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
|
|
style={{ background: badge.bg, color: badge.text }}
|
|
>
|
|
{cat.categoryName}
|
|
</span>
|
|
<span className="text-sm font-semibold text-wing-text text-left flex-1">
|
|
{cat.categoryName}
|
|
</span>
|
|
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
|
|
{cat.indicators.length}
|
|
</span>
|
|
<svg
|
|
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* 아코디언 콘텐츠 */}
|
|
{isExpanded && (
|
|
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
|
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
{cat.indicators.map((ind) => (
|
|
<IndicatorCard key={ind.indicatorId} indicator={ind} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) {
|
|
return (
|
|
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<div className="font-bold text-sm text-wing-text">
|
|
{indicator.fieldName}
|
|
</div>
|
|
{indicator.dataType && (
|
|
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
|
|
{indicator.dataType}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{indicator.description && (
|
|
<div className="text-xs text-wing-muted leading-relaxed mb-3 whitespace-pre-line">
|
|
{indicator.description}
|
|
</div>
|
|
)}
|
|
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
|
|
<div className="flex gap-2">
|
|
{indicator.conditionRed && (
|
|
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
|
|
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
|
|
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
|
|
</div>
|
|
)}
|
|
{indicator.conditionAmber && (
|
|
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
|
|
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
|
|
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
|
|
</div>
|
|
)}
|
|
{indicator.conditionGreen && (
|
|
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
|
|
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
|
|
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|