snp-batch-validation/frontend/src/components/screening/MethodologyTab.tsx
HYOJIN e3465401a2 feat(screening): Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:52:00 +09:00

209 lines
8.3 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
interface MethodologyTabProps {
lang: string;
}
const CHANGE_TYPE_COLORS: Record<string, string> = {
Addition: '#065f46',
Update: '#1d4ed8',
Expansion: '#6b21a8',
Change: '#92400e',
Removal: '#991b1b',
New: '#0f766e',
};
function getChangeTypeColor(changeType: string): string {
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
}
type LangKey = 'KO' | 'EN';
interface LangCache {
history: MethodologyHistoryResponse[];
banner: string;
}
export default function MethodologyTab({ lang }: MethodologyTabProps) {
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
const [banner, setBanner] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState('전체');
const cache = useRef<Map<LangKey, LangCache>>(new Map());
const fetchData = useCallback((fetchLang: string) => {
return Promise.all([
screeningGuideApi.getMethodologyHistory(fetchLang),
screeningGuideApi.getMethodologyBanner(fetchLang).catch(() => ({ data: null })),
]).then(([historyRes, bannerRes]) => {
const data: LangCache = {
history: historyRes.data ?? [],
banner: bannerRes.data?.description ?? '',
};
cache.current.set(fetchLang as LangKey, data);
return data;
});
}, []);
// 초기 로드: KO/EN 데이터 모두 가져와서 캐싱
useEffect(() => {
setLoading(true);
setError(null);
cache.current.clear();
Promise.all([fetchData('KO'), fetchData('EN')])
.then(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setHistory(cached.history);
setBanner(cached.banner);
}
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [fetchData]);
// 언어 변경: 캐시에서 스위칭
useEffect(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setHistory(cached.history);
setBanner(cached.banner);
}
}, [lang]);
const sortedHistory = [...history].sort((a, b) =>
b.changeDate.localeCompare(a.changeDate),
);
const uniqueTypes = Array.from(new Set(history.map((h) => h.changeType)));
const filtered =
selectedType === '전체'
? sortedHistory
: sortedHistory.filter((h) => h.changeType === selectedType);
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">
{/* 주의사항 배너 */}
{banner && (
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
{banner}
</div>
)}
{/* 변경 유형 필터 */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSelectedType('전체')}
className={`px-3 py-1 rounded-full text-[11px] font-bold transition-colors border ${
selectedType === '전체'
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
({history.length})
</button>
{uniqueTypes.map((type) => {
const count = history.filter((h) => h.changeType === type).length;
const hex = getChangeTypeColor(type);
const isActive = selectedType === type;
return (
<button
key={type}
onClick={() => setSelectedType(isActive ? '전체' : type)}
className="px-3 py-1 rounded-full text-[11px] font-bold transition-all border"
style={{
background: isActive ? hex : undefined,
borderColor: isActive ? hex : undefined,
color: isActive ? 'white' : undefined,
}}
>
{type} ({count})
</button>
);
})}
</div>
<div className="text-xs text-wing-muted">
: <strong className="text-wing-text">{filtered.length}</strong> |
</div>
{/* 타임라인 목록 */}
<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">
{['날짜', '변경 유형', '설명'].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 hex = getChangeTypeColor(row.changeType);
return (
<tr
key={`${row.historyId}-${i}`}
className="border-b border-wing-border align-top even:bg-wing-card"
>
<td className="px-3 py-3 min-w-[110px]">
<div className="font-bold text-wing-text">
{row.changeDate}
</div>
</td>
<td className="px-3 py-3 min-w-[110px]">
<span
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
style={{ background: hex }}
>
{row.changeType}
</span>
</td>
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
{row.description || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{filtered.length === 0 && (
<div className="text-center py-12 text-wing-muted text-sm">
.
</div>
)}
</div>
);
}