백엔드: - 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>
350 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|