feat(screening): Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124) #131
@ -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)
|
||||
|
||||
111
docs/compliance_category_migration.sql
Normal file
111
docs/compliance_category_migration.sql
Normal file
@ -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));
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user