백엔드: - JPA Entity 16개 (다국어 지원: Risk/Compliance/Methodology) - Repository 8개, DTO 5개, ScreeningGuideService, ScreeningGuideController - API: /api/screening-guide/risk-indicators, compliance-indicators, methodology-history - Swagger GroupedOpenApi "4. Screening Guide" 추가 - WebViewController SPA 라우트 추가 프론트엔드: - ScreeningGuide 메인 페이지 (탭 3개 + EN/KO 언어 토글) - RiskTab: 카테고리 필터, 테이블/카드 뷰, CSV 다운로드 - ComplianceTab: SHIP/COMPANY 토글, 카테고리 필터 - MethodologyTab: 변경유형 필터, 타임라인 테이블 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
386 lines
20 KiB
TypeScript
386 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
screeningGuideApi,
|
||
type ComplianceCategoryResponse,
|
||
type ComplianceIndicatorResponse,
|
||
} from '../../api/screeningGuideApi';
|
||
|
||
interface ComplianceTabProps {
|
||
lang: string;
|
||
}
|
||
|
||
type ViewMode = 'table' | 'card';
|
||
type IndicatorType = 'SHIP' | 'COMPANY';
|
||
|
||
const SHIP_CAT_COLORS: Record<string, string> = {
|
||
'Sanctions – Ship (US OFAC)': '#1e3a5f',
|
||
'Sanctions – Ownership (US OFAC)': '#1d4ed8',
|
||
'Sanctions – Ship (Non-US)': '#065f46',
|
||
'Sanctions – Ownership (Non-US)': '#0f766e',
|
||
'Sanctions – FATF': '#6b21a8',
|
||
'Sanctions – Other': '#991b1b',
|
||
'Port Calls': '#065f46',
|
||
'STS Activity': '#0f766e',
|
||
'Dark Activity': '#374151',
|
||
};
|
||
|
||
const COMPANY_CAT_COLORS: Record<string, string> = {
|
||
'Company Sanctions (US OFAC)': '#1e3a5f',
|
||
'Company Sanctions (Non-US)': '#065f46',
|
||
'Company Compliance': '#6b21a8',
|
||
'Company Risk': '#92400e',
|
||
};
|
||
|
||
function getCatHex(categoryName: string, type: IndicatorType): string {
|
||
const map = type === 'SHIP' ? SHIP_CAT_COLORS : COMPANY_CAT_COLORS;
|
||
return map[categoryName] ?? '#374151';
|
||
}
|
||
|
||
interface FlatRow {
|
||
category: string;
|
||
indicatorType: string;
|
||
indicator: ComplianceIndicatorResponse;
|
||
}
|
||
|
||
export default function ComplianceTab({ lang }: ComplianceTabProps) {
|
||
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
setError(null);
|
||
setSelectedCategory('전체');
|
||
screeningGuideApi
|
||
.getComplianceIndicators(lang, indicatorType)
|
||
.then((res) => setCategories(res.data ?? []))
|
||
.catch((err: Error) => setError(err.message))
|
||
.finally(() => setLoading(false));
|
||
}, [lang, indicatorType]);
|
||
|
||
const flatRows: FlatRow[] = categories.flatMap((cat) =>
|
||
cat.indicators.map((ind) => ({
|
||
category: cat.category,
|
||
indicatorType: cat.indicatorType,
|
||
indicator: ind,
|
||
})),
|
||
);
|
||
|
||
const filtered: FlatRow[] =
|
||
selectedCategory === '전체'
|
||
? flatRows
|
||
: flatRows.filter((r) => r.category === selectedCategory);
|
||
|
||
const uniqueCategories = Array.from(new Set(flatRows.map((r) => r.category)));
|
||
|
||
function downloadCSV() {
|
||
const bom = '\uFEFF';
|
||
const headers = [
|
||
'카테고리',
|
||
'타입',
|
||
'필드키',
|
||
'필드명',
|
||
'설명',
|
||
'RED 조건',
|
||
'AMBER 조건',
|
||
'GREEN 조건',
|
||
'데이터 타입',
|
||
'이력 관리 참고사항',
|
||
];
|
||
const rows = flatRows.map((r) =>
|
||
[
|
||
r.category,
|
||
r.indicatorType,
|
||
r.indicator.fieldKey,
|
||
r.indicator.fieldName,
|
||
r.indicator.description,
|
||
r.indicator.conditionRed,
|
||
r.indicator.conditionAmber,
|
||
r.indicator.conditionGreen,
|
||
r.indicator.dataType,
|
||
r.indicator.collectionNote,
|
||
]
|
||
.map((v) => `"${(v ?? '').replace(/"/g, '""')}"`)
|
||
.join(','),
|
||
);
|
||
const csv = bom + [headers.join(','), ...rows].join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `MIRS_Compliance_${indicatorType}.csv`;
|
||
a.click();
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* SHIP / COMPANY 토글 */}
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setIndicatorType('SHIP')}
|
||
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||
indicatorType === 'SHIP'
|
||
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||
}`}
|
||
>
|
||
선박 컴플라이언스 (SHIP)
|
||
</button>
|
||
<button
|
||
onClick={() => setIndicatorType('COMPANY')}
|
||
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||
indicatorType === 'COMPANY'
|
||
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||
}`}
|
||
>
|
||
기업 컴플라이언스 (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">데이터를 불러오는 중...</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||
<strong>데이터 로딩 실패:</strong> {error}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !error && (
|
||
<>
|
||
{/* 카테고리 요약 카드 */}
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||
{uniqueCategories.map((catName) => {
|
||
const count = flatRows.filter((r) => r.category === catName).length;
|
||
const isActive = selectedCategory === catName;
|
||
const hex = getCatHex(catName, indicatorType);
|
||
return (
|
||
<button
|
||
key={catName}
|
||
onClick={() =>
|
||
setSelectedCategory(isActive ? '전체' : catName)
|
||
}
|
||
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2 text-left"
|
||
style={{
|
||
background: isActive ? hex : undefined,
|
||
borderColor: isActive ? hex : undefined,
|
||
}}
|
||
>
|
||
<div
|
||
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
|
||
>
|
||
{count}
|
||
</div>
|
||
<div
|
||
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
|
||
>
|
||
{catName}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 컨트롤 바 */}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<button
|
||
onClick={() => setSelectedCategory('전체')}
|
||
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${
|
||
selectedCategory === '전체'
|
||
? 'bg-slate-900 text-white border-slate-900'
|
||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||
}`}
|
||
>
|
||
전체 ({flatRows.length})
|
||
</button>
|
||
<div className="flex-1" />
|
||
<span className="text-xs text-wing-muted">
|
||
표시:{' '}
|
||
<strong className="text-wing-text">{filtered.length}</strong>개 항목
|
||
</span>
|
||
<button
|
||
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
|
||
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
|
||
>
|
||
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
|
||
</button>
|
||
<button
|
||
onClick={downloadCSV}
|
||
className="px-4 py-1.5 rounded-lg bg-green-600 text-white text-xs font-bold hover:bg-green-700 transition-colors"
|
||
>
|
||
⬇ CSV 다운로드
|
||
</button>
|
||
</div>
|
||
|
||
{/* 테이블 뷰 */}
|
||
{viewMode === 'table' && (
|
||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead>
|
||
<tr className="bg-slate-900 text-white">
|
||
{[
|
||
'카테고리',
|
||
'필드명',
|
||
'설명',
|
||
'🔴 RED',
|
||
'🟡 AMBER',
|
||
'🟢 GREEN',
|
||
'데이터 타입',
|
||
'이력 관리 참고',
|
||
].map((h) => (
|
||
<th
|
||
key={h}
|
||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((row, i) => {
|
||
const showCat =
|
||
i === 0 ||
|
||
filtered[i - 1].category !== row.category;
|
||
const hex = getCatHex(row.category, indicatorType);
|
||
return (
|
||
<tr
|
||
key={`${row.indicator.indicatorId}-${i}`}
|
||
className="border-b border-wing-border align-top even:bg-wing-card"
|
||
>
|
||
<td className="px-3 py-2.5 min-w-[140px]">
|
||
{showCat && (
|
||
<span
|
||
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
||
style={{ background: hex }}
|
||
>
|
||
{row.category}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[160px]">
|
||
<div className="font-bold text-wing-text">
|
||
{row.indicator.fieldName}
|
||
</div>
|
||
<div className="text-wing-muted text-[10px] mt-0.5">
|
||
{row.indicator.fieldKey}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
|
||
{row.indicator.description}
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[130px]">
|
||
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
|
||
{row.indicator.conditionRed}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[130px]">
|
||
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
|
||
{row.indicator.conditionAmber}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[130px]">
|
||
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
|
||
{row.indicator.conditionGreen}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
|
||
{row.indicator.dataType}
|
||
</td>
|
||
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
|
||
{row.indicator.collectionNote &&
|
||
`💡 ${row.indicator.collectionNote}`}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 카드 뷰 */}
|
||
{viewMode === 'card' && (
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
{filtered.map((row, i) => {
|
||
const hex = getCatHex(row.category, indicatorType);
|
||
return (
|
||
<div
|
||
key={`${row.indicator.indicatorId}-${i}`}
|
||
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||
>
|
||
<div
|
||
className="px-4 py-2.5 flex justify-between items-center"
|
||
style={{ background: hex }}
|
||
>
|
||
<span className="text-white text-[10px] font-bold">
|
||
{row.category}
|
||
</span>
|
||
<span className="text-white/75 text-[10px]">
|
||
{row.indicator.dataType}
|
||
</span>
|
||
</div>
|
||
<div className="p-4">
|
||
<div className="font-bold text-sm text-wing-text mb-0.5">
|
||
{row.indicator.fieldName}
|
||
</div>
|
||
<div className="text-[10px] text-wing-muted mb-3">
|
||
{row.indicator.fieldKey}
|
||
</div>
|
||
<div className="text-xs text-wing-text leading-relaxed mb-3">
|
||
{row.indicator.description}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||
<div className="bg-red-50 rounded-lg p-2">
|
||
<div className="text-[10px] font-bold text-red-800 mb-1">
|
||
🔴 RED
|
||
</div>
|
||
<div className="text-[11px] text-red-800">
|
||
{row.indicator.conditionRed}
|
||
</div>
|
||
</div>
|
||
<div className="bg-amber-50 rounded-lg p-2">
|
||
<div className="text-[10px] font-bold text-amber-800 mb-1">
|
||
🟡 AMBER
|
||
</div>
|
||
<div className="text-[11px] text-amber-800">
|
||
{row.indicator.conditionAmber}
|
||
</div>
|
||
</div>
|
||
<div className="bg-green-50 rounded-lg p-2">
|
||
<div className="text-[10px] font-bold text-green-800 mb-1">
|
||
🟢 GREEN
|
||
</div>
|
||
<div className="text-[11px] text-green-800">
|
||
{row.indicator.conditionGreen}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{row.indicator.collectionNote && (
|
||
<div className="bg-blue-50 rounded-lg p-3 text-[11px] text-blue-700 leading-relaxed">
|
||
💡 {row.indicator.collectionNote}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|