snp-batch-validation/frontend/src/components/screening/RiskTab.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

350 lines
17 KiB
TypeScript

import { useState, useEffect } from 'react';
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
interface RiskTabProps {
lang: string;
}
type ViewMode = 'table' | 'card';
const CAT_COLORS: Record<string, string> = {
'AIS': 'bg-blue-800',
'Port Calls': 'bg-emerald-800',
'Associated with Russia': 'bg-red-800',
'Behavioural Risk': 'bg-amber-800',
'Safety, Security & Inspections': 'bg-blue-600',
'Flag Risk': 'bg-purple-800',
'Owner & Classification': 'bg-teal-700',
};
const CAT_HEX: Record<string, string> = {
'AIS': '#1e40af',
'Port Calls': '#065f46',
'Associated with Russia': '#991b1b',
'Behavioural Risk': '#92400e',
'Safety, Security & Inspections': '#1d4ed8',
'Flag Risk': '#6b21a8',
'Owner & Classification': '#0f766e',
};
function getCatColor(categoryName: string): string {
return CAT_COLORS[categoryName] ?? 'bg-slate-700';
}
function getCatHex(categoryName: string): string {
return CAT_HEX[categoryName] ?? '#374151';
}
interface FlatRow {
category: string;
indicator: RiskIndicatorResponse;
}
export default function RiskTab({ lang }: RiskTabProps) {
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [viewMode, setViewMode] = useState<ViewMode>('table');
useEffect(() => {
setLoading(true);
setError(null);
screeningGuideApi
.getRiskIndicators(lang)
.then((res) => setCategories(res.data ?? []))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [lang]);
const flatRows: FlatRow[] = categories.flatMap((cat) =>
cat.indicators.map((ind) => ({ category: cat.categoryName, indicator: ind })),
);
const filtered: FlatRow[] =
selectedCategory === '전체'
? flatRows
: flatRows.filter((r) => r.category === selectedCategory);
function downloadCSV() {
const bom = '\uFEFF';
const headers = [
'카테고리',
'필드키',
'필드명',
'설명',
'RED 조건',
'AMBER 조건',
'GREEN 조건',
'데이터 타입',
'이력 관리 참고사항',
];
const rows = flatRows.map((r) =>
[
r.category,
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_Risk_Indicators.csv';
a.click();
}
if (loading) {
return (
<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>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
<strong> :</strong> {error}
</div>
);
}
return (
<div className="space-y-4">
{/* 카테고리 요약 카드 */}
<div className="grid grid-cols-4 gap-3 lg:grid-cols-7">
{categories.map((cat) => {
const isActive = selectedCategory === cat.categoryName;
const hex = getCatHex(cat.categoryName);
return (
<button
key={cat.categoryCode}
onClick={() =>
setSelectedCategory(isActive ? '전체' : cat.categoryName)
}
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2"
style={{
background: isActive ? hex : undefined,
borderColor: isActive ? hex : undefined,
}}
>
<div
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
>
{cat.indicators.length}
</div>
<div
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
>
{cat.categoryName}
</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);
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-[110px]">
{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);
const colorClass = getCatColor(row.category);
return (
<div
key={`${row.indicator.indicatorId}-${i}`}
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
>
<div
className={`${colorClass} 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>
);
}