Merge pull request 'release: 2026-04-01 (14건 커밋)' (#133) from develop into main
All checks were successful
Build and Deploy Batch / build-and-deploy (push) Successful in 48s

This commit is contained in:
HYOJIN 2026-04-01 16:57:57 +09:00
커밋 28bef0758d
46개의 변경된 파일1571개의 추가작업 그리고 965개의 파일을 삭제

파일 보기

@ -4,6 +4,36 @@
## [Unreleased]
## [2026-04-01]
### 추가
- Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124)
- Screening Guide 아코디언 리스트 UI 개편 (카테고리별 접기/펼치기)
- 언더라인 탭 및 언어 토글 디자인 통일
- 다중언어 데이터 캐싱 (화면 로드 시 KO/EN 동시 조회)
- Compliance 카테고리 다중언어 테이블 신규 생성 (compliance_category, compliance_category_lang)
- RAG 지표 색상 테마 CSS 변수화 (다크모드/라이트모드 대응)
- S&P Bypass 피드백 반영 (#123)
- Response JSON 원본 반환 (ApiResponse 래핑 제거)
- 사용자용 API 카탈로그 페이지 추가 (/bypass-catalog)
- 운영 환경 코드 생성 차단 (app.environment 기반)
- Bypass API 코드 생성 (compliance, risk 도메인)
- 공통 UI 피드백 반영 (#121)
- 2단 탭 네비게이션 (섹션 탭 + 서브 탭)
- 섹션 간 직접 이동
- 메인화면 카드 높이 동기화 (CSS Grid)
### 수정
- Change History 선박 제재 KO 데이터 조회 누락 수정 (categoryCode 기반 분류로 변경)
- S&P Collector 다크모드 미적용 및 라벨 디자인 통일 (#122)
- 실행이력상세/재수집이력상세 API 호출 로그 다크모드 적용
- 개별 호출 로그 필터/테이블 다크모드 적용
- 작업관리 스케줄 라벨 rounded-full 디자인 통일
### 변경
- Navbar 메인 섹션 왼쪽 정렬, 서브 섹션 오른쪽 정렬로 변경
- 불필요한 DB 컬럼 참조 코드 제거 (collection_note, update_title)
## [2026-03-31]
### 추가

파일 보기

@ -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;

파일 보기

@ -16,6 +16,7 @@ const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
const Schedules = lazy(() => import('./pages/Schedules'));
const Timeline = lazy(() => import('./pages/Timeline'));
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
const BypassCatalog = lazy(() => import('./pages/BypassCatalog'));
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
@ -25,26 +26,41 @@ function AppLayout() {
const isMainMenu = location.pathname === '/';
return (
<div className="min-h-screen bg-wing-bg text-wing-text">
<div className={isMainMenu ? 'px-4' : 'max-w-7xl mx-auto px-4 py-6'}>
<Navbar />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<MainMenu />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/executions" element={<Executions />} />
<Route path="/executions/:id" element={<ExecutionDetail />} />
<Route path="/recollects" element={<Recollects />} />
<Route path="/recollects/:id" element={<RecollectDetail />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/schedule-timeline" element={<Timeline />} />
<Route path="/bypass-config" element={<BypassConfig />} />
<Route path="/screening-guide" element={<ScreeningGuide />} />
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
</Routes>
</Suspense>
</div>
<div className="h-screen bg-wing-bg text-wing-text flex flex-col overflow-hidden">
{/* 메인 화면: 전체화면, 섹션 페이지: 탭 + 스크롤 콘텐츠 */}
{isMainMenu ? (
<div className="flex-1 overflow-auto px-4">
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<MainMenu />} />
</Routes>
</Suspense>
</div>
) : (
<>
<div className="flex-shrink-0 px-4 pt-4 max-w-7xl mx-auto w-full">
<Navbar />
</div>
<div className="flex-1 overflow-auto px-4 pb-4 pt-6 max-w-7xl mx-auto w-full">
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/executions" element={<Executions />} />
<Route path="/executions/:id" element={<ExecutionDetail />} />
<Route path="/recollects" element={<Recollects />} />
<Route path="/recollects/:id" element={<RecollectDetail />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/schedule-timeline" element={<Timeline />} />
<Route path="/bypass-catalog" element={<BypassCatalog />} />
<Route path="/bypass-config" element={<BypassConfig />} />
<Route path="/screening-guide" element={<ScreeningGuide />} />
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
</Routes>
</Suspense>
</div>
</>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);

파일 보기

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

파일 보기

@ -71,11 +71,11 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-100 text-red-700'
? 'bg-red-500/15 text-red-500'
: key === 'SUCCESS'
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-blue-500/15 text-blue-500'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label} ({count.toLocaleString()})
@ -92,7 +92,7 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<thead className="bg-blue-500/15 text-blue-500 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
@ -104,24 +104,24 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
<tbody className="divide-y divide-blue-500/15">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
className={isError ? 'bg-red-500/10' : 'bg-wing-surface hover:bg-blue-500/10'}
>
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
<td className="px-2 py-1.5 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
<span className="font-mono text-wing-text truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5 font-semibold text-wing-text">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
@ -132,10 +132,10 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
<td className="px-2 py-1.5 text-right text-wing-text">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
<td className="px-2 py-1.5 text-right text-wing-text">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">

파일 보기

@ -1,49 +1,76 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useThemeContext } from '../contexts/ThemeContext';
interface NavSection {
key: string;
title: string;
paths: string[];
items: { path: string; label: string; icon: string }[];
interface MenuItem {
id: string;
label: string;
path: string;
}
const sections: NavSection[] = [
interface MenuSection {
id: string;
label: string;
shortLabel: string;
icon: React.ReactNode;
defaultPath: string;
children: MenuItem[];
}
const MENU_STRUCTURE: MenuSection[] = [
{
key: 'collector',
title: 'S&P Collector',
paths: ['/dashboard', '/jobs', '/executions', '/recollects', '/schedules', '/schedule-timeline'],
items: [
{ path: '/dashboard', label: '대시보드', icon: '📊' },
{ path: '/executions', label: '실행 이력', icon: '📋' },
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
{ path: '/jobs', label: '작업', icon: '⚙️' },
{ path: '/schedules', label: '스케줄', icon: '🕐' },
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
id: 'collector',
label: 'S&P Collector',
shortLabel: 'Collector',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
defaultPath: '/dashboard',
children: [
{ id: 'dashboard', label: '대시보드', path: '/dashboard' },
{ id: 'executions', label: '실행 이력', path: '/executions' },
{ id: 'recollects', label: '재수집 이력', path: '/recollects' },
{ id: 'jobs', label: '작업 관리', path: '/jobs' },
{ id: 'schedules', label: '스케줄', path: '/schedules' },
{ id: 'timeline', label: '타임라인', path: '/schedule-timeline' },
],
},
{
key: 'bypass',
title: 'S&P Bypass',
paths: ['/bypass-config'],
items: [
{ path: '/bypass-config', label: 'Bypass API', icon: '🔗' },
id: 'bypass',
label: 'S&P Bypass',
shortLabel: 'Bypass',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
),
defaultPath: '/bypass-catalog',
children: [
{ id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' },
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
],
},
{
key: 'risk',
title: 'S&P Risk & Compliance',
paths: ['/screening-guide', '/risk-compliance-history'],
items: [
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
id: 'risk',
label: 'S&P Risk & Compliance',
shortLabel: 'Risk & Compliance',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
defaultPath: '/risk-compliance-history',
children: [
{ id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' },
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
],
},
];
function getCurrentSection(pathname: string): NavSection | null {
for (const section of sections) {
if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
function getCurrentSection(pathname: string): MenuSection | null {
for (const section of MENU_STRUCTURE) {
if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) {
return section;
}
}
@ -52,56 +79,75 @@ function getCurrentSection(pathname: string): NavSection | null {
export default function Navbar() {
const location = useLocation();
const navigate = useNavigate();
const { theme, toggle } = useThemeContext();
const currentSection = getCurrentSection(location.pathname);
// 메인 화면에서는 Navbar 숨김
// 메인 화면에서는 숨김
if (!currentSection) return null;
const isActive = (path: string) => {
if (path === '/dashboard') return location.pathname === '/dashboard';
const isActivePath = (path: string) => {
return location.pathname === path || location.pathname.startsWith(path + '/');
};
return (
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<Link
to="/"
className="px-2.5 py-1.5 rounded-lg text-sm font-medium no-underline text-wing-muted hover:bg-wing-hover hover:text-wing-accent transition-colors"
title="메인 메뉴"
>
</Link>
<span className="text-wing-border">|</span>
<span className="text-sm font-bold text-wing-accent">{currentSection.title}</span>
</div>
<div className="flex gap-1 flex-wrap items-center">
{currentSection.items.map((item) => (
<Link
key={item.path}
to={item.path}
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
${isActive(item.path)
? 'bg-wing-accent text-white'
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
}`}
<div className="mb-6">
{/* 1단: 섹션 탭 */}
<div className="bg-slate-900 px-6 pt-3 rounded-t-xl flex items-center">
<Link
to="/"
className="flex items-center gap-1 px-3 py-2.5 text-sm text-slate-400 hover:text-white no-underline transition-colors"
title="메인 메뉴"
>
</Link>
<div className="flex-1 flex items-center justify-start gap-1">
{MENU_STRUCTURE.map((section) => (
<button
key={section.id}
onClick={() => {
if (currentSection?.id !== section.id) {
navigate(section.defaultPath);
}
}}
className={`flex items-center gap-2 px-5 py-2.5 rounded-t-lg text-sm font-medium transition-all ${
currentSection?.id === section.id
? 'bg-wing-bg text-wing-text shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<span className="mr-1">{item.icon}</span>
{item.label}
</Link>
{section.icon}
<span>{section.shortLabel}</span>
</button>
))}
</div>
<button
onClick={toggle}
className="px-2.5 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
{/* 2단: 서브 탭 */}
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md">
<div className="flex gap-1 -mb-px justify-end">
{currentSection?.children.map((child) => (
<button
key={child.id}
onClick={() => navigate(child.path)}
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 ${
isActivePath(child.path)
? 'border-blue-600 text-blue-600'
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
}`}
>
{child.label}
</button>
))}
<button
onClick={toggle}
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
hover:text-wing-text border border-wing-border transition-colors"
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
</div>
</nav>
</div>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
screeningGuideApi,
type ComplianceCategoryResponse,
@ -9,135 +9,105 @@ interface ComplianceTabProps {
lang: string;
}
type ViewMode = 'table' | 'card';
type IndicatorType = 'SHIP' | 'COMPANY';
type CacheKey = string;
const SHIP_CAT_COLORS: Record<string, string> = {
'Sanctions Ship (US OFAC)': '#1e3a5f',
'Sanctions Ownership (US OFAC)': '#1d4ed8',
'Sanctions Ship (Non-US)': '#065f46',
'Sanctions Ownership (Non-US)': '#0f766e',
'Sanctions FATF': '#6b21a8',
'Sanctions Other': '#991b1b',
'Port Calls': '#065f46',
'STS Activity': '#0f766e',
'Dark Activity': '#374151',
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
// SHIP
'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' },
'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' },
'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' },
'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' },
// COMPANY
'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' },
'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' },
'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' },
'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' },
'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' },
};
const COMPANY_CAT_COLORS: Record<string, string> = {
'Company Sanctions (US OFAC)': '#1e3a5f',
'Company Sanctions (Non-US)': '#065f46',
'Company Compliance': '#6b21a8',
'Company Risk': '#92400e',
};
function getCatHex(categoryName: string, type: IndicatorType): string {
const map = type === 'SHIP' ? SHIP_CAT_COLORS : COMPANY_CAT_COLORS;
return map[categoryName] ?? '#374151';
}
interface FlatRow {
category: string;
indicatorType: string;
indicator: ComplianceIndicatorResponse;
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
}
export default function ComplianceTab({ lang }: ComplianceTabProps) {
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
const fetchData = useCallback((fetchLang: string, type: IndicatorType) => {
return screeningGuideApi
.getComplianceIndicators(fetchLang, type)
.then((res) => {
const data = res.data ?? [];
cache.current.set(`${type}_${fetchLang}`, data);
return data;
});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
setSelectedCategory('전체');
screeningGuideApi
.getComplianceIndicators(lang, indicatorType)
.then((res) => setCategories(res.data ?? []))
setExpandedCategories(new Set());
cache.current.clear();
Promise.all([
fetchData('KO', indicatorType),
fetchData('EN', indicatorType),
])
.then(() => {
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [indicatorType, fetchData]);
useEffect(() => {
const cached = cache.current.get(`${indicatorType}_${lang}`);
if (cached) {
setCategories(cached);
}
}, [lang, indicatorType]);
const flatRows: FlatRow[] = categories.flatMap((cat) =>
cat.indicators.map((ind) => ({
category: cat.category,
indicatorType: cat.indicatorType,
indicator: ind,
})),
);
const filtered: FlatRow[] =
selectedCategory === '전체'
? flatRows
: flatRows.filter((r) => r.category === selectedCategory);
const uniqueCategories = Array.from(new Set(flatRows.map((r) => r.category)));
function downloadCSV() {
const bom = '\uFEFF';
const headers = [
'카테고리',
'타입',
'필드키',
'필드명',
'설명',
'RED 조건',
'AMBER 조건',
'GREEN 조건',
'데이터 타입',
'이력 관리 참고사항',
];
const rows = flatRows.map((r) =>
[
r.category,
r.indicatorType,
r.indicator.fieldKey,
r.indicator.fieldName,
r.indicator.description,
r.indicator.conditionRed,
r.indicator.conditionAmber,
r.indicator.conditionGreen,
r.indicator.dataType,
r.indicator.collectionNote,
]
.map((v) => `"${(v ?? '').replace(/"/g, '""')}"`)
.join(','),
);
const csv = bom + [headers.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `MIRS_Compliance_${indicatorType}.csv`;
a.click();
}
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
return (
<div className="space-y-4">
{/* SHIP / COMPANY 토글 */}
{/* Ship / Company 토글 */}
<div className="flex gap-2">
<button
onClick={() => setIndicatorType('SHIP')}
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
indicatorType === 'SHIP'
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
(SHIP)
</button>
<button
onClick={() => setIndicatorType('COMPANY')}
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
indicatorType === 'COMPANY'
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
(COMPANY)
</button>
{(['SHIP', 'COMPANY'] as const).map((type) => (
<button
key={type}
onClick={() => setIndicatorType(type)}
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
indicatorType === type
? 'bg-wing-text text-wing-bg shadow-sm'
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
}`}
>
{type === 'SHIP' ? 'Ship' : 'Company'}
</button>
))}
</div>
{loading && (
@ -156,229 +126,100 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) {
)}
{!loading && !error && (
<>
{/* 카테고리 요약 카드 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{uniqueCategories.map((catName) => {
const count = flatRows.filter((r) => r.category === catName).length;
const isActive = selectedCategory === catName;
const hex = getCatHex(catName, indicatorType);
return (
<div className="space-y-2">
{categories.map((cat) => {
const isExpanded = expandedCategories.has(cat.categoryCode);
const badge = getBadgeColor(cat.categoryCode);
return (
<div
key={cat.categoryCode}
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
>
{/* 아코디언 헤더 */}
<button
key={catName}
onClick={() =>
setSelectedCategory(isActive ? '전체' : catName)
}
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2 text-left"
style={{
background: isActive ? hex : undefined,
borderColor: isActive ? hex : undefined,
}}
onClick={() => toggleCategory(cat.categoryCode)}
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
>
<div
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
<span
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
style={{ background: badge.bg, color: badge.text }}
>
{count}
</div>
<div
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
{cat.categoryName}
</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"
>
{catName}
</div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
})}
</div>
{/* 컨트롤 바 */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => setSelectedCategory('전체')}
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${
selectedCategory === '전체'
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
({flatRows.length})
</button>
<div className="flex-1" />
<span className="text-xs text-wing-muted">
:{' '}
<strong className="text-wing-text">{filtered.length}</strong>
</span>
<button
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
>
{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>
{/* 아코디언 콘텐츠 */}
{isExpanded && (
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{cat.indicators.map((ind) => (
<IndicatorCard key={ind.indicatorId} indicator={ind} />
))}
</tr>
</thead>
<tbody>
{filtered.map((row, i) => {
const showCat =
i === 0 ||
filtered[i - 1].category !== row.category;
const hex = getCatHex(row.category, 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>
);
}
function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) {
return (
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="font-bold text-sm text-wing-text">
{indicator.fieldName}
</div>
{indicator.dataType && (
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
{indicator.dataType}
</span>
)}
</div>
{indicator.description && (
<div className="text-xs text-wing-muted leading-relaxed mb-3 whitespace-pre-line">
{indicator.description}
</div>
)}
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
<div className="flex gap-2">
{indicator.conditionRed && (
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
</div>
)}
{indicator.conditionAmber && (
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
</div>
)}
{indicator.conditionGreen && (
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
</div>
)}
</div>
)}
</div>
);

파일 보기

@ -166,12 +166,13 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
);
}
// 선박 Compliance 탭 분류
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') },
{ key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' },
{ key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' },
// 선박 Compliance 탭 분류 (categoryCode 기반)
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [
{ key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') },
{ key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
{ key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' },
];
// Compliance 예외 처리
@ -252,7 +253,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
const tabData = useMemo(() => {
const result: Record<string, IndicatorStatusResponse[]> = {};
for (const tab of SHIP_COMPLIANCE_TABS) {
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
result[tab.key] = filteredItems.filter((i) => tab.match(i.categoryCode));
}
return result;
}, [filteredItems]);
@ -461,21 +462,23 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
return (
<div className="space-y-4">
{/* 이력 유형 선택 */}
<div className="flex gap-2 flex-wrap justify-center">
{HISTORY_TYPES.map((t) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
historyType === t.key
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
}`}
>
{t.label}
</button>
))}
{/* 이력 유형 선택 (언더라인 탭) */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{HISTORY_TYPES.map((t) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
historyType === t.key
? 'text-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text'
}`}
>
{t.label}
</button>
))}
</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';
interface MethodologyTabProps {
@ -18,20 +18,60 @@ function getChangeTypeColor(changeType: string): string {
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
}
type LangKey = 'KO' | 'EN';
interface LangCache {
history: MethodologyHistoryResponse[];
banner: string;
}
export default function MethodologyTab({ lang }: MethodologyTabProps) {
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
const [banner, setBanner] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState('전체');
const cache = useRef<Map<LangKey, LangCache>>(new Map());
const fetchData = useCallback((fetchLang: string) => {
return Promise.all([
screeningGuideApi.getMethodologyHistory(fetchLang),
screeningGuideApi.getMethodologyBanner(fetchLang).catch(() => ({ data: null })),
]).then(([historyRes, bannerRes]) => {
const data: LangCache = {
history: historyRes.data ?? [],
banner: bannerRes.data?.description ?? '',
};
cache.current.set(fetchLang as LangKey, data);
return data;
});
}, []);
// 초기 로드: KO/EN 데이터 모두 가져와서 캐싱
useEffect(() => {
setLoading(true);
setError(null);
screeningGuideApi
.getMethodologyHistory(lang)
.then((res) => setHistory(res.data ?? []))
cache.current.clear();
Promise.all([fetchData('KO'), fetchData('EN')])
.then(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setHistory(cached.history);
setBanner(cached.banner);
}
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [fetchData]);
// 언어 변경: 캐시에서 스위칭
useEffect(() => {
const cached = cache.current.get(lang as LangKey);
if (cached) {
setHistory(cached.history);
setBanner(cached.banner);
}
}, [lang]);
const sortedHistory = [...history].sort((a, b) =>
@ -67,11 +107,11 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
return (
<div className="space-y-4">
{/* 주의사항 배너 */}
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
<strong> :</strong> ·
. ,
.
</div>
{banner && (
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
{banner}
</div>
)}
{/* 변경 유형 필터 */}
<div className="flex gap-2 flex-wrap">
@ -116,7 +156,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 text-white">
{['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => (
{['날짜', '변경 유형', '설명'].map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
@ -147,14 +187,8 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
{row.changeType}
</span>
</td>
<td className="px-3 py-3 min-w-[180px] font-semibold text-wing-text leading-relaxed">
{row.updateTitle}
</td>
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
{row.description}
</td>
<td className="px-3 py-3 min-w-[200px] text-blue-600 leading-relaxed">
{row.collectionNote && `💡 ${row.collectionNote}`}
{row.description || '-'}
</td>
</tr>
);

파일 보기

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

파일 보기

@ -0,0 +1,311 @@
import { useState, useEffect, useMemo } from 'react';
interface BypassParam {
paramName: string;
paramType: string;
paramIn: string;
required: boolean;
description: string;
example: string;
}
interface BypassConfig {
id: number;
domainName: string;
endpointName: string;
displayName: string;
httpMethod: string;
externalPath: string;
description: string;
generated: boolean;
createdAt: string;
params: BypassParam[];
}
interface ApiResponse<T> {
success: boolean;
data: T;
}
type ViewMode = 'card' | 'table';
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-emerald-100 text-emerald-700',
POST: 'bg-blue-100 text-blue-700',
PUT: 'bg-amber-100 text-amber-700',
DELETE: 'bg-red-100 text-red-700',
};
const SWAGGER_URL = '/snp-api/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
export default function BypassCatalog() {
const [configs, setConfigs] = useState<BypassConfig[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedDomain, setSelectedDomain] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('table');
useEffect(() => {
fetch('/snp-api/api/bypass-config')
.then(res => res.json())
.then((res: ApiResponse<BypassConfig[]>) => setConfigs((res.data ?? []).filter(c => c.generated)))
.catch(() => setConfigs([]))
.finally(() => setLoading(false));
}, []);
const domainNames = useMemo(() => {
const names = [...new Set(configs.map((c) => c.domainName))];
return names.sort();
}, [configs]);
const filtered = useMemo(() => {
return configs.filter((c) => {
const matchesSearch =
!searchTerm.trim() ||
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
return matchesSearch && matchesDomain;
});
}, [configs, searchTerm, selectedDomain]);
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-wing-muted">
<div className="text-sm">API ...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">Bypass API </h1>
<p className="mt-1 text-sm text-wing-muted">
Bypass API . Swagger UI에서 .
</p>
</div>
<a
href={SWAGGER_URL}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
>
Swagger UI
</a>
</div>
{/* 검색 + 필터 + 뷰 전환 */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-3 items-center flex-wrap">
{/* 검색 */}
<div className="relative flex-1 min-w-[200px]">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="text"
placeholder="도메인명, 표시명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* 도메인 드롭다운 필터 */}
<select
value={selectedDomain}
onChange={(e) => setSelectedDomain(e.target.value)}
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
>
<option value=""> </option>
{domainNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
{/* 뷰 전환 토글 */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
viewMode === 'table'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</button>
</div>
</div>
{(searchTerm || selectedDomain) && (
<p className="mt-2 text-xs text-wing-muted">
{filtered.length} API
</p>
)}
</div>
{/* 빈 상태 */}
{configs.length === 0 ? (
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
<p className="text-base font-medium mb-1"> API가 .</p>
<p className="text-sm"> .</p>
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
<p className="text-base font-medium mb-1"> .</p>
<p className="text-sm"> .</p>
</div>
) : viewMode === 'card' ? (
/* 카드 뷰 */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((config) => (
<div
key={config.id}
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
</div>
<span className={['shrink-0 px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
{config.httpMethod}
</span>
</div>
<div className="space-y-1.5">
<p className="text-xs text-wing-muted font-mono truncate">{config.externalPath}</p>
{config.description && (
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
)}
</div>
{config.params.length > 0 && (
<div>
<div className="text-[10px] text-wing-muted mb-1 font-medium">Parameters</div>
<div className="flex flex-wrap gap-1">
{config.params.map((p) => (
<span
key={p.paramName}
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted"
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
>
{p.paramName}
{p.required && <span className="text-red-400 ml-0.5">*</span>}
</span>
))}
</div>
</div>
)}
<div className="pt-1 border-t border-wing-border mt-auto">
<a
href={SWAGGER_URL}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
>
Swagger에서
</a>
</div>
</div>
))}
</div>
) : (
/* 테이블 뷰 */
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">HTTP</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Swagger</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{filtered.map((config) => (
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3 font-mono text-xs text-wing-text">{config.domainName}</td>
<td className="px-4 py-3 font-medium text-wing-text">{config.displayName}</td>
<td className="px-4 py-3">
<span className={['px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
{config.httpMethod}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[250px] truncate" title={config.externalPath}>
{config.externalPath}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{config.params.map((p) => (
<span
key={p.paramName}
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-card text-wing-muted"
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
>
{p.paramName}
{p.required && <span className="text-red-400 ml-0.5">*</span>}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-right">
<a
href={SWAGGER_URL}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -41,6 +41,7 @@ export default function BypassConfig() {
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
const [codeGenEnabled, setCodeGenEnabled] = useState(true);
const loadConfigs = useCallback(async () => {
try {
@ -59,6 +60,10 @@ export default function BypassConfig() {
bypassApi.getWebclientBeans()
.then((res) => setWebclientBeans(res.data ?? []))
.catch((err) => console.error(err));
fetch('/snp-api/api/bypass-config/environment')
.then(res => res.json())
.then(res => setCodeGenEnabled(res.data?.codeGenerationEnabled ?? true))
.catch(() => {});
}, [loadConfigs]);
const handleCreate = () => {
@ -314,7 +319,13 @@ export default function BypassConfig() {
<button
type="button"
onClick={() => setConfirmAction({ type: 'generate', config })}
className="flex-1 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
disabled={!codeGenEnabled}
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
className={`flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors ${
codeGenEnabled
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
: 'text-wing-muted bg-wing-card cursor-not-allowed'
}`}
>
{config.generated ? '재생성' : '코드 생성'}
</button>
@ -416,7 +427,13 @@ export default function BypassConfig() {
<button
type="button"
onClick={() => setConfirmAction({ type: 'generate', config })}
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
disabled={!codeGenEnabled}
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
codeGenEnabled
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
: 'text-wing-muted bg-wing-card cursor-not-allowed'
}`}
>
{config.generated ? '재생성' : '코드 생성'}
</button>

파일 보기

@ -96,32 +96,32 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
{step.apiLogSummary ? (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"> </p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
{step.apiLogSummary.errorCount.toLocaleString()}
</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
@ -132,7 +132,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
)}
</div>
) : step.apiCallInfo && (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
@ -163,7 +163,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
{step.exitMessage}
@ -468,7 +468,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
};
return (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<div className="flex items-center justify-between">
<button
onClick={() => setOpen((v) => !v)}
@ -522,7 +522,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<div className="mt-2">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-red-100 text-red-700">
<thead className="bg-red-500/20 text-red-700">
<tr>
<th className="px-2 py-1.5 font-medium">Record Key</th>
<th className="px-2 py-1.5 font-medium"> </th>
@ -531,11 +531,11 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<th className="px-2 py-1.5 font-medium"> </th>
</tr>
</thead>
<tbody className="divide-y divide-red-100">
<tbody className="divide-y divide-red-500/20">
{pagedRecords.map((record) => (
<tr
key={record.id}
className="bg-white hover:bg-red-50"
className="bg-wing-surface hover:bg-red-500/10"
>
<td className="px-2 py-1.5 font-mono text-red-900">
{record.recordKey}
@ -581,19 +581,19 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-3">
{failedRecords.length} IMO에 .
</p>
<div className="bg-gray-50 rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="bg-wing-card rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1">
{failedRecords.map((r) => (
<span
key={r.id}
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-100 text-red-700 rounded"
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-500/20 text-red-700 rounded"
>
{r.recordKey}
</span>
@ -604,7 +604,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowConfirm(false)}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
@ -630,7 +630,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
@ -642,7 +642,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
@ -668,7 +668,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
@ -680,7 +680,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>

파일 보기

@ -376,11 +376,11 @@ export default function Jobs() {
<div className="flex items-center gap-2 pt-0.5">
{job.scheduleCron ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-500">
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-wing-card text-wing-muted">
</span>
)}
@ -478,11 +478,11 @@ export default function Jobs() {
</td>
<td className="px-4 py-3">
{job.scheduleCron ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700">
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-wing-card text-wing-muted">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-wing-card text-wing-muted">
</span>
)}

파일 보기

@ -15,7 +15,7 @@ const sections = [
title: 'S&P Bypass',
description: 'S&P Bypass API 관리',
detail: 'API 등록, 코드 생성 관리, 테스트',
path: '/bypass-config',
path: '/bypass-catalog',
icon: '🔗',
iconClass: 'gc-card-icon gc-card-icon-guide',
menuCount: 1,
@ -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,

파일 보기

@ -75,32 +75,32 @@ function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; j
{/* API 호출 로그 요약 (batch_api_log 기반) */}
{summary && (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-wing-text">{summary.totalCalls.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"> </p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-600">{summary.successCount.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className={`text-sm font-bold ${summary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
{summary.errorCount.toLocaleString()}
</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-blue-600">{Math.round(summary.avgResponseMs).toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-red-500">{summary.maxResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-500">{summary.minResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
@ -118,7 +118,7 @@ function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; j
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
{step.exitMessage}
@ -361,7 +361,7 @@ export default function RecollectDetail() {
<h2 className="text-lg font-semibold text-red-600 mb-3">
</h2>
<pre className="text-sm text-wing-text font-mono bg-red-50 border border-red-200 px-4 py-3 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
<pre className="text-sm text-wing-text font-mono bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{history.failureReason}
</pre>
</div>
@ -536,7 +536,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
};
return (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<div className="flex items-center justify-between">
<button
onClick={() => setOpen((v) => !v)}
@ -590,7 +590,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<div className="mt-2">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-red-100 text-red-700">
<thead className="bg-red-500/20 text-red-700">
<tr>
<th className="px-2 py-1.5 font-medium">Record Key</th>
<th className="px-2 py-1.5 font-medium"> </th>
@ -599,11 +599,11 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<th className="px-2 py-1.5 font-medium"> </th>
</tr>
</thead>
<tbody className="divide-y divide-red-100">
<tbody className="divide-y divide-red-500/20">
{pagedRecords.map((record) => (
<tr
key={record.id}
className="bg-white hover:bg-red-50"
className="bg-wing-surface hover:bg-red-500/10"
>
<td className="px-2 py-1.5 font-mono text-red-900">
{record.recordKey}
@ -649,19 +649,19 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-3">
{failedRecords.length} IMO에 .
</p>
<div className="bg-gray-50 rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="bg-wing-card rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1">
{failedRecords.map((r) => (
<span
key={r.id}
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-100 text-red-700 rounded"
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-500/20 text-red-700 rounded"
>
{r.recordKey}
</span>
@ -672,7 +672,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowConfirm(false)}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
@ -698,7 +698,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
@ -710,7 +710,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
@ -736,7 +736,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
@ -748,7 +748,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>

파일 보기

@ -6,42 +6,28 @@ export default function RiskComplianceHistory() {
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
<div className="text-xs opacity-75 mb-1">
S&P Global · Maritime Intelligence Risk Suite (MIRS)
{/* 헤더 + 언어 토글 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">Risk & Compliance Change History</h1>
<p className="mt-1 text-sm text-wing-muted">
S&P
</p>
</div>
<h1 className="text-xl font-bold mb-1">
Risk & Compliance Change History
</h1>
<p className="text-sm opacity-85">
</p>
</div>
{/* 언어 토글 */}
<div className="flex justify-end">
<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 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>

파일 보기

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

파일 보기

@ -27,16 +27,19 @@ body {
/* Main Menu Cards */
.gc-cards {
padding: 2rem 0;
display: flex;
justify-content: center;
align-items: stretch;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;
gap: 2rem;
width: 80%;
margin: 0 auto;
}
.gc-cards > * {
flex: 1 1 0;
@media (max-width: 768px) {
.gc-cards {
grid-template-columns: 1fr;
width: 90%;
}
}
.gc-card {

파일 보기

@ -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;
}

파일 보기

@ -25,4 +25,21 @@ public abstract class BaseBypassController {
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
}
}
/**
* 외부 API 응답을 ApiResponse 래핑 없이 원본 그대로 반환
*/
protected <T> ResponseEntity<T> executeRaw(Supplier<T> action) {
try {
T result = action.get();
return ResponseEntity.ok(result);
} catch (WebClientResponseException e) {
log.error("외부 API 호출 실패 - status: {}, body: {}",
e.getStatusCode(), e.getResponseBodyAsString());
return ResponseEntity.status(e.getStatusCode()).body(null);
} catch (Exception e) {
log.error("API 처리 중 오류", e);
return ResponseEntity.internalServerError().body(null);
}
}
}

파일 보기

@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@ -40,6 +41,9 @@ public class BypassConfigController {
private final BypassCodeGenerator bypassCodeGenerator;
private final BypassApiConfigRepository configRepository;
@Value("${app.environment:dev}")
private String environment;
@Operation(summary = "설정 목록 조회")
@GetMapping
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
@ -82,6 +86,10 @@ public class BypassConfigController {
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean force) {
if ("prod".equals(environment)) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("운영 환경에서는 코드 생성이 불가합니다. 개발 환경에서 생성 후 배포해주세요."));
}
try {
BypassApiConfig config = configRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
@ -101,6 +109,15 @@ public class BypassConfigController {
}
}
@Operation(summary = "환경 정보", description = "현재 서버 환경 정보를 반환합니다 (dev/prod).")
@GetMapping("/environment")
public ResponseEntity<ApiResponse<Map<String, Object>>> getEnvironment() {
return ResponseEntity.ok(ApiResponse.success(Map.of(
"environment", environment,
"codeGenerationEnabled", !"prod".equals(environment)
)));
}
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
@GetMapping("/webclient-beans")
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {

파일 보기

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

파일 보기

@ -15,10 +15,10 @@ public class WebViewController {
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
"/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline", "/monitoring",
"/bypass-config", "/screening-guide", "/risk-compliance-history",
"/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history",
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
"/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
public String forward() {
return "forward:/index.html";
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,37 @@
package com.snp.batch.jobs.web.compliance.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.controller.BaseBypassController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.snp.batch.jobs.web.compliance.service.CompliancesByImosService;
/**
* Compliance bypass API
* S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@RestController
@RequestMapping("/api/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance", description = "[Service API] Compliance bypass API")
public class ComplianceController extends BaseBypassController {
private final CompliancesByImosService compliancesByImosService;
@Operation(
summary = "IMO 기반 선박 규정준수 조회",
description = "Gets details of the IMOs of ships with full compliance details that match given IMOs"
)
@GetMapping("/CompliancesByImos")
public ResponseEntity<JsonNode> getCompliancesByImosData(@Parameter(description = "Comma separated IMOs up to a total of 100", example = "9876543")
@RequestParam(required = true) String imos) {
return executeRaw(() -> compliancesByImosService.getCompliancesByImosData(imos));
}
}

파일 보기

@ -0,0 +1,32 @@
package com.snp.batch.jobs.web.compliance.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.service.BaseBypassService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
/**
* IMO 기반 선박 규정준수 조회 bypass 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@Service
public class CompliancesByImosService extends BaseBypassService<JsonNode> {
public CompliancesByImosService(
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
super(webClient, "/RiskAndCompliance/CompliancesByImos", "IMO 기반 선박 규정준수 조회",
new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {});
}
/**
* IMO 기반 선박 규정준수 조회 데이터를 조회합니다.
*/
public JsonNode getCompliancesByImosData(String imos) {
return fetchRawGet(uri -> uri.path(getApiPath())
.queryParam("imos", imos)
.build());
}
}

파일 보기

@ -0,0 +1,51 @@
package com.snp.batch.jobs.web.risk.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.controller.BaseBypassController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.snp.batch.jobs.web.risk.service.RisksByImosService;
import com.snp.batch.jobs.web.risk.service.UpdatedComplianceListService;
/**
* Risk bypass API
* S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@RestController
@RequestMapping("/api/risk")
@RequiredArgsConstructor
@Tag(name = "Risk", description = "[Service API] Risk bypass API")
public class RiskController extends BaseBypassController {
private final RisksByImosService risksByImosService;
private final UpdatedComplianceListService updatedComplianceListService;
@Operation(
summary = "IMO 기반 선박 위험지표 조회",
description = "Gets details of the IMOs of all ships with risk updates as a collection"
)
@GetMapping("/RisksByImos")
public ResponseEntity<JsonNode> getRisksByImosData(@Parameter(description = "Comma separated IMOs up to a total of 100", example = "9876543")
@RequestParam(required = true) String imos) {
return executeRaw(() -> risksByImosService.getRisksByImosData(imos));
}
@Operation(
summary = "기간 내 변경된 위험지표 조회",
description = "Gets details of the IMOs of all ships with compliance updates"
)
@GetMapping("/UpdatedComplianceList")
public ResponseEntity<JsonNode> getUpdatedComplianceListData(@Parameter(description = "Time/seconds are optional", example = "2026-03-30T07:01:27.000Z")
@RequestParam(required = true) String fromDate,
@Parameter(description = "Time/seconds are optional. If unspecified, the current UTC date and time is used", example = "2026-03-31T07:01:27.000Z")
@RequestParam(required = true) String toDate) {
return executeRaw(() -> updatedComplianceListService.getUpdatedComplianceListData(fromDate, toDate));
}
}

파일 보기

@ -0,0 +1,32 @@
package com.snp.batch.jobs.web.risk.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.service.BaseBypassService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
/**
* IMO 기반 선박 위험지표 조회 bypass 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@Service
public class RisksByImosService extends BaseBypassService<JsonNode> {
public RisksByImosService(
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
super(webClient, "/RiskAndCompliance/RisksByImos", "IMO 기반 선박 위험지표 조회",
new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {});
}
/**
* IMO 기반 선박 위험지표 조회 데이터를 조회합니다.
*/
public JsonNode getRisksByImosData(String imos) {
return fetchRawGet(uri -> uri.path(getApiPath())
.queryParam("imos", imos)
.build());
}
}

파일 보기

@ -0,0 +1,33 @@
package com.snp.batch.jobs.web.risk.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.service.BaseBypassService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
/**
* 기간 변경된 위험지표 조회 bypass 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@Service
public class UpdatedComplianceListService extends BaseBypassService<JsonNode> {
public UpdatedComplianceListService(
@Qualifier("maritimeServiceApiWebClient") WebClient webClient) {
super(webClient, "/RiskAndCompliance/UpdatedComplianceList", "기간 내 변경된 위험지표 조회",
new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {});
}
/**
* 기간 변경된 위험지표 조회 데이터를 조회합니다.
*/
public JsonNode getUpdatedComplianceListData(String fromDate, String toDate) {
return fetchRawGet(uri -> uri.path(getApiPath())
.queryParam("fromDate", fromDate)
.queryParam("toDate", toDate)
.build());
}
}

파일 보기

@ -131,7 +131,7 @@ public class BypassCodeGenerator {
/**
* Controller 코드 생성 (RAW 모드).
* 모든 엔드포인트가 ResponseEntity<ApiResponse<JsonNode>> 반환합니다.
* 모든 엔드포인트가 ResponseEntity<JsonNode> 반환합니다 (외부 API 원본 JSON 그대로).
*/
private String generateControllerCode(String domain, List<BypassApiConfig> configs) {
String packageName = BASE_PACKAGE + "." + domain + ".controller";
@ -142,7 +142,6 @@ public class BypassCodeGenerator {
// imports (중복 제거)
Set<String> importSet = new LinkedHashSet<>();
importSet.add("import com.fasterxml.jackson.databind.JsonNode;");
importSet.add("import com.snp.batch.common.web.ApiResponse;");
importSet.add("import com.snp.batch.common.web.controller.BaseBypassController;");
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
importSet.add("import io.swagger.v3.oas.annotations.Parameter;");
@ -207,12 +206,12 @@ public class BypassCodeGenerator {
methods.append(" description = \"").append(opDescription).append("\"\n");
methods.append(" )\n");
methods.append(" ").append(mappingAnnotation).append(mappingPath).append("\n");
methods.append(" public ResponseEntity<ApiResponse<JsonNode>> ").append(methodName).append("(");
methods.append(" public ResponseEntity<JsonNode> ").append(methodName).append("(");
if (!paramAnnotations.isEmpty()) {
methods.append(paramAnnotations);
}
methods.append(") {\n");
methods.append(" return execute(() -> ").append(serviceField)
methods.append(" return executeRaw(() -> ").append(serviceField)
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
methods.append(" }\n");
}

파일 보기

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

파일 보기

@ -78,6 +78,7 @@ logging:
# Custom Application Properties
app:
environment: prod
batch:
chunk-size: 1000
schedule:

파일 보기

@ -76,6 +76,7 @@ logging:
# Custom Application Properties
app:
environment: dev
batch:
chunk-size: 1000
target-schema: