snp-batch-validation/frontend/src/components/screening/ComplianceTab.tsx
HYOJIN 62741d5f1d feat: Risk&Compliance Screening Guide 페이지 생성 (#109)
백엔드:
- 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>
2026-03-27 16:42:29 +09:00

386 lines
20 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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