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] ## [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) - S&P Bypass 피드백 반영 (#123)
- Response JSON 원본 반환 (ApiResponse 래핑 제거) - Response JSON 원본 반환 (ApiResponse 래핑 제거)
- 사용자용 API 카탈로그 페이지 추가 (/bypass-catalog) - 사용자용 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; conditionAmber: string;
conditionGreen: string; conditionGreen: string;
dataType: string; dataType: string;
collectionNote: string;
} }
export interface RiskCategoryResponse { export interface RiskCategoryResponse {
@ -34,11 +33,11 @@ export interface ComplianceIndicatorResponse {
conditionAmber: string; conditionAmber: string;
conditionGreen: string; conditionGreen: string;
dataType: string; dataType: string;
collectionNote: string;
} }
export interface ComplianceCategoryResponse { export interface ComplianceCategoryResponse {
category: string; categoryCode: string;
categoryName: string;
indicatorType: string; indicatorType: string;
indicators: ComplianceIndicatorResponse[]; indicators: ComplianceIndicatorResponse[];
} }
@ -47,10 +46,9 @@ export interface ComplianceCategoryResponse {
export interface MethodologyHistoryResponse { export interface MethodologyHistoryResponse {
historyId: number; historyId: number;
changeDate: string; changeDate: string;
changeTypeCode: string;
changeType: string; changeType: string;
updateTitle: string;
description: string; description: string;
collectionNote: string;
} }
// 값 변경 이력 타입 // 값 변경 이력 타입
@ -108,6 +106,7 @@ export interface CompanyInfoResponse {
export interface IndicatorStatusResponse { export interface IndicatorStatusResponse {
columnName: string; columnName: string;
fieldName: string; fieldName: string;
categoryCode: string;
category: string; category: string;
value: string | null; value: string | null;
narrative: string | null; narrative: string | null;
@ -129,6 +128,8 @@ export const screeningGuideApi = {
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`), fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
getMethodologyHistory: (lang = 'KO') => getMethodologyHistory: (lang = 'KO') =>
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`), fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
getMethodologyBanner: (lang = 'KO') =>
fetchJson<ApiResponse<MethodologyHistoryResponse>>(`${BASE}/methodology-banner?lang=${lang}`),
getShipRiskHistory: (imoNo: string, lang = 'KO') => getShipRiskHistory: (imoNo: string, lang = 'KO') =>
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`), fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
getShipComplianceHistory: (imoNo: string, lang = 'KO') => 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" /> <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> </svg>
), ),
defaultPath: '/screening-guide', defaultPath: '/risk-compliance-history',
children: [ children: [
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
{ id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' }, { 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> </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) => ( {MENU_STRUCTURE.map((section) => (
<button <button
key={section.id} key={section.id}
@ -132,7 +132,7 @@ export default function Navbar() {
{/* 2단: 서브 탭 */} {/* 2단: 서브 탭 */}
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md"> <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) => ( {currentSection?.children.map((child) => (
<button <button
key={child.id} key={child.id}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { import {
screeningGuideApi, screeningGuideApi,
type ComplianceCategoryResponse, type ComplianceCategoryResponse,
@ -9,135 +9,105 @@ interface ComplianceTabProps {
lang: string; lang: string;
} }
type ViewMode = 'table' | 'card';
type IndicatorType = 'SHIP' | 'COMPANY'; type IndicatorType = 'SHIP' | 'COMPANY';
type CacheKey = string;
const SHIP_CAT_COLORS: Record<string, string> = { const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
'Sanctions Ship (US OFAC)': '#1e3a5f', // SHIP
'Sanctions Ownership (US OFAC)': '#1d4ed8', 'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
'Sanctions Ship (Non-US)': '#065f46', 'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
'Sanctions Ownership (Non-US)': '#0f766e', 'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
'Sanctions FATF': '#6b21a8', 'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
'Sanctions Other': '#991b1b', 'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
'Port Calls': '#065f46', 'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
'STS Activity': '#0f766e', 'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
'Dark Activity': '#374151', '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> = { function getBadgeColor(categoryCode: string): { bg: string; text: string } {
'Company Sanctions (US OFAC)': '#1e3a5f', return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
'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;
} }
export default function ComplianceTab({ lang }: ComplianceTabProps) { export default function ComplianceTab({ lang }: ComplianceTabProps) {
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]); const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP'); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setSelectedCategory('전체'); setExpandedCategories(new Set());
screeningGuideApi cache.current.clear();
.getComplianceIndicators(lang, indicatorType)
.then((res) => setCategories(res.data ?? [])) Promise.all([
fetchData('KO', indicatorType),
fetchData('EN', indicatorType),
])
.then(() => {
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
})
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [indicatorType, fetchData]);
useEffect(() => {
const cached = cache.current.get(`${indicatorType}_${lang}`);
if (cached) {
setCategories(cached);
}
}, [lang, indicatorType]); }, [lang, indicatorType]);
const flatRows: FlatRow[] = categories.flatMap((cat) => const toggleCategory = (category: string) => {
cat.indicators.map((ind) => ({ setExpandedCategories((prev) => {
category: cat.category, const next = new Set(prev);
indicatorType: cat.indicatorType, if (next.has(category)) {
indicator: ind, next.delete(category);
})), } else {
); next.add(category);
}
const filtered: FlatRow[] = return next;
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();
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* SHIP / COMPANY 토글 */} {/* Ship / Company 토글 */}
<div className="flex gap-2"> <div className="flex gap-2">
<button {(['SHIP', 'COMPANY'] as const).map((type) => (
onClick={() => setIndicatorType('SHIP')} <button
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${ key={type}
indicatorType === 'SHIP' onClick={() => setIndicatorType(type)}
? 'bg-slate-900 text-white border-slate-900 shadow-md' className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text' 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 {type === 'SHIP' ? 'Ship' : 'Company'}
onClick={() => setIndicatorType('COMPANY')} </button>
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)
</button>
</div> </div>
{loading && ( {loading && (
@ -156,229 +126,100 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) {
)} )}
{!loading && !error && ( {!loading && !error && (
<> <div className="space-y-2">
{/* 카테고리 요약 카드 */} {categories.map((cat) => {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> const isExpanded = expandedCategories.has(cat.categoryCode);
{uniqueCategories.map((catName) => { const badge = getBadgeColor(cat.categoryCode);
const count = flatRows.filter((r) => r.category === catName).length; return (
const isActive = selectedCategory === catName; <div
const hex = getCatHex(catName, indicatorType); key={cat.categoryCode}
return ( className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
>
{/* 아코디언 헤더 */}
<button <button
key={catName} onClick={() => toggleCategory(cat.categoryCode)}
onClick={() => className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
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 <span
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`} className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
style={{ background: badge.bg, color: badge.text }}
> >
{count} {cat.categoryName}
</div> </span>
<div <span className="text-sm font-semibold text-wing-text text-left flex-1">
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`} {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"
> >
{catName} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</div> </svg>
</button> </button>
);
})}
</div>
{/* 컨트롤 바 */} {/* 아코디언 콘텐츠 */}
<div className="flex items-center gap-3 flex-wrap"> {isExpanded && (
<button <div className="px-5 pt-5 pb-5 border-t border-wing-border">
onClick={() => setSelectedCategory('전체')} <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${ {cat.indicators.map((ind) => (
selectedCategory === '전체' <IndicatorCard key={ind.indicatorId} indicator={ind} />
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
({flatRows.length})
</button>
<div className="flex-1" />
<span className="text-xs text-wing-muted">
:{' '}
<strong className="text-wing-text">{filtered.length}</strong>
</span>
<button
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
>
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
</button>
<button
onClick={downloadCSV}
className="px-4 py-1.5 rounded-lg bg-green-600 text-white text-xs font-bold hover:bg-green-700 transition-colors"
>
CSV
</button>
</div>
{/* 테이블 뷰 */}
{viewMode === 'table' && (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 text-white">
{[
'카테고리',
'필드명',
'설명',
'🔴 RED',
'🟡 AMBER',
'🟢 GREEN',
'데이터 타입',
'이력 관리 참고',
].map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
>
{h}
</th>
))} ))}
</tr>
</thead>
<tbody>
{filtered.map((row, i) => {
const showCat =
i === 0 ||
filtered[i - 1].category !== row.category;
const hex = getCatHex(row.category, 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 }}
>
{row.category}
</span>
)}
</td>
<td className="px-3 py-2.5 min-w-[160px]">
<div className="font-bold text-wing-text">
{row.indicator.fieldName}
</div>
<div className="text-wing-muted text-[10px] mt-0.5">
{row.indicator.fieldKey}
</div>
</td>
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
{row.indicator.description}
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
{row.indicator.conditionRed}
</div>
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
{row.indicator.conditionAmber}
</div>
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
{row.indicator.conditionGreen}
</div>
</td>
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
{row.indicator.dataType}
</td>
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
{row.indicator.collectionNote &&
`💡 ${row.indicator.collectionNote}`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* 카드 뷰 */}
{viewMode === 'card' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filtered.map((row, i) => {
const hex = getCatHex(row.category, 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>
</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>
); )}
})} </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> </div>
); );

파일 보기

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

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi'; import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
interface MethodologyTabProps { interface MethodologyTabProps {
@ -18,20 +18,60 @@ function getChangeTypeColor(changeType: string): string {
return CHANGE_TYPE_COLORS[changeType] ?? '#374151'; return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
} }
type LangKey = 'KO' | 'EN';
interface LangCache {
history: MethodologyHistoryResponse[];
banner: string;
}
export default function MethodologyTab({ lang }: MethodologyTabProps) { export default function MethodologyTab({ lang }: MethodologyTabProps) {
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]); const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
const [banner, setBanner] = useState<string>('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState('전체'); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
screeningGuideApi cache.current.clear();
.getMethodologyHistory(lang)
.then((res) => setHistory(res.data ?? [])) 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)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [fetchData]);
// 언어 변경: 캐시에서 스위칭
useEffect(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setHistory(cached.history);
setBanner(cached.banner);
}
}, [lang]); }, [lang]);
const sortedHistory = [...history].sort((a, b) => const sortedHistory = [...history].sort((a, b) =>
@ -67,11 +107,11 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 주의사항 배너 */} {/* 주의사항 배너 */}
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed"> {banner && (
<strong> :</strong> · <div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
. , {banner}
. </div>
</div> )}
{/* 변경 유형 필터 */} {/* 변경 유형 필터 */}
<div className="flex gap-2 flex-wrap"> <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"> <table className="w-full text-xs border-collapse">
<thead> <thead>
<tr className="bg-slate-900 text-white"> <tr className="bg-slate-900 text-white">
{['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => ( {['날짜', '변경 유형', '설명'].map((h) => (
<th <th
key={h} key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap" className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
@ -147,14 +187,8 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
{row.changeType} {row.changeType}
</span> </span>
</td> </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"> <td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
{row.description} {row.description || '-'}
</td>
<td className="px-3 py-3 min-w-[200px] text-blue-600 leading-relaxed">
{row.collectionNote && `💡 ${row.collectionNote}`}
</td> </td>
</tr> </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'; import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
interface RiskTabProps { interface RiskTabProps {
lang: string; lang: string;
} }
type ViewMode = 'table' | 'card'; type LangKey = 'KO' | 'EN';
const CAT_COLORS: Record<string, string> = { const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
'AIS': 'bg-blue-800', 'AIS': { bg: '#dbeafe', text: '#1e40af' },
'Port Calls': 'bg-emerald-800', 'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
'Associated with Russia': 'bg-red-800', 'ASSOCIATED_WITH_RUSSIA': { bg: '#fee2e2', text: '#991b1b' },
'Behavioural Risk': 'bg-amber-800', 'BEHAVIOURAL_RISK': { bg: '#fef3c7', text: '#92400e' },
'Safety, Security & Inspections': 'bg-blue-600', 'SAFETY_SECURITY_AND_INSPECTIONS': { bg: '#dbeafe', text: '#1d4ed8' },
'Flag Risk': 'bg-purple-800', 'FLAG_RISK': { bg: '#ede9fe', text: '#6b21a8' },
'Owner & Classification': 'bg-teal-700', 'OWNER_AND_CLASSIFICATION': { bg: '#ccfbf1', text: '#0f766e' },
}; };
const CAT_HEX: Record<string, string> = { function getBadgeColor(categoryCode: string): { bg: string; text: string } {
'AIS': '#1e40af', return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
'Port Calls': '#065f46',
'Associated with Russia': '#991b1b',
'Behavioural Risk': '#92400e',
'Safety, Security & Inspections': '#1d4ed8',
'Flag Risk': '#6b21a8',
'Owner & Classification': '#0f766e',
};
function getCatColor(categoryName: string): string {
return CAT_COLORS[categoryName] ?? 'bg-slate-700';
}
function getCatHex(categoryName: string): string {
return CAT_HEX[categoryName] ?? '#374151';
}
interface FlatRow {
category: string;
indicator: RiskIndicatorResponse;
} }
export default function RiskTab({ lang }: RiskTabProps) { export default function RiskTab({ lang }: RiskTabProps) {
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]); const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState('전체'); const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<ViewMode>('table'); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
screeningGuideApi cache.current.clear();
.getRiskIndicators(lang)
.then((res) => setCategories(res.data ?? [])) Promise.all([fetchData('KO'), fetchData('EN')])
.then(() => {
setCategories(cache.current.get(lang as LangKey) ?? []);
})
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [fetchData]);
useEffect(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setCategories(cached);
}
}, [lang]); }, [lang]);
const flatRows: FlatRow[] = categories.flatMap((cat) => const toggleCategory = (code: string) => {
cat.indicators.map((ind) => ({ category: cat.categoryName, indicator: ind })), setExpandedCategories((prev) => {
); const next = new Set(prev);
if (next.has(code)) {
const filtered: FlatRow[] = next.delete(code);
selectedCategory === '전체' } else {
? flatRows next.add(code);
: flatRows.filter((r) => r.category === selectedCategory); }
return next;
function downloadCSV() { });
const bom = '\uFEFF'; };
const headers = [
'카테고리',
'필드키',
'필드명',
'설명',
'RED 조건',
'AMBER 조건',
'GREEN 조건',
'데이터 타입',
'이력 관리 참고사항',
];
const rows = flatRows.map((r) =>
[
r.category,
r.indicator.fieldKey,
r.indicator.fieldName,
r.indicator.description,
r.indicator.conditionRed,
r.indicator.conditionAmber,
r.indicator.conditionGreen,
r.indicator.dataType,
r.indicator.collectionNote,
]
.map((v) => `"${(v ?? '').replace(/"/g, '""')}"`)
.join(','),
);
const csv = bom + [headers.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'MIRS_Risk_Indicators.csv';
a.click();
}
if (loading) { if (loading) {
return ( return (
@ -122,226 +90,95 @@ export default function RiskTab({ lang }: RiskTabProps) {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-2">
{/* 카테고리 요약 카드 */} {categories.map((cat) => {
<div className="grid grid-cols-4 gap-3 lg:grid-cols-7"> const isExpanded = expandedCategories.has(cat.categoryCode);
{categories.map((cat) => { const badge = getBadgeColor(cat.categoryCode);
const isActive = selectedCategory === cat.categoryName; return (
const hex = getCatHex(cat.categoryName); <div
return ( key={cat.categoryCode}
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
>
<button <button
key={cat.categoryCode} onClick={() => toggleCategory(cat.categoryCode)}
onClick={() => className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
setSelectedCategory(isActive ? '전체' : cat.categoryName)
}
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2"
style={{
background: isActive ? hex : undefined,
borderColor: isActive ? hex : undefined,
}}
> >
<div <span
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`} className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
> style={{ background: badge.bg, color: badge.text }}
{cat.indicators.length}
</div>
<div
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
> >
{cat.categoryName} {cat.categoryName}
</div> </span>
<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> </button>
);
})}
</div>
{/* 컨트롤 바 */} {isExpanded && (
<div className="flex items-center gap-3 flex-wrap"> <div className="px-5 pt-5 pb-5 border-t border-wing-border">
<button <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
onClick={() => setSelectedCategory('전체')} {cat.indicators.map((ind) => (
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${ <IndicatorCard key={ind.indicatorId} indicator={ind} />
selectedCategory === '전체'
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
({flatRows.length})
</button>
<div className="flex-1" />
<span className="text-xs text-wing-muted">
:{' '}
<strong className="text-wing-text">{filtered.length}</strong>
</span>
<button
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
>
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
</button>
<button
onClick={downloadCSV}
className="px-4 py-1.5 rounded-lg bg-green-600 text-white text-xs font-bold hover:bg-green-700 transition-colors"
>
CSV
</button>
</div>
{/* 테이블 뷰 */}
{viewMode === 'table' && (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 text-white">
{[
'카테고리',
'필드명',
'설명',
'🔴 RED',
'🟡 AMBER',
'🟢 GREEN',
'데이터 타입',
'이력 관리 참고',
].map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
>
{h}
</th>
))} ))}
</tr> </div>
</thead> </div>
<tbody> )}
{filtered.map((row, i) => {
const showCat =
i === 0 ||
filtered[i - 1].category !== row.category;
const hex = getCatHex(row.category);
return (
<tr
key={`${row.indicator.indicatorId}-${i}`}
className="border-b border-wing-border align-top even:bg-wing-card"
>
<td className="px-3 py-2.5 min-w-[110px]">
{showCat && (
<span
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
style={{ background: hex }}
>
{row.category}
</span>
)}
</td>
<td className="px-3 py-2.5 min-w-[160px]">
<div className="font-bold text-wing-text">
{row.indicator.fieldName}
</div>
<div className="text-wing-muted text-[10px] mt-0.5">
{row.indicator.fieldKey}
</div>
</td>
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
{row.indicator.description}
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
{row.indicator.conditionRed}
</div>
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
{row.indicator.conditionAmber}
</div>
</td>
<td className="px-3 py-2.5 min-w-[130px]">
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
{row.indicator.conditionGreen}
</div>
</td>
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
{row.indicator.dataType}
</td>
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
{row.indicator.collectionNote &&
`💡 ${row.indicator.collectionNote}`}
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
);
})}
</div>
);
}
function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) {
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">
{indicator.description}
</div> </div>
)} )}
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
{/* 카드 뷰 */} <div className="flex gap-2">
{viewMode === 'card' && ( {indicator.conditionRed && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
{filtered.map((row, i) => { <div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
const hex = getCatHex(row.category); <div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
const colorClass = getCatColor(row.category); </div>
return ( )}
<div {indicator.conditionAmber && (
key={`${row.indicator.indicatorId}-${i}`} <div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
className="bg-wing-surface rounded-xl shadow-md overflow-hidden" <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 </div>
className={`${colorClass} px-4 py-2.5 flex justify-between items-center`} )}
style={{ background: hex }} {indicator.conditionGreen && (
> <div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
<span className="text-white text-[10px] font-bold"> <div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
{row.category} <div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
</span> </div>
<span className="text-white/75 text-[10px]"> )}
{row.indicator.dataType}
</span>
</div>
<div className="p-4">
<div className="font-bold text-sm text-wing-text mb-0.5">
{row.indicator.fieldName}
</div>
<div className="text-[10px] text-wing-muted mb-3">
{row.indicator.fieldKey}
</div>
<div className="text-xs text-wing-text leading-relaxed mb-3">
{row.indicator.description}
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-red-50 rounded-lg p-2">
<div className="text-[10px] font-bold text-red-800 mb-1">
🔴 RED
</div>
<div className="text-[11px] text-red-800">
{row.indicator.conditionRed}
</div>
</div>
<div className="bg-amber-50 rounded-lg p-2">
<div className="text-[10px] font-bold text-amber-800 mb-1">
🟡 AMBER
</div>
<div className="text-[11px] text-amber-800">
{row.indicator.conditionAmber}
</div>
</div>
<div className="bg-green-50 rounded-lg p-2">
<div className="text-[10px] font-bold text-green-800 mb-1">
🟢 GREEN
</div>
<div className="text-[11px] text-green-800">
{row.indicator.conditionGreen}
</div>
</div>
</div>
{row.indicator.collectionNote && (
<div className="bg-blue-50 rounded-lg p-3 text-[11px] text-blue-700 leading-relaxed">
💡 {row.indicator.collectionNote}
</div>
)}
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -19,6 +19,12 @@
--wing-hover: rgba(255, 255, 255, 0.05); --wing-hover: rgba(255, 255, 255, 0.05);
--wing-input-bg: #0f172a; --wing-input-bg: #0f172a;
--wing-input-border: #334155; --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 */ /* Light theme */
@ -41,6 +47,12 @@
--wing-hover: rgba(0, 0, 0, 0.04); --wing-hover: rgba(0, 0, 0, 0.04);
--wing-input-bg: #ffffff; --wing-input-bg: #ffffff;
--wing-input-border: #cbd5e1; --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 { @theme {
@ -62,5 +74,11 @@
--color-wing-hover: var(--wing-hover); --color-wing-hover: var(--wing-hover);
--color-wing-input-bg: var(--wing-input-bg); --color-wing-input-bg: var(--wing-input-bg);
--color-wing-input-border: var(--wing-input-border); --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; --font-sans: 'Noto Sans KR', sans-serif;
} }

파일 보기

@ -60,6 +60,14 @@ public class ScreeningGuideController {
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang))); 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 번호로 선박 위험지표 값 변경 이력을 조회합니다.") @Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
@GetMapping("/history/ship-risk") @GetMapping("/history/ship-risk")
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory( public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -19,5 +19,4 @@ public class RiskIndicatorResponse {
private String conditionAmber; private String conditionAmber;
private String conditionGreen; private String conditionGreen;
private String dataType; 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) @Column(name = "indicator_type", nullable = false, length = 20)
private String indicatorType; private String indicatorType;
@Column(name = "category", nullable = false, length = 100) @Column(name = "category_code", nullable = false, length = 50)
private String category; private String categoryCode;
@Column(name = "field_key", nullable = false, length = 200) @Column(name = "field_key", nullable = false, length = 200)
private String fieldKey; private String fieldKey;
@ -36,9 +36,6 @@ public class ComplianceIndicator {
@Column(name = "data_type_code", length = 50) @Column(name = "data_type_code", length = 50)
private String dataTypeCode; private String dataTypeCode;
@Column(name = "collection_note", columnDefinition = "TEXT")
private String collectionNote;
@Column(name = "sort_order", nullable = false) @Column(name = "sort_order", nullable = false)
private Integer sortOrder; private Integer sortOrder;

파일 보기

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

파일 보기

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

파일 보기

@ -32,9 +32,6 @@ public class RiskIndicator {
@Column(name = "data_type_code", length = 50) @Column(name = "data_type_code", length = 50)
private String dataTypeCode; private String dataTypeCode;
@Column(name = "collection_note", columnDefinition = "TEXT")
private String collectionNote;
@Column(name = "sort_order", nullable = false) @Column(name = "sort_order", nullable = false)
private Integer sortOrder; 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.CompanyDetailInfo;
import com.snp.batch.global.model.screening.ShipCountryCode; import com.snp.batch.global.model.screening.ShipCountryCode;
import com.snp.batch.global.model.screening.CompanyComplianceHistory; 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.ComplianceIndicator;
import com.snp.batch.global.model.screening.ComplianceIndicatorLang; import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
import com.snp.batch.global.model.screening.MethodologyHistory; 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.ChangeTypeLangRepository;
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository; import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository; 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.ComplianceIndicatorLangRepository;
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository; import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository; import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
@ -63,6 +65,7 @@ public class ScreeningGuideService {
private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo; private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo;
private final ComplianceIndicatorRepository complianceIndicatorRepo; private final ComplianceIndicatorRepository complianceIndicatorRepo;
private final ComplianceIndicatorLangRepository complianceIndicatorLangRepo; private final ComplianceIndicatorLangRepository complianceIndicatorLangRepo;
private final ComplianceCategoryLangRepository complianceCategoryLangRepo;
private final MethodologyHistoryRepository methodologyHistoryRepo; private final MethodologyHistoryRepository methodologyHistoryRepo;
private final MethodologyHistoryLangRepository methodologyHistoryLangRepo; private final MethodologyHistoryLangRepository methodologyHistoryLangRepo;
private final ChangeTypeLangRepository changeTypeLangRepo; private final ChangeTypeLangRepository changeTypeLangRepo;
@ -106,7 +109,6 @@ public class ScreeningGuideService {
.conditionAmber(langData != null ? langData.getConditionAmber() : "") .conditionAmber(langData != null ? langData.getConditionAmber() : "")
.conditionGreen(langData != null ? langData.getConditionGreen() : "") .conditionGreen(langData != null ? langData.getConditionGreen() : "")
.dataType(ri.getDataTypeCode()) .dataType(ri.getDataTypeCode())
.collectionNote(ri.getCollectionNote())
.build(); .build();
}).toList(); }).toList();
@ -127,6 +129,9 @@ public class ScreeningGuideService {
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ComplianceCategoryResponse> getComplianceIndicators(String lang, String type) { 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()) List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type) ? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc(); : complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
@ -135,9 +140,10 @@ public class ScreeningGuideService {
.collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, Function.identity())); .collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, Function.identity()));
Map<String, List<ComplianceIndicator>> grouped = indicators.stream() 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 -> { return grouped.entrySet().stream().map(entry -> {
String catCode = entry.getKey();
List<ComplianceIndicatorResponse> indicatorResponses = entry.getValue().stream().map(ci -> { List<ComplianceIndicatorResponse> indicatorResponses = entry.getValue().stream().map(ci -> {
ComplianceIndicatorLang langData = langMap.get(ci.getIndicatorId()); ComplianceIndicatorLang langData = langMap.get(ci.getIndicatorId());
return ComplianceIndicatorResponse.builder() return ComplianceIndicatorResponse.builder()
@ -149,12 +155,12 @@ public class ScreeningGuideService {
.conditionAmber(langData != null ? langData.getConditionAmber() : "") .conditionAmber(langData != null ? langData.getConditionAmber() : "")
.conditionGreen(langData != null ? langData.getConditionGreen() : "") .conditionGreen(langData != null ? langData.getConditionGreen() : "")
.dataType(ci.getDataTypeCode()) .dataType(ci.getDataTypeCode())
.collectionNote(ci.getCollectionNote())
.build(); .build();
}).toList(); }).toList();
return ComplianceCategoryResponse.builder() return ComplianceCategoryResponse.builder()
.category(entry.getKey()) .categoryCode(catCode)
.categoryName(catNameMap.getOrDefault(catCode, catCode))
.indicatorType(type) .indicatorType(type)
.indicators(indicatorResponses) .indicators(indicatorResponses)
.build(); .build();
@ -177,17 +183,40 @@ public class ScreeningGuideService {
List<MethodologyHistory> histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc(); List<MethodologyHistory> histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc();
return histories.stream().map(mh -> { return histories.stream()
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId()); .filter(mh -> !"BANNER".equals(mh.getChangeTypeCode()))
return MethodologyHistoryResponse.builder() .map(mh -> {
.historyId(mh.getHistoryId()) MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
.changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "") return MethodologyHistoryResponse.builder()
.changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode())) .historyId(mh.getHistoryId())
.updateTitle(langData != null ? langData.getUpdateTitle() : "") .changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "")
.description(langData != null ? langData.getDescription() : "") .changeTypeCode(mh.getChangeTypeCode())
.collectionNote(mh.getCollectionNote()) .changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode()))
.build(); .description(langData != null ? langData.getDescription() : "")
}).toList(); .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, String> fieldNameMap = getRiskFieldNameMap(lang);
Map<String, Integer> sortOrderMap = getRiskSortOrderMap(); Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
Map<String, String> categoryMap = getRiskCategoryMap(lang); Map<String, String> categoryMap = getRiskCategoryMap(lang);
Map<String, String> categoryCodeMap = getRiskCategoryCodeMap();
try { try {
Map<String, Object> row = jdbcTemplate.queryForMap( Map<String, Object> row = jdbcTemplate.queryForMap(
@ -351,6 +381,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder() result.add(IndicatorStatusResponse.builder()
.columnName(colName) .columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName)) .fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, "")) .category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null) .value(codeVal != null ? codeVal.toString() : null)
.narrative(descVal != null ? descVal.toString() : null) .narrative(descVal != null ? descVal.toString() : null)
@ -371,7 +402,8 @@ public class ScreeningGuideService {
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) { public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP"); Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("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 { try {
Map<String, Object> row = jdbcTemplate.queryForMap( Map<String, Object> row = jdbcTemplate.queryForMap(
@ -385,6 +417,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder() result.add(IndicatorStatusResponse.builder()
.columnName(colName) .columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName)) .fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, "")) .category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null) .value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue()) .sortOrder(entry.getValue())
@ -404,7 +437,8 @@ public class ScreeningGuideService {
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) { public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY"); Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("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 { try {
Map<String, Object> row = jdbcTemplate.queryForMap( Map<String, Object> row = jdbcTemplate.queryForMap(
@ -418,6 +452,7 @@ public class ScreeningGuideService {
result.add(IndicatorStatusResponse.builder() result.add(IndicatorStatusResponse.builder()
.columnName(colName) .columnName(colName)
.fieldName(fieldNameMap.getOrDefault(colName, colName)) .fieldName(fieldNameMap.getOrDefault(colName, colName))
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
.category(categoryMap.getOrDefault(colName, "")) .category(categoryMap.getOrDefault(colName, ""))
.value(codeVal != null ? codeVal.toString() : null) .value(codeVal != null ? codeVal.toString() : null)
.sortOrder(entry.getValue()) .sortOrder(entry.getValue())
@ -462,6 +497,15 @@ public class ScreeningGuideService {
(a, b) -> a)); (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() { private Map<String, Integer> getRiskSortOrderMap() {
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
.filter(ri -> ri.getColumnName() != null) .filter(ri -> ri.getColumnName() != null)
@ -488,7 +532,9 @@ public class ScreeningGuideService {
(a, b) -> a)); (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()) List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type) ? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc(); : complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
@ -496,7 +542,19 @@ public class ScreeningGuideService {
.filter(ci -> ci.getColumnName() != null) .filter(ci -> ci.getColumnName() != null)
.collect(Collectors.toMap( .collect(Collectors.toMap(
ComplianceIndicator::getColumnName, 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)); (a, b) -> a));
} }