snp-batch-validation/frontend/src/components/screening/ComplianceTab.tsx
HYOJIN 5f7708962d feat(screening): Risk & Compliance 다국어 지원 및 사용자 편의성 개선 (#134)
- UI 고정 텍스트 다국어 메타 파일(screeningTexts.ts) 추가
- -999/null 값 'No Data'/'데이터 없음' 표시 처리
- Screening Guide 탭 분리 (Ship/Company Compliance)
- Change History ↔ Screening Guide 간 언어 설정 공유
- 섹션 헤더에 Screening Guide 연결 링크 추가
2026-04-02 11:14:00 +09:00

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>
);
}