From e3465401a2632876baee82b4b1f024f16062b4f3 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 1 Apr 2026 16:52:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(screening):=20Risk=20&=20Compliance=20?= =?UTF-8?q?Screening=20Guide=20UI=20=EA=B0=9C=ED=8E=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=EC=96=B8=EC=96=B4=20=EC=A7=80=EC=9B=90=20(#1?= =?UTF-8?q?24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/compliance_category_migration.sql | 111 ++++ frontend/src/api/screeningGuideApi.ts | 11 +- frontend/src/components/Navbar.tsx | 8 +- .../components/screening/ComplianceTab.tsx | 483 ++++++------------ .../src/components/screening/HistoryTab.tsx | 47 +- .../components/screening/MethodologyTab.tsx | 68 ++- frontend/src/components/screening/RiskTab.tsx | 417 +++++---------- frontend/src/pages/MainMenu.tsx | 2 +- frontend/src/pages/RiskComplianceHistory.tsx | 56 +- frontend/src/pages/ScreeningGuide.tsx | 125 ++--- frontend/src/theme/tokens.css | 18 + .../controller/ScreeningGuideController.java | 8 + .../screening/ComplianceCategoryResponse.java | 3 +- .../ComplianceIndicatorResponse.java | 1 - .../screening/IndicatorStatusResponse.java | 1 + .../screening/MethodologyHistoryResponse.java | 3 +- .../dto/screening/RiskIndicatorResponse.java | 1 - .../model/screening/ComplianceCategory.java | 28 + .../screening/ComplianceCategoryLang.java | 31 ++ .../screening/ComplianceCategoryLangId.java | 33 ++ .../model/screening/ComplianceIndicator.java | 7 +- .../model/screening/MethodologyHistory.java | 3 - .../screening/MethodologyHistoryLang.java | 3 - .../global/model/screening/RiskIndicator.java | 3 - .../ComplianceCategoryLangRepository.java | 14 + .../batch/service/ScreeningGuideService.java | 96 +++- 26 files changed, 772 insertions(+), 809 deletions(-) create mode 100644 docs/compliance_category_migration.sql create mode 100644 src/main/java/com/snp/batch/global/model/screening/ComplianceCategory.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLang.java create mode 100644 src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLangId.java create mode 100644 src/main/java/com/snp/batch/global/repository/screening/ComplianceCategoryLangRepository.java diff --git a/docs/compliance_category_migration.sql b/docs/compliance_category_migration.sql new file mode 100644 index 0000000..9dc7586 --- /dev/null +++ b/docs/compliance_category_migration.sql @@ -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; diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts index 4be75d1..fe42291 100644 --- a/frontend/src/api/screeningGuideApi.ts +++ b/frontend/src/api/screeningGuideApi.ts @@ -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>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`), getMethodologyHistory: (lang = 'KO') => fetchJson>(`${BASE}/methodology-history?lang=${lang}`), + getMethodologyBanner: (lang = 'KO') => + fetchJson>(`${BASE}/methodology-banner?lang=${lang}`), getShipRiskHistory: (imoNo: string, lang = 'KO') => fetchJson>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`), getShipComplianceHistory: (imoNo: string, lang = 'KO') => diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ee7a070..1c2adf2 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -60,10 +60,10 @@ const MENU_STRUCTURE: MenuSection[] = [ ), - 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() { > ← -
+
{MENU_STRUCTURE.map((section) => ( - + {(['SHIP', 'COMPANY'] as const).map((type) => ( + + ))}
{loading && ( @@ -156,229 +126,100 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) { )} {!loading && !error && ( - <> - {/* 카테고리 요약 카드 */} -
- {uniqueCategories.map((catName) => { - const count = flatRows.filter((r) => r.category === catName).length; - const isActive = selectedCategory === catName; - const hex = getCatHex(catName, indicatorType); - return ( +
+ {categories.map((cat) => { + const isExpanded = expandedCategories.has(cat.categoryCode); + const badge = getBadgeColor(cat.categoryCode); + return ( +
+ {/* 아코디언 헤더 */} - ); - })} -
- {/* 컨트롤 바 */} -
- -
- - 표시:{' '} - {filtered.length}개 항목 - - - -
- - {/* 테이블 뷰 */} - {viewMode === 'table' && ( -
-
- - - - {[ - '카테고리', - '필드명', - '설명', - '🔴 RED', - '🟡 AMBER', - '🟢 GREEN', - '데이터 타입', - '이력 관리 참고', - ].map((h) => ( - + {/* 아코디언 콘텐츠 */} + {isExpanded && ( +
+
+ {cat.indicators.map((ind) => ( + ))} -
- - - {filtered.map((row, i) => { - const showCat = - i === 0 || - filtered[i - 1].category !== row.category; - const hex = getCatHex(row.category, indicatorType); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {showCat && ( - - {row.category} - - )} - -
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} - -
- {row.indicator.conditionRed} -
-
-
- {row.indicator.conditionAmber} -
-
-
- {row.indicator.conditionGreen} -
-
- {row.indicator.dataType} - - {row.indicator.collectionNote && - `💡 ${row.indicator.collectionNote}`} -
-
-
- )} - - {/* 카드 뷰 */} - {viewMode === 'card' && ( -
- {filtered.map((row, i) => { - const hex = getCatHex(row.category, indicatorType); - return ( -
-
- - {row.category} - - - {row.indicator.dataType} - -
-
-
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} -
-
-
-
- 🔴 RED -
-
- {row.indicator.conditionRed} -
-
-
-
- 🟡 AMBER -
-
- {row.indicator.conditionAmber} -
-
-
-
- 🟢 GREEN -
-
- {row.indicator.conditionGreen} -
-
-
- {row.indicator.collectionNote && ( -
- 💡 {row.indicator.collectionNote} -
- )}
- ); - })} -
- )} - + )} +
+ ); + })} +
+ )} +
+ ); +} + +function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) { + return ( +
+
+
+ {indicator.fieldName} +
+ {indicator.dataType && ( + + {indicator.dataType} + + )} +
+ {indicator.description && ( +
+ {indicator.description} +
+ )} + {(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && ( +
+ {indicator.conditionRed && ( +
+
🔴
+
{indicator.conditionRed}
+
+ )} + {indicator.conditionAmber && ( +
+
🟡
+
{indicator.conditionAmber}
+
+ )} + {indicator.conditionGreen && ( +
+
🟢
+
{indicator.conditionGreen}
+
+ )} +
)}
); diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx index 0507406..dcbfbd8 100644 --- a/frontend/src/components/screening/HistoryTab.tsx +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -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 = {}; 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,21 +462,23 @@ export default function HistoryTab({ lang }: HistoryTabProps) { return (
- {/* 이력 유형 선택 */} -
- {HISTORY_TYPES.map((t) => ( - - ))} + {/* 이력 유형 선택 (언더라인 탭) */} +
+
+ {HISTORY_TYPES.map((t) => ( + + ))} +
{/* 검색 */} diff --git a/frontend/src/components/screening/MethodologyTab.tsx b/frontend/src/components/screening/MethodologyTab.tsx index fb9aa14..cca8945 100644 --- a/frontend/src/components/screening/MethodologyTab.tsx +++ b/frontend/src/components/screening/MethodologyTab.tsx @@ -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([]); + const [banner, setBanner] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedType, setSelectedType] = useState('전체'); + const cache = useRef>(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 (
{/* 주의사항 배너 */} -
- 이력 관리 주의사항: 방법론 변경은 선박·기업의 컴플라이언스 - 상태값을 자동으로 변경시킵니다. 상태 변경이 실제 리스크 변화인지, 방법론 - 업데이트 때문인지 반드시 교차 확인해야 합니다. -
+ {banner && ( +
+ {banner} +
+ )} {/* 변경 유형 필터 */}
@@ -116,7 +156,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { - {['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => ( + {['날짜', '변경 유형', '설명'].map((h) => ( - ); diff --git a/frontend/src/components/screening/RiskTab.tsx b/frontend/src/components/screening/RiskTab.tsx index 92b6e40..d8b6c13 100644 --- a/frontend/src/components/screening/RiskTab.tsx +++ b/frontend/src/components/screening/RiskTab.tsx @@ -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 = { - '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 = { + '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 = { - '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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedCategory, setSelectedCategory] = useState('전체'); - const [viewMode, setViewMode] = useState('table'); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const cache = useRef>(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 ( -
- {/* 카테고리 요약 카드 */} -
- {categories.map((cat) => { - const isActive = selectedCategory === cat.categoryName; - const hex = getCatHex(cat.categoryName); - return ( +
+ {categories.map((cat) => { + const isExpanded = expandedCategories.has(cat.categoryCode); + const badge = getBadgeColor(cat.categoryCode); + return ( +
- ); - })} -
- {/* 컨트롤 바 */} -
- -
- - 표시:{' '} - {filtered.length}개 항목 - - - -
- - {/* 테이블 뷰 */} - {viewMode === 'table' && ( -
-
-
- - {row.updateTitle} - - {row.description} - - {row.collectionNote && `💡 ${row.collectionNote}`} + {row.description || '-'}
- - - {[ - '카테고리', - '필드명', - '설명', - '🔴 RED', - '🟡 AMBER', - '🟢 GREEN', - '데이터 타입', - '이력 관리 참고', - ].map((h) => ( - + {isExpanded && ( +
+
+ {cat.indicators.map((ind) => ( + ))} -
- - - {filtered.map((row, i) => { - const showCat = - i === 0 || - filtered[i - 1].category !== row.category; - const hex = getCatHex(row.category); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {showCat && ( - - {row.category} - - )} - -
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} - -
- {row.indicator.conditionRed} -
-
-
- {row.indicator.conditionAmber} -
-
-
- {row.indicator.conditionGreen} -
-
- {row.indicator.dataType} - - {row.indicator.collectionNote && - `💡 ${row.indicator.collectionNote}`} -
+
+
+ )}
+ ); + })} +
+ ); +} + +function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) { + return ( +
+
+
+ {indicator.fieldName} +
+ {indicator.dataType && ( + + {indicator.dataType} + + )} +
+ {indicator.description && ( +
+ {indicator.description}
)} - - {/* 카드 뷰 */} - {viewMode === 'card' && ( -
- {filtered.map((row, i) => { - const hex = getCatHex(row.category); - const colorClass = getCatColor(row.category); - return ( -
-
- - {row.category} - - - {row.indicator.dataType} - -
-
-
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} -
-
-
-
- 🔴 RED -
-
- {row.indicator.conditionRed} -
-
-
-
- 🟡 AMBER -
-
- {row.indicator.conditionAmber} -
-
-
-
- 🟢 GREEN -
-
- {row.indicator.conditionGreen} -
-
-
- {row.indicator.collectionNote && ( -
- 💡 {row.indicator.collectionNote} -
- )} -
-
- ); - })} + {(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && ( +
+ {indicator.conditionRed && ( +
+
🔴
+
{indicator.conditionRed}
+
+ )} + {indicator.conditionAmber && ( +
+
🟡
+
{indicator.conditionAmber}
+
+ )} + {indicator.conditionGreen && ( +
+
🟢
+
{indicator.conditionGreen}
+
+ )}
)}
diff --git a/frontend/src/pages/MainMenu.tsx b/frontend/src/pages/MainMenu.tsx index f2f8735..f0e9fa9 100644 --- a/frontend/src/pages/MainMenu.tsx +++ b/frontend/src/pages/MainMenu.tsx @@ -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, diff --git a/frontend/src/pages/RiskComplianceHistory.tsx b/frontend/src/pages/RiskComplianceHistory.tsx index 04c900c..9f72f15 100644 --- a/frontend/src/pages/RiskComplianceHistory.tsx +++ b/frontend/src/pages/RiskComplianceHistory.tsx @@ -6,42 +6,28 @@ export default function RiskComplianceHistory() { return (
- {/* 헤더 */} -
-
- S&P Global · Maritime Intelligence Risk Suite (MIRS) + {/* 헤더 + 언어 토글 */} +
+
+

Risk & Compliance Change History

+

+ S&P 위험 지표 및 규정 준수 값 변경 이력 +

-

- Risk & Compliance Change History -

-

- 위험 지표 및 컴플라이언스 값 변경 이력 -

-
- - {/* 언어 토글 */} -
-
- - +
+ {(['EN', 'KO'] as const).map((l) => ( + + ))}
diff --git a/frontend/src/pages/ScreeningGuide.tsx b/frontend/src/pages/ScreeningGuide.tsx index 6f971dc..66b9a46 100644 --- a/frontend/src/pages/ScreeningGuide.tsx +++ b/frontend/src/pages/ScreeningGuide.tsx @@ -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 ( - - ); -} +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('risk'); - const [lang, setLang] = useState('KO'); + const [activeTab, setActiveTab] = useState('compliance'); + const [lang, setLang] = useState('EN'); return (
{/* 헤더 */} -
-
- S&P Global · Maritime Intelligence Risk Suite (MIRS) +
+
+

+ Risk & Compliance Screening Guide +

+

+ S&P Risk Indicators and Regulatory Compliance Screening Guide +

+
+ {/* 언어 토글 */} +
+ {(['EN', 'KO'] as const).map((l) => ( + + ))}
-

- Risk & Compliance Screening Guide -

-

- 위험 지표 및 컴플라이언스 심사 기준 가이드 -

- {/* 탭 + 언어 토글 */} -
-
- setActiveTab('risk')} - > - Risk Indicators - - setActiveTab('compliance')} - > - Compliance - - setActiveTab('methodology')} - > - Methodology History - -
-
- - + {/* 언더라인 탭 */} +
+
+ {TABS.map((tab) => ( + + ))}
{/* 탭 내용 */} - {activeTab === 'risk' && } {activeTab === 'compliance' && } + {activeTab === 'risk' && } {activeTab === 'methodology' && }
); diff --git a/frontend/src/theme/tokens.css b/frontend/src/theme/tokens.css index 1614ed3..5543625 100644 --- a/frontend/src/theme/tokens.css +++ b/frontend/src/theme/tokens.css @@ -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; } diff --git a/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java b/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java index df5d2b0..84647a5 100644 --- a/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java +++ b/src/main/java/com/snp/batch/global/controller/ScreeningGuideController.java @@ -60,6 +60,14 @@ public class ScreeningGuideController { return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang))); } + @Operation(summary = "방법론 배너 조회", description = "방법론 변경 이력 페이지의 안내 배너 텍스트를 조회합니다.") + @GetMapping("/methodology-banner") + public ResponseEntity> 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>> getShipRiskDetailHistory( diff --git a/src/main/java/com/snp/batch/global/dto/screening/ComplianceCategoryResponse.java b/src/main/java/com/snp/batch/global/dto/screening/ComplianceCategoryResponse.java index 26257ba..badbe7a 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/ComplianceCategoryResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/ComplianceCategoryResponse.java @@ -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 indicators; } diff --git a/src/main/java/com/snp/batch/global/dto/screening/ComplianceIndicatorResponse.java b/src/main/java/com/snp/batch/global/dto/screening/ComplianceIndicatorResponse.java index 43667fc..aebc53a 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/ComplianceIndicatorResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/ComplianceIndicatorResponse.java @@ -19,5 +19,4 @@ public class ComplianceIndicatorResponse { private String conditionAmber; private String conditionGreen; private String dataType; - private String collectionNote; } diff --git a/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java b/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java index 2fde664..6e9f9fe 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/IndicatorStatusResponse.java @@ -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; diff --git a/src/main/java/com/snp/batch/global/dto/screening/MethodologyHistoryResponse.java b/src/main/java/com/snp/batch/global/dto/screening/MethodologyHistoryResponse.java index 7849e88..d8ac3ff 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/MethodologyHistoryResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/MethodologyHistoryResponse.java @@ -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; } diff --git a/src/main/java/com/snp/batch/global/dto/screening/RiskIndicatorResponse.java b/src/main/java/com/snp/batch/global/dto/screening/RiskIndicatorResponse.java index 14bae94..adc5018 100644 --- a/src/main/java/com/snp/batch/global/dto/screening/RiskIndicatorResponse.java +++ b/src/main/java/com/snp/batch/global/dto/screening/RiskIndicatorResponse.java @@ -19,5 +19,4 @@ public class RiskIndicatorResponse { private String conditionAmber; private String conditionGreen; private String dataType; - private String collectionNote; } diff --git a/src/main/java/com/snp/batch/global/model/screening/ComplianceCategory.java b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategory.java new file mode 100644 index 0000000..7f29622 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategory.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLang.java b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLang.java new file mode 100644 index 0000000..b4f988c --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLang.java @@ -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; +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLangId.java b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLangId.java new file mode 100644 index 0000000..9eb473e --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/screening/ComplianceCategoryLangId.java @@ -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); + } +} diff --git a/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java b/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java index 6211b3b..b2a4bf7 100644 --- a/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java +++ b/src/main/java/com/snp/batch/global/model/screening/ComplianceIndicator.java @@ -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; diff --git a/src/main/java/com/snp/batch/global/model/screening/MethodologyHistory.java b/src/main/java/com/snp/batch/global/model/screening/MethodologyHistory.java index 8d51265..144e311 100644 --- a/src/main/java/com/snp/batch/global/model/screening/MethodologyHistory.java +++ b/src/main/java/com/snp/batch/global/model/screening/MethodologyHistory.java @@ -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; } diff --git a/src/main/java/com/snp/batch/global/model/screening/MethodologyHistoryLang.java b/src/main/java/com/snp/batch/global/model/screening/MethodologyHistoryLang.java index 08d77e3..614e2c4 100644 --- a/src/main/java/com/snp/batch/global/model/screening/MethodologyHistoryLang.java +++ b/src/main/java/com/snp/batch/global/model/screening/MethodologyHistoryLang.java @@ -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; } diff --git a/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java b/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java index 374ab01..19a1ff2 100644 --- a/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java +++ b/src/main/java/com/snp/batch/global/model/screening/RiskIndicator.java @@ -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; diff --git a/src/main/java/com/snp/batch/global/repository/screening/ComplianceCategoryLangRepository.java b/src/main/java/com/snp/batch/global/repository/screening/ComplianceCategoryLangRepository.java new file mode 100644 index 0000000..8576a76 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/screening/ComplianceCategoryLangRepository.java @@ -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 { + + List findByLangCode(String langCode); +} diff --git a/src/main/java/com/snp/batch/service/ScreeningGuideService.java b/src/main/java/com/snp/batch/service/ScreeningGuideService.java index 1b8f296..40009a8 100644 --- a/src/main/java/com/snp/batch/service/ScreeningGuideService.java +++ b/src/main/java/com/snp/batch/service/ScreeningGuideService.java @@ -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 getComplianceIndicators(String lang, String type) { + Map catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream() + .collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName)); + List 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> 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 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,17 +183,40 @@ public class ScreeningGuideService { List histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc(); - return histories.stream().map(mh -> { - MethodologyHistoryLang langData = langMap.get(mh.getHistoryId()); - return MethodologyHistoryResponse.builder() - .historyId(mh.getHistoryId()) - .changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "") - .changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode())) - .updateTitle(langData != null ? langData.getUpdateTitle() : "") - .description(langData != null ? langData.getDescription() : "") - .collectionNote(mh.getCollectionNote()) - .build(); - }).toList(); + 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())) + .description(langData != null ? langData.getDescription() : "") + .build(); + }).toList(); + } + + /** + * 방법론 배너 텍스트 조회 + */ + @Transactional(readOnly = true) + public MethodologyHistoryResponse getMethodologyBanner(String lang) { + Map 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 fieldNameMap = getRiskFieldNameMap(lang); Map sortOrderMap = getRiskSortOrderMap(); Map categoryMap = getRiskCategoryMap(lang); + Map categoryCodeMap = getRiskCategoryCodeMap(); try { Map 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 getShipComplianceStatus(String imoNo, String lang) { Map fieldNameMap = getComplianceFieldNameMap(lang, "SHIP"); Map sortOrderMap = getComplianceSortOrderMap("SHIP"); - Map categoryMap = getComplianceCategoryMap("SHIP"); + Map categoryMap = getComplianceCategoryMap("SHIP", lang); + Map categoryCodeMap = getComplianceCategoryCodeMap("SHIP"); try { Map 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 getCompanyComplianceStatus(String companyCode, String lang) { Map fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY"); Map sortOrderMap = getComplianceSortOrderMap("COMPANY"); - Map categoryMap = getComplianceCategoryMap("COMPANY"); + Map categoryMap = getComplianceCategoryMap("COMPANY", lang); + Map categoryCodeMap = getComplianceCategoryCodeMap("COMPANY"); try { Map 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 getRiskCategoryCodeMap() { + return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() + .filter(ri -> ri.getColumnName() != null) + .collect(Collectors.toMap( + RiskIndicator::getColumnName, + RiskIndicator::getCategoryCode, + (a, b) -> a)); + } + private Map getRiskSortOrderMap() { return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream() .filter(ri -> ri.getColumnName() != null) @@ -488,7 +532,9 @@ public class ScreeningGuideService { (a, b) -> a)); } - private Map getComplianceCategoryMap(String type) { + private Map getComplianceCategoryMap(String type, String lang) { + Map catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream() + .collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName)); List 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 getComplianceCategoryCodeMap(String type) { + List 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)); } From eb827fbe944b5ce2821f8f6317eb645a35edad8b Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 1 Apr 2026 16:52:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bd13c81..f877add 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -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)