Merge pull request 'feat(screening): Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124)' (#131) from feature/ISSUE-124-risk-compliance-feedback into develop

This commit is contained in:
HYOJIN 2026-04-01 16:53:56 +09:00
커밋 69f8d954eb
27개의 변경된 파일786개의 추가작업 그리고 809개의 파일을 삭제

파일 보기

@ -5,6 +5,20 @@
## [Unreleased]
### 추가
- Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124)
- Screening Guide 아코디언 리스트 UI 개편 (카테고리별 접기/펼치기)
- 언더라인 탭 및 언어 토글 디자인 통일
- 다중언어 데이터 캐싱 (화면 로드 시 KO/EN 동시 조회)
- Compliance 카테고리 다중언어 테이블 신규 생성 (compliance_category, compliance_category_lang)
- RAG 지표 색상 테마 CSS 변수화 (다크모드/라이트모드 대응)
### 수정
- Change History 선박 제재 KO 데이터 조회 누락 수정 (categoryCode 기반 분류로 변경)
### 변경
- Navbar 메인 섹션 왼쪽 정렬, 서브 섹션 오른쪽 정렬로 변경
- 불필요한 DB 컬럼 참조 코드 제거 (collection_note, update_title)
- S&P Bypass 피드백 반영 (#123)
- Response JSON 원본 반환 (ApiResponse 래핑 제거)
- 사용자용 API 카탈로그 페이지 추가 (/bypass-catalog)

파일 보기

@ -0,0 +1,111 @@
-- ============================================================
-- Compliance 카테고리 다중언어 마이그레이션 스크립트
-- ============================================================
-- 1. 카테고리 마스터 테이블 생성
CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category (
category_code VARCHAR(50) PRIMARY KEY,
indicator_type VARCHAR(20) NOT NULL,
sort_order INTEGER NOT NULL
);
-- 2. 카테고리 다중언어 테이블 생성
CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category_lang (
category_code VARCHAR(50) NOT NULL,
lang_code VARCHAR(5) NOT NULL,
category_name VARCHAR(200) NOT NULL,
PRIMARY KEY (category_code, lang_code)
);
-- 3. 카테고리 마스터 데이터 삽입
INSERT INTO std_snp_data.compliance_category (category_code, indicator_type, sort_order) VALUES
('SANCTIONS_SHIP_US_OFAC', 'SHIP', 1),
('SANCTIONS_OWNERSHIP_US_OFAC', 'SHIP', 2),
('SANCTIONS_SHIP_NON_US', 'SHIP', 3),
('SANCTIONS_OWNERSHIP_NON_US', 'SHIP', 4),
('SANCTIONS_FATF', 'SHIP', 5),
('SANCTIONS_OTHER', 'SHIP', 6),
('PORT_CALLS', 'SHIP', 7),
('STS_ACTIVITY', 'SHIP', 8),
('SUSPICIOUS_BEHAVIOR', 'SHIP', 9),
('OWNERSHIP_SCREENING', 'SHIP', 10),
('COMPLIANCE_SCREENING_HISTORY', 'SHIP', 11),
('US_TREASURY_SANCTIONS', 'COMPANY', 1),
('NON_US_SANCTIONS', 'COMPANY', 2),
('FATF_JURISDICTION', 'COMPANY', 3),
('PARENT_COMPANY', 'COMPANY', 4),
('OVERALL_COMPLIANCE_STATUS', 'COMPANY', 5),
('RELATED_SCREENING', 'COMPANY', 6),
('COMPANY_COMPLIANCE_HISTORY', 'COMPANY', 7);
-- 4. 카테고리 다중언어 데이터 삽입 (EN)
INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES
('SANCTIONS_SHIP_US_OFAC', 'EN', 'Sanctions Ship (US OFAC)'),
('SANCTIONS_OWNERSHIP_US_OFAC', 'EN', 'Sanctions Ownership (US OFAC)'),
('SANCTIONS_SHIP_NON_US', 'EN', 'Sanctions Ship (Non-US)'),
('SANCTIONS_OWNERSHIP_NON_US', 'EN', 'Sanctions Ownership (Non-US)'),
('SANCTIONS_FATF', 'EN', 'Sanctions FATF'),
('SANCTIONS_OTHER', 'EN', 'Sanctions Other'),
('PORT_CALLS', 'EN', 'Port Calls'),
('STS_ACTIVITY', 'EN', 'STS Activity'),
('SUSPICIOUS_BEHAVIOR', 'EN', 'Suspicious Behavior'),
('OWNERSHIP_SCREENING', 'EN', 'Ownership Screening'),
('COMPLIANCE_SCREENING_HISTORY', 'EN', 'Compliance Screening History'),
('US_TREASURY_SANCTIONS', 'EN', 'US Treasury Sanctions'),
('NON_US_SANCTIONS', 'EN', 'Non-US Sanctions'),
('FATF_JURISDICTION', 'EN', 'FATF Jurisdiction'),
('PARENT_COMPANY', 'EN', 'Parent Company'),
('OVERALL_COMPLIANCE_STATUS', 'EN', 'Overall Compliance Status'),
('RELATED_SCREENING', 'EN', 'Related Screening'),
('COMPANY_COMPLIANCE_HISTORY', 'EN', 'Compliance Screening Change History');
-- 5. 카테고리 다중언어 데이터 삽입 (KO)
INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES
('SANCTIONS_SHIP_US_OFAC', 'KO', '제재 선박 (US OFAC)'),
('SANCTIONS_OWNERSHIP_US_OFAC', 'KO', '제재 소유권 (US OFAC)'),
('SANCTIONS_SHIP_NON_US', 'KO', '제재 선박 (비미국)'),
('SANCTIONS_OWNERSHIP_NON_US', 'KO', '제재 소유권 (비미국)'),
('SANCTIONS_FATF', 'KO', '제재 FATF'),
('SANCTIONS_OTHER', 'KO', '제재 기타'),
('PORT_CALLS', 'KO', '입항 이력'),
('STS_ACTIVITY', 'KO', 'STS 활동'),
('SUSPICIOUS_BEHAVIOR', 'KO', '의심 행위'),
('OWNERSHIP_SCREENING', 'KO', '소유권 심사'),
('COMPLIANCE_SCREENING_HISTORY', 'KO', '컴플라이언스 심사 이력'),
('US_TREASURY_SANCTIONS', 'KO', '미국 재무부 제재'),
('NON_US_SANCTIONS', 'KO', '비미국 제재'),
('FATF_JURISDICTION', 'KO', 'FATF 관할지역'),
('PARENT_COMPANY', 'KO', '모회사'),
('OVERALL_COMPLIANCE_STATUS', 'KO', '종합 컴플라이언스 상태'),
('RELATED_SCREENING', 'KO', '관련 심사'),
('COMPANY_COMPLIANCE_HISTORY', 'KO', '컴플라이언스 심사 변경 이력');
-- 6. compliance_indicator 테이블: category → category_code 변환
-- 먼저 컬럼 추가
ALTER TABLE std_snp_data.compliance_indicator ADD COLUMN category_code VARCHAR(50);
-- 기존 category 값을 category_code로 매핑
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_US_OFAC' WHERE category = 'Sanctions Ship (US OFAC)';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_US_OFAC' WHERE category = 'Sanctions Ownership (US OFAC)';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_NON_US' WHERE category = 'Sanctions Ship (Non-US)';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_NON_US' WHERE category = 'Sanctions Ownership (Non-US)';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_FATF' WHERE category = 'Sanctions FATF';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OTHER' WHERE category = 'Sanctions Other';
UPDATE std_snp_data.compliance_indicator SET category_code = 'PORT_CALLS' WHERE category = 'Port Calls';
UPDATE std_snp_data.compliance_indicator SET category_code = 'STS_ACTIVITY' WHERE category = 'STS Activity';
UPDATE std_snp_data.compliance_indicator SET category_code = 'SUSPICIOUS_BEHAVIOR' WHERE category = 'Suspicious Behavior';
UPDATE std_snp_data.compliance_indicator SET category_code = 'OWNERSHIP_SCREENING' WHERE category = 'Ownership Screening';
UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPLIANCE_SCREENING_HISTORY' WHERE category = 'Compliance Screening History';
UPDATE std_snp_data.compliance_indicator SET category_code = 'US_TREASURY_SANCTIONS' WHERE category = 'US Treasury Sanctions';
UPDATE std_snp_data.compliance_indicator SET category_code = 'NON_US_SANCTIONS' WHERE category = 'Non-US Sanctions';
UPDATE std_snp_data.compliance_indicator SET category_code = 'FATF_JURISDICTION' WHERE category = 'FATF Jurisdiction';
UPDATE std_snp_data.compliance_indicator SET category_code = 'PARENT_COMPANY' WHERE category = 'Parent Company';
UPDATE std_snp_data.compliance_indicator SET category_code = 'OVERALL_COMPLIANCE_STATUS' WHERE category = 'Overall Compliance Status';
UPDATE std_snp_data.compliance_indicator SET category_code = 'RELATED_SCREENING' WHERE category = 'Related Screening';
UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPANY_COMPLIANCE_HISTORY' WHERE category = 'Compliance Screening Change History';
-- category_code NOT NULL 설정
ALTER TABLE std_snp_data.compliance_indicator ALTER COLUMN category_code SET NOT NULL;
-- 기존 category 컬럼 삭제
ALTER TABLE std_snp_data.compliance_indicator DROP COLUMN category;

파일 보기

@ -15,7 +15,6 @@ export interface RiskIndicatorResponse {
conditionAmber: string;
conditionGreen: string;
dataType: string;
collectionNote: string;
}
export interface RiskCategoryResponse {
@ -34,11 +33,11 @@ export interface ComplianceIndicatorResponse {
conditionAmber: string;
conditionGreen: string;
dataType: string;
collectionNote: string;
}
export interface ComplianceCategoryResponse {
category: string;
categoryCode: string;
categoryName: string;
indicatorType: string;
indicators: ComplianceIndicatorResponse[];
}
@ -47,10 +46,9 @@ export interface ComplianceCategoryResponse {
export interface MethodologyHistoryResponse {
historyId: number;
changeDate: string;
changeTypeCode: string;
changeType: string;
updateTitle: string;
description: string;
collectionNote: string;
}
// 값 변경 이력 타입
@ -108,6 +106,7 @@ export interface CompanyInfoResponse {
export interface IndicatorStatusResponse {
columnName: string;
fieldName: string;
categoryCode: string;
category: string;
value: string | null;
narrative: string | null;
@ -129,6 +128,8 @@ export const screeningGuideApi = {
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
getMethodologyHistory: (lang = 'KO') =>
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
getMethodologyBanner: (lang = 'KO') =>
fetchJson<ApiResponse<MethodologyHistoryResponse>>(`${BASE}/methodology-banner?lang=${lang}`),
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>

파일 보기

@ -60,10 +60,10 @@ const MENU_STRUCTURE: MenuSection[] = [
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
defaultPath: '/screening-guide',
defaultPath: '/risk-compliance-history',
children: [
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
{ id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' },
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
],
},
];
@ -101,7 +101,7 @@ export default function Navbar() {
>
</Link>
<div className="flex-1 flex items-center justify-center gap-1">
<div className="flex-1 flex items-center justify-start gap-1">
{MENU_STRUCTURE.map((section) => (
<button
key={section.id}
@ -132,7 +132,7 @@ export default function Navbar() {
{/* 2단: 서브 탭 */}
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md">
<div className="flex gap-1 -mb-px justify-center">
<div className="flex gap-1 -mb-px justify-end">
{currentSection?.children.map((child) => (
<button
key={child.id}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
screeningGuideApi,
type ComplianceCategoryResponse,
@ -9,135 +9,105 @@ interface ComplianceTabProps {
lang: string;
}
type ViewMode = 'table' | 'card';
type IndicatorType = 'SHIP' | 'COMPANY';
type CacheKey = string;
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 CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
// SHIP
'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' },
'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' },
'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' },
'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' },
// COMPANY
'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' },
'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' },
'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' },
'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' },
'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' },
};
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;
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
}
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');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
const fetchData = useCallback((fetchLang: string, type: IndicatorType) => {
return screeningGuideApi
.getComplianceIndicators(fetchLang, type)
.then((res) => {
const data = res.data ?? [];
cache.current.set(`${type}_${fetchLang}`, data);
return data;
});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
setSelectedCategory('전체');
screeningGuideApi
.getComplianceIndicators(lang, indicatorType)
.then((res) => setCategories(res.data ?? []))
setExpandedCategories(new Set());
cache.current.clear();
Promise.all([
fetchData('KO', indicatorType),
fetchData('EN', indicatorType),
])
.then(() => {
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [indicatorType, fetchData]);
useEffect(() => {
const cached = cache.current.get(`${indicatorType}_${lang}`);
if (cached) {
setCategories(cached);
}
}, [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();
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
return (
<div className="space-y-4">
{/* SHIP / COMPANY 토글 */}
{/* Ship / Company 토글 */}
<div className="flex gap-2">
{(['SHIP', 'COMPANY'] as const).map((type) => (
<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'
key={type}
onClick={() => setIndicatorType(type)}
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
indicatorType === type
? 'bg-wing-text text-wing-bg shadow-sm'
: 'bg-wing-card text-wing-muted border 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)
{type === 'SHIP' ? 'Ship' : 'Company'}
</button>
))}
</div>
{loading && (
@ -156,230 +126,101 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) {
)}
{!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);
<div className="space-y-2">
{categories.map((cat) => {
const isExpanded = expandedCategories.has(cat.categoryCode);
const badge = getBadgeColor(cat.categoryCode);
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'}`}
key={cat.categoryCode}
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
>
{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'
}`}
onClick={() => toggleCategory(cat.categoryCode)}
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
>
({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 }}
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
style={{ background: badge.bg, color: badge.text }}
>
{row.category}
{cat.categoryName}
</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>
)}
<span className="text-sm font-semibold text-wing-text text-left flex-1">
{cat.categoryName}
</span>
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
{cat.indicators.length}
</span>
<svg
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 카드 뷰 */}
{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>
{/* 아코디언 콘텐츠 */}
{isExpanded && (
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{cat.indicators.map((ind) => (
<IndicatorCard key={ind.indicatorId} indicator={ind} />
))}
</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>
);
}
function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) {
return (
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="font-bold text-sm text-wing-text">
{indicator.fieldName}
</div>
{indicator.dataType && (
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
{indicator.dataType}
</span>
)}
</div>
{indicator.description && (
<div className="text-xs text-wing-muted leading-relaxed mb-3 whitespace-pre-line">
{indicator.description}
</div>
)}
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
<div className="flex gap-2">
{indicator.conditionRed && (
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
</div>
)}
{indicator.conditionAmber && (
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
</div>
)}
{indicator.conditionGreen && (
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
</div>
)}
</div>
)}
</div>
);
}

파일 보기

@ -166,12 +166,13 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
);
}
// 선박 Compliance 탭 분류
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') },
{ key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' },
{ key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' },
// 선박 Compliance 탭 분류 (categoryCode 기반)
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') },
{ key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
{ key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' },
];
// Compliance 예외 처리
@ -252,7 +253,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
const tabData = useMemo(() => {
const result: Record<string, IndicatorStatusResponse[]> = {};
for (const tab of SHIP_COMPLIANCE_TABS) {
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
result[tab.key] = filteredItems.filter((i) => tab.match(i.categoryCode));
}
return result;
}, [filteredItems]);
@ -461,22 +462,24 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
return (
<div className="space-y-4">
{/* 이력 유형 선택 */}
<div className="flex gap-2 flex-wrap justify-center">
{/* 이력 유형 선택 (언더라인 탭) */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{HISTORY_TYPES.map((t) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
historyType === t.key
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
? 'text-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* 검색 */}
<div className="flex gap-3 items-center justify-center">

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
interface MethodologyTabProps {
@ -18,20 +18,60 @@ 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);
screeningGuideApi
.getMethodologyHistory(lang)
.then((res) => setHistory(res.data ?? []))
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) =>
@ -67,11 +107,11 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
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">
<strong> :</strong> ·
. ,
.
{banner}
</div>
)}
{/* 변경 유형 필터 */}
<div className="flex gap-2 flex-wrap">
@ -116,7 +156,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 text-white">
{['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => (
{['날짜', '변경 유형', '설명'].map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
@ -147,14 +187,8 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
{row.changeType}
</span>
</td>
<td className="px-3 py-3 min-w-[180px] font-semibold text-wing-text leading-relaxed">
{row.updateTitle}
</td>
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
{row.description}
</td>
<td className="px-3 py-3 min-w-[200px] text-blue-600 leading-relaxed">
{row.collectionNote && `💡 ${row.collectionNote}`}
{row.description || '-'}
</td>
</tr>
);

파일 보기

@ -1,106 +1,74 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
interface RiskTabProps {
lang: string;
}
type ViewMode = 'table' | 'card';
type LangKey = 'KO' | 'EN';
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_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
'AIS': { bg: '#dbeafe', text: '#1e40af' },
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
'ASSOCIATED_WITH_RUSSIA': { bg: '#fee2e2', text: '#991b1b' },
'BEHAVIOURAL_RISK': { bg: '#fef3c7', text: '#92400e' },
'SAFETY_SECURITY_AND_INSPECTIONS': { bg: '#dbeafe', text: '#1d4ed8' },
'FLAG_RISK': { bg: '#ede9fe', text: '#6b21a8' },
'OWNER_AND_CLASSIFICATION': { bg: '#ccfbf1', text: '#0f766e' },
};
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;
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
}
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');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const cache = useRef<Map<LangKey, RiskCategoryResponse[]>>(new Map());
const fetchData = useCallback((fetchLang: string) => {
return screeningGuideApi
.getRiskIndicators(fetchLang)
.then((res) => {
const data = res.data ?? [];
cache.current.set(fetchLang as LangKey, data);
return data;
});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
screeningGuideApi
.getRiskIndicators(lang)
.then((res) => setCategories(res.data ?? []))
cache.current.clear();
Promise.all([fetchData('KO'), fetchData('EN')])
.then(() => {
setCategories(cache.current.get(lang as LangKey) ?? []);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [fetchData]);
useEffect(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setCategories(cached);
}
}, [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();
const toggleCategory = (code: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
return next;
});
};
if (loading) {
return (
@ -122,226 +90,95 @@ export default function RiskTab({ lang }: RiskTabProps) {
}
return (
<div className="space-y-4">
{/* 카테고리 요약 카드 */}
<div className="grid grid-cols-4 gap-3 lg:grid-cols-7">
<div className="space-y-2">
{categories.map((cat) => {
const isActive = selectedCategory === cat.categoryName;
const hex = getCatHex(cat.categoryName);
const isExpanded = expandedCategories.has(cat.categoryCode);
const badge = getBadgeColor(cat.categoryCode);
return (
<button
<div
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,
}}
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
>
<div
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
<button
onClick={() => toggleCategory(cat.categoryCode)}
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
>
{cat.indicators.length}
</div>
<div
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
<span
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
style={{ background: badge.bg, color: badge.text }}
>
{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"
<span className="text-sm font-semibold text-wing-text text-left flex-1">
{cat.categoryName}
</span>
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
{cat.indicators.length}
</span>
<svg
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</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>
{isExpanded && (
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{cat.indicators.map((ind) => (
<IndicatorCard key={ind.indicatorId} indicator={ind} />
))}
</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>
</div>
</div>
)}
</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);
function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) {
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}
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="font-bold text-sm text-wing-text">
{indicator.fieldName}
</div>
{indicator.dataType && (
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
{indicator.dataType}
</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>
{indicator.description && (
<div className="text-xs text-wing-muted leading-relaxed mb-3">
{indicator.description}
</div>
);
})}
)}
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
<div className="flex gap-2">
{indicator.conditionRed && (
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
</div>
)}
{indicator.conditionAmber && (
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
</div>
)}
{indicator.conditionGreen && (
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
</div>
)}
</div>
)}
</div>

파일 보기

@ -24,7 +24,7 @@ const sections = [
title: 'S&P Risk & Compliance',
description: 'S&P 위험 지표 및 규정 준수',
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
path: '/screening-guide',
path: '/risk-compliance-history',
icon: '⚖️',
iconClass: 'gc-card-icon gc-card-icon-nexus',
menuCount: 2,

파일 보기

@ -6,42 +6,28 @@ export default function RiskComplianceHistory() {
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
<div className="text-xs opacity-75 mb-1">
S&P Global · Maritime Intelligence Risk Suite (MIRS)
</div>
<h1 className="text-xl font-bold mb-1">
Risk & Compliance Change History
</h1>
<p className="text-sm opacity-85">
{/* 헤더 + 언어 토글 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">Risk & Compliance Change History</h1>
<p className="mt-1 text-sm text-wing-muted">
S&P
</p>
</div>
{/* 언어 토글 */}
<div className="flex justify-end">
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
{(['EN', 'KO'] as const).map((l) => (
<button
onClick={() => setLang('EN')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'EN'
? 'bg-slate-900 text-white'
key={l}
onClick={() => setLang(l)}
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
lang === l
? 'bg-wing-text text-wing-bg'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
EN
</button>
<button
onClick={() => setLang('KO')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'KO'
? 'bg-slate-900 text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
KO
{l}
</button>
))}
</div>
</div>

파일 보기

@ -3,97 +3,70 @@ import RiskTab from '../components/screening/RiskTab';
import ComplianceTab from '../components/screening/ComplianceTab';
import MethodologyTab from '../components/screening/MethodologyTab';
type ActiveTab = 'risk' | 'compliance' | 'methodology';
type ActiveTab = 'compliance' | 'risk' | 'methodology';
interface TabButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
function TabButton({ active, onClick, children }: TabButtonProps) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
active
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
}`}
>
{children}
</button>
);
}
const TABS: { key: ActiveTab; label: string }[] = [
{ key: 'compliance', label: 'Compliance' },
{ key: 'risk', label: 'Risk Indicators' },
{ key: 'methodology', label: 'Methodology History' },
];
export default function ScreeningGuide() {
const [activeTab, setActiveTab] = useState<ActiveTab>('risk');
const [lang, setLang] = useState('KO');
const [activeTab, setActiveTab] = useState<ActiveTab>('compliance');
const [lang, setLang] = useState('EN');
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
<div className="text-xs opacity-75 mb-1">
S&P Global · Maritime Intelligence Risk Suite (MIRS)
</div>
<h1 className="text-xl font-bold mb-1">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">
Risk & Compliance Screening Guide
</h1>
<p className="text-sm opacity-85">
<p className="mt-1 text-sm text-wing-muted">
S&P Risk Indicators and Regulatory Compliance Screening Guide
</p>
</div>
{/* 탭 + 언어 토글 */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex gap-2 flex-wrap">
<TabButton
active={activeTab === 'risk'}
onClick={() => setActiveTab('risk')}
{/* 언어 토글 */}
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
{(['EN', 'KO'] as const).map((l) => (
<button
key={l}
onClick={() => setLang(l)}
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
lang === l
? 'bg-wing-text text-wing-bg'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
Risk Indicators
</TabButton>
<TabButton
active={activeTab === 'compliance'}
onClick={() => setActiveTab('compliance')}
>
Compliance
</TabButton>
<TabButton
active={activeTab === 'methodology'}
onClick={() => setActiveTab('methodology')}
>
Methodology History
</TabButton>
{l}
</button>
))}
</div>
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
</div>
{/* 언더라인 탭 */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{TABS.map((tab) => (
<button
onClick={() => setLang('EN')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'EN'
? 'bg-slate-900 text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
activeTab === tab.key
? 'text-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text'
}`}
>
EN
</button>
<button
onClick={() => setLang('KO')}
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
lang === 'KO'
? 'bg-slate-900 text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text'
}`}
>
KO
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 내용 */}
{activeTab === 'risk' && <RiskTab lang={lang} />}
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
{activeTab === 'risk' && <RiskTab lang={lang} />}
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
</div>
);

파일 보기

@ -19,6 +19,12 @@
--wing-hover: rgba(255, 255, 255, 0.05);
--wing-input-bg: #0f172a;
--wing-input-border: #334155;
--wing-rag-red-bg: rgba(127, 29, 29, 0.15);
--wing-rag-red-text: #fca5a5;
--wing-rag-amber-bg: rgba(120, 53, 15, 0.15);
--wing-rag-amber-text: #fcd34d;
--wing-rag-green-bg: rgba(5, 46, 22, 0.15);
--wing-rag-green-text: #86efac;
}
/* Light theme */
@ -41,6 +47,12 @@
--wing-hover: rgba(0, 0, 0, 0.04);
--wing-input-bg: #ffffff;
--wing-input-border: #cbd5e1;
--wing-rag-red-bg: #fef2f2;
--wing-rag-red-text: #b91c1c;
--wing-rag-amber-bg: #fffbeb;
--wing-rag-amber-text: #b45309;
--wing-rag-green-bg: #f0fdf4;
--wing-rag-green-text: #15803d;
}
@theme {
@ -62,5 +74,11 @@
--color-wing-hover: var(--wing-hover);
--color-wing-input-bg: var(--wing-input-bg);
--color-wing-input-border: var(--wing-input-border);
--color-wing-rag-red-bg: var(--wing-rag-red-bg);
--color-wing-rag-red-text: var(--wing-rag-red-text);
--color-wing-rag-amber-bg: var(--wing-rag-amber-bg);
--color-wing-rag-amber-text: var(--wing-rag-amber-text);
--color-wing-rag-green-bg: var(--wing-rag-green-bg);
--color-wing-rag-green-text: var(--wing-rag-green-text);
--font-sans: 'Noto Sans KR', sans-serif;
}

파일 보기

@ -60,6 +60,14 @@ public class ScreeningGuideController {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
}
@Operation(summary = "방법론 배너 조회", description = "방법론 변경 이력 페이지의 안내 배너 텍스트를 조회합니다.")
@GetMapping("/methodology-banner")
public ResponseEntity<ApiResponse<MethodologyHistoryResponse>> getMethodologyBanner(
@Parameter(description = "언어 코드", example = "KO")
@RequestParam(defaultValue = "KO") String lang) {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyBanner(lang)));
}
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
@GetMapping("/history/ship-risk")
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(

파일 보기

@ -13,7 +13,8 @@ import java.util.List;
@AllArgsConstructor
public class ComplianceCategoryResponse {
private String category;
private String categoryCode;
private String categoryName;
private String indicatorType;
private List<ComplianceIndicatorResponse> indicators;
}

파일 보기

@ -19,5 +19,4 @@ public class ComplianceIndicatorResponse {
private String conditionAmber;
private String conditionGreen;
private String dataType;
private String collectionNote;
}

파일 보기

@ -9,6 +9,7 @@ import lombok.*;
public class IndicatorStatusResponse {
private String columnName;
private String fieldName;
private String categoryCode;
private String category;
private String value;
private String narrative;

파일 보기

@ -13,8 +13,7 @@ public class MethodologyHistoryResponse {
private Integer historyId;
private String changeDate;
private String changeTypeCode;
private String changeType;
private String updateTitle;
private String description;
private String collectionNote;
}

파일 보기

@ -19,5 +19,4 @@ public class RiskIndicatorResponse {
private String conditionAmber;
private String conditionGreen;
private String dataType;
private String collectionNote;
}

파일 보기

@ -0,0 +1,28 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 컴플라이언스 카테고리 마스터 엔티티 (읽기 전용)
*/
@Entity
@Table(name = "compliance_category", schema = "std_snp_data")
@Getter
@NoArgsConstructor
public class ComplianceCategory {
@Id
@Column(name = "category_code", length = 50)
private String categoryCode;
@Column(name = "indicator_type", nullable = false, length = 20)
private String indicatorType;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
}

파일 보기

@ -0,0 +1,31 @@
package com.snp.batch.global.model.screening;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 컴플라이언스 카테고리 다국어 엔티티 (읽기 전용)
*/
@Entity
@Table(name = "compliance_category_lang", schema = "std_snp_data")
@IdClass(ComplianceCategoryLangId.class)
@Getter
@NoArgsConstructor
public class ComplianceCategoryLang {
@Id
@Column(name = "category_code", length = 50)
private String categoryCode;
@Id
@Column(name = "lang_code", length = 5)
private String langCode;
@Column(name = "category_name", nullable = false, length = 200)
private String categoryName;
}

파일 보기

@ -0,0 +1,33 @@
package com.snp.batch.global.model.screening;
import java.io.Serializable;
import java.util.Objects;
/**
* 컴플라이언스 카테고리 다국어 복합 PK
*/
public class ComplianceCategoryLangId implements Serializable {
private String categoryCode;
private String langCode;
public ComplianceCategoryLangId() {
}
public ComplianceCategoryLangId(String categoryCode, String langCode) {
this.categoryCode = categoryCode;
this.langCode = langCode;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ComplianceCategoryLangId that)) return false;
return Objects.equals(categoryCode, that.categoryCode) && Objects.equals(langCode, that.langCode);
}
@Override
public int hashCode() {
return Objects.hash(categoryCode, langCode);
}
}

파일 보기

@ -27,8 +27,8 @@ public class ComplianceIndicator {
@Column(name = "indicator_type", nullable = false, length = 20)
private String indicatorType;
@Column(name = "category", nullable = false, length = 100)
private String category;
@Column(name = "category_code", nullable = false, length = 50)
private String categoryCode;
@Column(name = "field_key", nullable = false, length = 200)
private String fieldKey;
@ -36,9 +36,6 @@ public class ComplianceIndicator {
@Column(name = "data_type_code", length = 50)
private String dataTypeCode;
@Column(name = "collection_note", columnDefinition = "TEXT")
private String collectionNote;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;

파일 보기

@ -31,9 +31,6 @@ public class MethodologyHistory {
@Column(name = "change_type_code", nullable = false, length = 30)
private String changeTypeCode;
@Column(name = "collection_note", columnDefinition = "TEXT")
private String collectionNote;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
}

파일 보기

@ -26,9 +26,6 @@ public class MethodologyHistoryLang {
@Column(name = "lang_code", length = 5)
private String langCode;
@Column(name = "update_title", length = 500)
private String updateTitle;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
}

파일 보기

@ -32,9 +32,6 @@ public class RiskIndicator {
@Column(name = "data_type_code", length = 50)
private String dataTypeCode;
@Column(name = "collection_note", columnDefinition = "TEXT")
private String collectionNote;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;

파일 보기

@ -0,0 +1,14 @@
package com.snp.batch.global.repository.screening;
import com.snp.batch.global.model.screening.ComplianceCategoryLang;
import com.snp.batch.global.model.screening.ComplianceCategoryLangId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ComplianceCategoryLangRepository extends JpaRepository<ComplianceCategoryLang, ComplianceCategoryLangId> {
List<ComplianceCategoryLang> findByLangCode(String langCode);
}

파일 보기

@ -13,6 +13,7 @@ import com.snp.batch.global.model.screening.ChangeTypeLang;
import com.snp.batch.global.model.screening.CompanyDetailInfo;
import com.snp.batch.global.model.screening.ShipCountryCode;
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
import com.snp.batch.global.model.screening.ComplianceCategoryLang;
import com.snp.batch.global.model.screening.ComplianceIndicator;
import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
import com.snp.batch.global.model.screening.MethodologyHistory;
@ -24,6 +25,7 @@ import com.snp.batch.global.model.screening.ShipComplianceHistory;
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository;
import com.snp.batch.global.repository.screening.ComplianceCategoryLangRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
@ -63,6 +65,7 @@ public class ScreeningGuideService {
private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo;
private final ComplianceIndicatorRepository complianceIndicatorRepo;
private final ComplianceIndicatorLangRepository complianceIndicatorLangRepo;
private final ComplianceCategoryLangRepository complianceCategoryLangRepo;
private final MethodologyHistoryRepository methodologyHistoryRepo;
private final MethodologyHistoryLangRepository methodologyHistoryLangRepo;
private final ChangeTypeLangRepository changeTypeLangRepo;
@ -106,7 +109,6 @@ public class ScreeningGuideService {
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
.dataType(ri.getDataTypeCode())
.collectionNote(ri.getCollectionNote())
.build();
}).toList();
@ -127,6 +129,9 @@ public class ScreeningGuideService {
*/
@Transactional(readOnly = true)
public List<ComplianceCategoryResponse> getComplianceIndicators(String lang, String type) {
Map<String, String> catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream()
.collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName));
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
@ -135,9 +140,10 @@ public class ScreeningGuideService {
.collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, Function.identity()));
Map<String, List<ComplianceIndicator>> grouped = indicators.stream()
.collect(Collectors.groupingBy(ComplianceIndicator::getCategory, LinkedHashMap::new, Collectors.toList()));
.collect(Collectors.groupingBy(ComplianceIndicator::getCategoryCode, LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
String catCode = entry.getKey();
List<ComplianceIndicatorResponse> indicatorResponses = entry.getValue().stream().map(ci -> {
ComplianceIndicatorLang langData = langMap.get(ci.getIndicatorId());
return ComplianceIndicatorResponse.builder()
@ -149,12 +155,12 @@ public class ScreeningGuideService {
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
.dataType(ci.getDataTypeCode())
.collectionNote(ci.getCollectionNote())
.build();
}).toList();
return ComplianceCategoryResponse.builder()
.category(entry.getKey())
.categoryCode(catCode)
.categoryName(catNameMap.getOrDefault(catCode, catCode))
.indicatorType(type)
.indicators(indicatorResponses)
.build();
@ -177,19 +183,42 @@ public class ScreeningGuideService {
List<MethodologyHistory> histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc();
return histories.stream().map(mh -> {
return histories.stream()
.filter(mh -> !"BANNER".equals(mh.getChangeTypeCode()))
.map(mh -> {
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
return MethodologyHistoryResponse.builder()
.historyId(mh.getHistoryId())
.changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "")
.changeTypeCode(mh.getChangeTypeCode())
.changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode()))
.updateTitle(langData != null ? langData.getUpdateTitle() : "")
.description(langData != null ? langData.getDescription() : "")
.collectionNote(mh.getCollectionNote())
.build();
}).toList();
}
/**
* 방법론 배너 텍스트 조회
*/
@Transactional(readOnly = true)
public MethodologyHistoryResponse getMethodologyBanner(String lang) {
Map<Integer, MethodologyHistoryLang> langMap = methodologyHistoryLangRepo.findByLangCode(lang).stream()
.collect(Collectors.toMap(MethodologyHistoryLang::getHistoryId, Function.identity()));
return methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc().stream()
.filter(mh -> "BANNER".equals(mh.getChangeTypeCode()))
.findFirst()
.map(mh -> {
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
return MethodologyHistoryResponse.builder()
.historyId(mh.getHistoryId())
.changeTypeCode(mh.getChangeTypeCode())
.description(langData != null ? langData.getDescription() : "")
.build();
})
.orElse(null);
}
/**
* 선박 위험지표 변경 이력 조회
*
@ -336,6 +365,7 @@ public class ScreeningGuideService {
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
Map<String, String> categoryMap = getRiskCategoryMap(lang);
Map<String, String> categoryCodeMap = getRiskCategoryCodeMap();
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
@ -351,6 +381,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.narrative(descVal != null ? descVal.toString() : null)
@ -371,7 +402,8 @@ public class ScreeningGuideService {
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP");
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP", lang);
Map<String, String> categoryCodeMap = getComplianceCategoryCodeMap("SHIP");
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
@ -385,6 +417,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue())
@ -404,7 +437,8 @@ public class ScreeningGuideService {
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY");
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY", lang);
Map<String, String> categoryCodeMap = getComplianceCategoryCodeMap("COMPANY");
try {
Map<String, Object> row = jdbcTemplate.queryForMap(
@ -418,6 +452,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder()
.columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue())
@ -462,6 +497,15 @@ public class ScreeningGuideService {
(a, b) -> a));
}
private Map<String, String> getRiskCategoryCodeMap() {
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null)
.collect(Collectors.toMap(
RiskIndicator::getColumnName,
RiskIndicator::getCategoryCode,
(a, b) -> a));
}
private Map<String, Integer> getRiskSortOrderMap() {
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null)
@ -488,7 +532,9 @@ public class ScreeningGuideService {
(a, b) -> a));
}
private Map<String, String> getComplianceCategoryMap(String type) {
private Map<String, String> getComplianceCategoryMap(String type, String lang) {
Map<String, String> catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream()
.collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName));
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
@ -496,7 +542,19 @@ public class ScreeningGuideService {
.filter(ci -> ci.getColumnName() != null)
.collect(Collectors.toMap(
ComplianceIndicator::getColumnName,
ComplianceIndicator::getCategory,
ci -> catNameMap.getOrDefault(ci.getCategoryCode(), ci.getCategoryCode()),
(a, b) -> a));
}
private Map<String, String> getComplianceCategoryCodeMap(String type) {
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
return indicators.stream()
.filter(ci -> ci.getColumnName() != null)
.collect(Collectors.toMap(
ComplianceIndicator::getColumnName,
ComplianceIndicator::getCategoryCode,
(a, b) -> a));
}