release: 2026-04-01 (14건 커밋) #133
@ -4,6 +4,36 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [2026-03-31]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
111
docs/compliance_category_migration.sql
Normal file
111
docs/compliance_category_migration.sql
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Compliance 카테고리 다중언어 마이그레이션 스크립트
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 카테고리 마스터 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category (
|
||||||
|
category_code VARCHAR(50) PRIMARY KEY,
|
||||||
|
indicator_type VARCHAR(20) NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 카테고리 다중언어 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category_lang (
|
||||||
|
category_code VARCHAR(50) NOT NULL,
|
||||||
|
lang_code VARCHAR(5) NOT NULL,
|
||||||
|
category_name VARCHAR(200) NOT NULL,
|
||||||
|
PRIMARY KEY (category_code, lang_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 카테고리 마스터 데이터 삽입
|
||||||
|
INSERT INTO std_snp_data.compliance_category (category_code, indicator_type, sort_order) VALUES
|
||||||
|
('SANCTIONS_SHIP_US_OFAC', 'SHIP', 1),
|
||||||
|
('SANCTIONS_OWNERSHIP_US_OFAC', 'SHIP', 2),
|
||||||
|
('SANCTIONS_SHIP_NON_US', 'SHIP', 3),
|
||||||
|
('SANCTIONS_OWNERSHIP_NON_US', 'SHIP', 4),
|
||||||
|
('SANCTIONS_FATF', 'SHIP', 5),
|
||||||
|
('SANCTIONS_OTHER', 'SHIP', 6),
|
||||||
|
('PORT_CALLS', 'SHIP', 7),
|
||||||
|
('STS_ACTIVITY', 'SHIP', 8),
|
||||||
|
('SUSPICIOUS_BEHAVIOR', 'SHIP', 9),
|
||||||
|
('OWNERSHIP_SCREENING', 'SHIP', 10),
|
||||||
|
('COMPLIANCE_SCREENING_HISTORY', 'SHIP', 11),
|
||||||
|
('US_TREASURY_SANCTIONS', 'COMPANY', 1),
|
||||||
|
('NON_US_SANCTIONS', 'COMPANY', 2),
|
||||||
|
('FATF_JURISDICTION', 'COMPANY', 3),
|
||||||
|
('PARENT_COMPANY', 'COMPANY', 4),
|
||||||
|
('OVERALL_COMPLIANCE_STATUS', 'COMPANY', 5),
|
||||||
|
('RELATED_SCREENING', 'COMPANY', 6),
|
||||||
|
('COMPANY_COMPLIANCE_HISTORY', 'COMPANY', 7);
|
||||||
|
|
||||||
|
-- 4. 카테고리 다중언어 데이터 삽입 (EN)
|
||||||
|
INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES
|
||||||
|
('SANCTIONS_SHIP_US_OFAC', 'EN', 'Sanctions – Ship (US OFAC)'),
|
||||||
|
('SANCTIONS_OWNERSHIP_US_OFAC', 'EN', 'Sanctions – Ownership (US OFAC)'),
|
||||||
|
('SANCTIONS_SHIP_NON_US', 'EN', 'Sanctions – Ship (Non-US)'),
|
||||||
|
('SANCTIONS_OWNERSHIP_NON_US', 'EN', 'Sanctions – Ownership (Non-US)'),
|
||||||
|
('SANCTIONS_FATF', 'EN', 'Sanctions – FATF'),
|
||||||
|
('SANCTIONS_OTHER', 'EN', 'Sanctions – Other'),
|
||||||
|
('PORT_CALLS', 'EN', 'Port Calls'),
|
||||||
|
('STS_ACTIVITY', 'EN', 'STS Activity'),
|
||||||
|
('SUSPICIOUS_BEHAVIOR', 'EN', 'Suspicious Behavior'),
|
||||||
|
('OWNERSHIP_SCREENING', 'EN', 'Ownership Screening'),
|
||||||
|
('COMPLIANCE_SCREENING_HISTORY', 'EN', 'Compliance Screening History'),
|
||||||
|
('US_TREASURY_SANCTIONS', 'EN', 'US Treasury Sanctions'),
|
||||||
|
('NON_US_SANCTIONS', 'EN', 'Non-US Sanctions'),
|
||||||
|
('FATF_JURISDICTION', 'EN', 'FATF Jurisdiction'),
|
||||||
|
('PARENT_COMPANY', 'EN', 'Parent Company'),
|
||||||
|
('OVERALL_COMPLIANCE_STATUS', 'EN', 'Overall Compliance Status'),
|
||||||
|
('RELATED_SCREENING', 'EN', 'Related Screening'),
|
||||||
|
('COMPANY_COMPLIANCE_HISTORY', 'EN', 'Compliance Screening Change History');
|
||||||
|
|
||||||
|
-- 5. 카테고리 다중언어 데이터 삽입 (KO)
|
||||||
|
INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES
|
||||||
|
('SANCTIONS_SHIP_US_OFAC', 'KO', '제재 – 선박 (US OFAC)'),
|
||||||
|
('SANCTIONS_OWNERSHIP_US_OFAC', 'KO', '제재 – 소유권 (US OFAC)'),
|
||||||
|
('SANCTIONS_SHIP_NON_US', 'KO', '제재 – 선박 (비미국)'),
|
||||||
|
('SANCTIONS_OWNERSHIP_NON_US', 'KO', '제재 – 소유권 (비미국)'),
|
||||||
|
('SANCTIONS_FATF', 'KO', '제재 – FATF'),
|
||||||
|
('SANCTIONS_OTHER', 'KO', '제재 – 기타'),
|
||||||
|
('PORT_CALLS', 'KO', '입항 이력'),
|
||||||
|
('STS_ACTIVITY', 'KO', 'STS 활동'),
|
||||||
|
('SUSPICIOUS_BEHAVIOR', 'KO', '의심 행위'),
|
||||||
|
('OWNERSHIP_SCREENING', 'KO', '소유권 심사'),
|
||||||
|
('COMPLIANCE_SCREENING_HISTORY', 'KO', '컴플라이언스 심사 이력'),
|
||||||
|
('US_TREASURY_SANCTIONS', 'KO', '미국 재무부 제재'),
|
||||||
|
('NON_US_SANCTIONS', 'KO', '비미국 제재'),
|
||||||
|
('FATF_JURISDICTION', 'KO', 'FATF 관할지역'),
|
||||||
|
('PARENT_COMPANY', 'KO', '모회사'),
|
||||||
|
('OVERALL_COMPLIANCE_STATUS', 'KO', '종합 컴플라이언스 상태'),
|
||||||
|
('RELATED_SCREENING', 'KO', '관련 심사'),
|
||||||
|
('COMPANY_COMPLIANCE_HISTORY', 'KO', '컴플라이언스 심사 변경 이력');
|
||||||
|
|
||||||
|
-- 6. compliance_indicator 테이블: category → category_code 변환
|
||||||
|
-- 먼저 컬럼 추가
|
||||||
|
ALTER TABLE std_snp_data.compliance_indicator ADD COLUMN category_code VARCHAR(50);
|
||||||
|
|
||||||
|
-- 기존 category 값을 category_code로 매핑
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_US_OFAC' WHERE category = 'Sanctions – Ship (US OFAC)';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_US_OFAC' WHERE category = 'Sanctions – Ownership (US OFAC)';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_NON_US' WHERE category = 'Sanctions – Ship (Non-US)';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_NON_US' WHERE category = 'Sanctions – Ownership (Non-US)';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_FATF' WHERE category = 'Sanctions – FATF';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OTHER' WHERE category = 'Sanctions – Other';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'PORT_CALLS' WHERE category = 'Port Calls';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'STS_ACTIVITY' WHERE category = 'STS Activity';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'SUSPICIOUS_BEHAVIOR' WHERE category = 'Suspicious Behavior';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'OWNERSHIP_SCREENING' WHERE category = 'Ownership Screening';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPLIANCE_SCREENING_HISTORY' WHERE category = 'Compliance Screening History';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'US_TREASURY_SANCTIONS' WHERE category = 'US Treasury Sanctions';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'NON_US_SANCTIONS' WHERE category = 'Non-US Sanctions';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'FATF_JURISDICTION' WHERE category = 'FATF Jurisdiction';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'PARENT_COMPANY' WHERE category = 'Parent Company';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'OVERALL_COMPLIANCE_STATUS' WHERE category = 'Overall Compliance Status';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'RELATED_SCREENING' WHERE category = 'Related Screening';
|
||||||
|
UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPANY_COMPLIANCE_HISTORY' WHERE category = 'Compliance Screening Change History';
|
||||||
|
|
||||||
|
-- category_code NOT NULL 설정
|
||||||
|
ALTER TABLE std_snp_data.compliance_indicator ALTER COLUMN category_code SET NOT NULL;
|
||||||
|
|
||||||
|
-- 기존 category 컬럼 삭제
|
||||||
|
ALTER TABLE std_snp_data.compliance_indicator DROP COLUMN category;
|
||||||
@ -16,6 +16,7 @@ const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
|||||||
const Schedules = lazy(() => import('./pages/Schedules'));
|
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||||
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
||||||
|
const BypassCatalog = lazy(() => import('./pages/BypassCatalog'));
|
||||||
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
|
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
|
||||||
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
|
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
|
||||||
|
|
||||||
@ -25,12 +26,24 @@ function AppLayout() {
|
|||||||
const isMainMenu = location.pathname === '/';
|
const isMainMenu = location.pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-wing-bg text-wing-text">
|
<div className="h-screen bg-wing-bg text-wing-text flex flex-col overflow-hidden">
|
||||||
<div className={isMainMenu ? 'px-4' : 'max-w-7xl mx-auto px-4 py-6'}>
|
{/* 메인 화면: 전체화면, 섹션 페이지: 탭 + 스크롤 콘텐츠 */}
|
||||||
<Navbar />
|
{isMainMenu ? (
|
||||||
|
<div className="flex-1 overflow-auto px-4">
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainMenu />} />
|
<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="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/executions" element={<Executions />} />
|
<Route path="/executions" element={<Executions />} />
|
||||||
@ -39,12 +52,15 @@ function AppLayout() {
|
|||||||
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
<Route path="/schedules" element={<Schedules />} />
|
||||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||||
|
<Route path="/bypass-catalog" element={<BypassCatalog />} />
|
||||||
<Route path="/bypass-config" element={<BypassConfig />} />
|
<Route path="/bypass-config" element={<BypassConfig />} />
|
||||||
<Route path="/screening-guide" element={<ScreeningGuide />} />
|
<Route path="/screening-guide" element={<ScreeningGuide />} />
|
||||||
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
|
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,7 +15,6 @@ export interface RiskIndicatorResponse {
|
|||||||
conditionAmber: string;
|
conditionAmber: string;
|
||||||
conditionGreen: string;
|
conditionGreen: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
collectionNote: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RiskCategoryResponse {
|
export interface RiskCategoryResponse {
|
||||||
@ -34,11 +33,11 @@ export interface ComplianceIndicatorResponse {
|
|||||||
conditionAmber: string;
|
conditionAmber: string;
|
||||||
conditionGreen: string;
|
conditionGreen: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
collectionNote: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComplianceCategoryResponse {
|
export interface ComplianceCategoryResponse {
|
||||||
category: string;
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
indicatorType: string;
|
indicatorType: string;
|
||||||
indicators: ComplianceIndicatorResponse[];
|
indicators: ComplianceIndicatorResponse[];
|
||||||
}
|
}
|
||||||
@ -47,10 +46,9 @@ export interface ComplianceCategoryResponse {
|
|||||||
export interface MethodologyHistoryResponse {
|
export interface MethodologyHistoryResponse {
|
||||||
historyId: number;
|
historyId: number;
|
||||||
changeDate: string;
|
changeDate: string;
|
||||||
|
changeTypeCode: string;
|
||||||
changeType: string;
|
changeType: string;
|
||||||
updateTitle: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
collectionNote: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 값 변경 이력 타입
|
// 값 변경 이력 타입
|
||||||
@ -108,6 +106,7 @@ export interface CompanyInfoResponse {
|
|||||||
export interface IndicatorStatusResponse {
|
export interface IndicatorStatusResponse {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
|
categoryCode: string;
|
||||||
category: string;
|
category: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
narrative: string | null;
|
narrative: string | null;
|
||||||
@ -129,6 +128,8 @@ export const screeningGuideApi = {
|
|||||||
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
|
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
|
||||||
getMethodologyHistory: (lang = 'KO') =>
|
getMethodologyHistory: (lang = 'KO') =>
|
||||||
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
|
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
|
||||||
|
getMethodologyBanner: (lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<MethodologyHistoryResponse>>(`${BASE}/methodology-banner?lang=${lang}`),
|
||||||
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
|
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
|
||||||
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
|
||||||
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>
|
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>
|
||||||
|
|||||||
@ -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 ${
|
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
status === key
|
status === key
|
||||||
? key === 'ERROR'
|
? key === 'ERROR'
|
||||||
? 'bg-red-100 text-red-700'
|
? 'bg-red-500/15 text-red-500'
|
||||||
: key === 'SUCCESS'
|
: key === 'SUCCESS'
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-500/15 text-emerald-500'
|
||||||
: 'bg-blue-100 text-blue-700'
|
: 'bg-blue-500/15 text-blue-500'
|
||||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label} ({count.toLocaleString()})
|
{label} ({count.toLocaleString()})
|
||||||
@ -92,7 +92,7 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
|
|||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left">
|
<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>
|
<tr>
|
||||||
<th className="px-2 py-1.5 font-medium">#</th>
|
<th className="px-2 py-1.5 font-medium">#</th>
|
||||||
<th className="px-2 py-1.5 font-medium">URI</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>
|
<th className="px-2 py-1.5 font-medium">에러</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-blue-100">
|
<tbody className="divide-y divide-blue-500/15">
|
||||||
{logData.content.map((log, idx) => {
|
{logData.content.map((log, idx) => {
|
||||||
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
|
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={log.logId}
|
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 text-blue-500">{page * 10 + idx + 1}</td>
|
||||||
<td className="px-2 py-1.5 max-w-[200px]">
|
<td className="px-2 py-1.5 max-w-[200px]">
|
||||||
<div className="flex items-center gap-0.5">
|
<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}
|
{log.requestUri}
|
||||||
</span>
|
</span>
|
||||||
<CopyButton text={log.requestUri} />
|
<CopyButton text={log.requestUri} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td className="px-2 py-1.5">
|
||||||
<span className={`font-semibold ${
|
<span className={`font-semibold ${
|
||||||
log.statusCode == null ? 'text-gray-400'
|
log.statusCode == null ? 'text-gray-400'
|
||||||
@ -132,10 +132,10 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio
|
|||||||
{log.statusCode ?? '-'}
|
{log.statusCode ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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() ?? '-'}
|
{log.responseTimeMs?.toLocaleString() ?? '-'}
|
||||||
</td>
|
</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() ?? '-'}
|
{log.responseCount?.toLocaleString() ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
|
<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';
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
interface NavSection {
|
interface MenuItem {
|
||||||
key: string;
|
id: string;
|
||||||
title: string;
|
label: string;
|
||||||
paths: string[];
|
path: string;
|
||||||
items: { path: string; label: string; icon: 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',
|
id: 'collector',
|
||||||
title: 'S&P Collector',
|
label: 'S&P Collector',
|
||||||
paths: ['/dashboard', '/jobs', '/executions', '/recollects', '/schedules', '/schedule-timeline'],
|
shortLabel: 'Collector',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
<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" />
|
||||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
</svg>
|
||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
),
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
defaultPath: '/dashboard',
|
||||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
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',
|
id: 'bypass',
|
||||||
title: 'S&P Bypass',
|
label: 'S&P Bypass',
|
||||||
paths: ['/bypass-config'],
|
shortLabel: 'Bypass',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/bypass-config', label: 'Bypass API', 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',
|
id: 'risk',
|
||||||
title: 'S&P Risk & Compliance',
|
label: 'S&P Risk & Compliance',
|
||||||
paths: ['/screening-guide', '/risk-compliance-history'],
|
shortLabel: 'Risk & Compliance',
|
||||||
items: [
|
icon: (
|
||||||
{ path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' },
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
{ path: '/risk-compliance-history', label: 'Change History', icon: '📜' },
|
<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 {
|
function getCurrentSection(pathname: string): MenuSection | null {
|
||||||
for (const section of sections) {
|
for (const section of MENU_STRUCTURE) {
|
||||||
if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
|
if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) {
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,56 +79,75 @@ function getCurrentSection(pathname: string): NavSection | null {
|
|||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { theme, toggle } = useThemeContext();
|
const { theme, toggle } = useThemeContext();
|
||||||
const currentSection = getCurrentSection(location.pathname);
|
const currentSection = getCurrentSection(location.pathname);
|
||||||
|
|
||||||
// 메인 화면에서는 Navbar 숨김
|
// 메인 화면에서는 숨김
|
||||||
if (!currentSection) return null;
|
if (!currentSection) return null;
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActivePath = (path: string) => {
|
||||||
if (path === '/dashboard') return location.pathname === '/dashboard';
|
|
||||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="mb-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
{/* 1단: 섹션 탭 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-slate-900 px-6 pt-3 rounded-t-xl flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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"
|
className="flex items-center gap-1 px-3 py-2.5 text-sm text-slate-400 hover:text-white no-underline transition-colors"
|
||||||
title="메인 메뉴"
|
title="메인 메뉴"
|
||||||
>
|
>
|
||||||
← 메인
|
←
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-wing-border">|</span>
|
<div className="flex-1 flex items-center justify-start gap-1">
|
||||||
<span className="text-sm font-bold text-wing-accent">{currentSection.title}</span>
|
{MENU_STRUCTURE.map((section) => (
|
||||||
</div>
|
<button
|
||||||
<div className="flex gap-1 flex-wrap items-center">
|
key={section.id}
|
||||||
{currentSection.items.map((item) => (
|
onClick={() => {
|
||||||
<Link
|
if (currentSection?.id !== section.id) {
|
||||||
key={item.path}
|
navigate(section.defaultPath);
|
||||||
to={item.path}
|
}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
|
}}
|
||||||
${isActive(item.path)
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-t-lg text-sm font-medium transition-all ${
|
||||||
? 'bg-wing-accent text-white'
|
currentSection?.id === section.id
|
||||||
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
|
? '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>
|
{section.icon}
|
||||||
{item.label}
|
<span>{section.shortLabel}</span>
|
||||||
</Link>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
className="px-2.5 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
hover:text-wing-text border border-wing-border transition-colors"
|
|
||||||
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
screeningGuideApi,
|
screeningGuideApi,
|
||||||
type ComplianceCategoryResponse,
|
type ComplianceCategoryResponse,
|
||||||
@ -9,135 +9,105 @@ interface ComplianceTabProps {
|
|||||||
lang: string;
|
lang: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'table' | 'card';
|
|
||||||
type IndicatorType = 'SHIP' | 'COMPANY';
|
type IndicatorType = 'SHIP' | 'COMPANY';
|
||||||
|
type CacheKey = string;
|
||||||
|
|
||||||
const SHIP_CAT_COLORS: Record<string, string> = {
|
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
'Sanctions – Ship (US OFAC)': '#1e3a5f',
|
// SHIP
|
||||||
'Sanctions – Ownership (US OFAC)': '#1d4ed8',
|
'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
|
||||||
'Sanctions – Ship (Non-US)': '#065f46',
|
'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
'Sanctions – Ownership (Non-US)': '#0f766e',
|
'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
|
||||||
'Sanctions – FATF': '#6b21a8',
|
'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
'Sanctions – Other': '#991b1b',
|
'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
'Port Calls': '#065f46',
|
'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
|
||||||
'STS Activity': '#0f766e',
|
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
'Dark Activity': '#374151',
|
'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
|
'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' },
|
||||||
|
'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' },
|
||||||
|
'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' },
|
||||||
|
// COMPANY
|
||||||
|
'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' },
|
||||||
|
'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
|
'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
|
'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' },
|
||||||
|
'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPANY_CAT_COLORS: Record<string, string> = {
|
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
|
||||||
'Company Sanctions (US OFAC)': '#1e3a5f',
|
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
|
||||||
'Company Sanctions (Non-US)': '#065f46',
|
|
||||||
'Company Compliance': '#6b21a8',
|
|
||||||
'Company Risk': '#92400e',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCatHex(categoryName: string, type: IndicatorType): string {
|
|
||||||
const map = type === 'SHIP' ? SHIP_CAT_COLORS : COMPANY_CAT_COLORS;
|
|
||||||
return map[categoryName] ?? '#374151';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlatRow {
|
|
||||||
category: string;
|
|
||||||
indicatorType: string;
|
|
||||||
indicator: ComplianceIndicatorResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComplianceTab({ lang }: ComplianceTabProps) {
|
export default function ComplianceTab({ lang }: ComplianceTabProps) {
|
||||||
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
|
||||||
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
|
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string, type: IndicatorType) => {
|
||||||
|
return screeningGuideApi
|
||||||
|
.getComplianceIndicators(fetchLang, type)
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data ?? [];
|
||||||
|
cache.current.set(`${type}_${fetchLang}`, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedCategory('전체');
|
setExpandedCategories(new Set());
|
||||||
screeningGuideApi
|
cache.current.clear();
|
||||||
.getComplianceIndicators(lang, indicatorType)
|
|
||||||
.then((res) => setCategories(res.data ?? []))
|
Promise.all([
|
||||||
|
fetchData('KO', indicatorType),
|
||||||
|
fetchData('EN', indicatorType),
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
|
||||||
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
}, [indicatorType, fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(`${indicatorType}_${lang}`);
|
||||||
|
if (cached) {
|
||||||
|
setCategories(cached);
|
||||||
|
}
|
||||||
}, [lang, indicatorType]);
|
}, [lang, indicatorType]);
|
||||||
|
|
||||||
const flatRows: FlatRow[] = categories.flatMap((cat) =>
|
const toggleCategory = (category: string) => {
|
||||||
cat.indicators.map((ind) => ({
|
setExpandedCategories((prev) => {
|
||||||
category: cat.category,
|
const next = new Set(prev);
|
||||||
indicatorType: cat.indicatorType,
|
if (next.has(category)) {
|
||||||
indicator: ind,
|
next.delete(category);
|
||||||
})),
|
} else {
|
||||||
);
|
next.add(category);
|
||||||
|
|
||||||
const filtered: FlatRow[] =
|
|
||||||
selectedCategory === '전체'
|
|
||||||
? flatRows
|
|
||||||
: flatRows.filter((r) => r.category === selectedCategory);
|
|
||||||
|
|
||||||
const uniqueCategories = Array.from(new Set(flatRows.map((r) => r.category)));
|
|
||||||
|
|
||||||
function downloadCSV() {
|
|
||||||
const bom = '\uFEFF';
|
|
||||||
const headers = [
|
|
||||||
'카테고리',
|
|
||||||
'타입',
|
|
||||||
'필드키',
|
|
||||||
'필드명',
|
|
||||||
'설명',
|
|
||||||
'RED 조건',
|
|
||||||
'AMBER 조건',
|
|
||||||
'GREEN 조건',
|
|
||||||
'데이터 타입',
|
|
||||||
'이력 관리 참고사항',
|
|
||||||
];
|
|
||||||
const rows = flatRows.map((r) =>
|
|
||||||
[
|
|
||||||
r.category,
|
|
||||||
r.indicatorType,
|
|
||||||
r.indicator.fieldKey,
|
|
||||||
r.indicator.fieldName,
|
|
||||||
r.indicator.description,
|
|
||||||
r.indicator.conditionRed,
|
|
||||||
r.indicator.conditionAmber,
|
|
||||||
r.indicator.conditionGreen,
|
|
||||||
r.indicator.dataType,
|
|
||||||
r.indicator.collectionNote,
|
|
||||||
]
|
|
||||||
.map((v) => `"${(v ?? '').replace(/"/g, '""')}"`)
|
|
||||||
.join(','),
|
|
||||||
);
|
|
||||||
const csv = bom + [headers.join(','), ...rows].join('\n');
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = `MIRS_Compliance_${indicatorType}.csv`;
|
|
||||||
a.click();
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* SHIP / COMPANY 토글 */}
|
{/* Ship / Company 토글 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{(['SHIP', 'COMPANY'] as const).map((type) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIndicatorType('SHIP')}
|
key={type}
|
||||||
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
|
onClick={() => setIndicatorType(type)}
|
||||||
indicatorType === 'SHIP'
|
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
||||||
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
indicatorType === type
|
||||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
? 'bg-wing-text text-wing-bg shadow-sm'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
선박 컴플라이언스 (SHIP)
|
{type === 'SHIP' ? 'Ship' : 'Company'}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@ -156,230 +126,101 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
{/* 카테고리 요약 카드 */}
|
{categories.map((cat) => {
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
const isExpanded = expandedCategories.has(cat.categoryCode);
|
||||||
{uniqueCategories.map((catName) => {
|
const badge = getBadgeColor(cat.categoryCode);
|
||||||
const count = flatRows.filter((r) => r.category === catName).length;
|
|
||||||
const isActive = selectedCategory === catName;
|
|
||||||
const hex = getCatHex(catName, indicatorType);
|
|
||||||
return (
|
return (
|
||||||
<button
|
|
||||||
key={catName}
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedCategory(isActive ? '전체' : catName)
|
|
||||||
}
|
|
||||||
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2 text-left"
|
|
||||||
style={{
|
|
||||||
background: isActive ? hex : undefined,
|
|
||||||
borderColor: isActive ? hex : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
|
key={cat.categoryCode}
|
||||||
|
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
|
||||||
>
|
>
|
||||||
{count}
|
{/* 아코디언 헤더 */}
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
|
|
||||||
>
|
|
||||||
{catName}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컨트롤 바 */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCategory('전체')}
|
onClick={() => toggleCategory(cat.categoryCode)}
|
||||||
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${
|
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
|
||||||
selectedCategory === '전체'
|
|
||||||
? 'bg-slate-900 text-white border-slate-900'
|
|
||||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
전체 ({flatRows.length})
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<span className="text-xs text-wing-muted">
|
|
||||||
표시:{' '}
|
|
||||||
<strong className="text-wing-text">{filtered.length}</strong>개 항목
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
|
|
||||||
>
|
|
||||||
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={downloadCSV}
|
|
||||||
className="px-4 py-1.5 rounded-lg bg-green-600 text-white text-xs font-bold hover:bg-green-700 transition-colors"
|
|
||||||
>
|
|
||||||
⬇ CSV 다운로드
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 뷰 */}
|
|
||||||
{viewMode === 'table' && (
|
|
||||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-900 text-white">
|
|
||||||
{[
|
|
||||||
'카테고리',
|
|
||||||
'필드명',
|
|
||||||
'설명',
|
|
||||||
'🔴 RED',
|
|
||||||
'🟡 AMBER',
|
|
||||||
'🟢 GREEN',
|
|
||||||
'데이터 타입',
|
|
||||||
'이력 관리 참고',
|
|
||||||
].map((h) => (
|
|
||||||
<th
|
|
||||||
key={h}
|
|
||||||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</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
|
<span
|
||||||
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||||
style={{ background: hex }}
|
style={{ background: badge.bg, color: badge.text }}
|
||||||
>
|
>
|
||||||
{row.category}
|
{cat.categoryName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="text-sm font-semibold text-wing-text text-left flex-1">
|
||||||
</td>
|
{cat.categoryName}
|
||||||
<td className="px-3 py-2.5 min-w-[160px]">
|
</span>
|
||||||
<div className="font-bold text-wing-text">
|
<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">
|
||||||
{row.indicator.fieldName}
|
{cat.indicators.length}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-wing-muted text-[10px] mt-0.5">
|
<svg
|
||||||
{row.indicator.fieldKey}
|
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
</div>
|
fill="none"
|
||||||
</td>
|
stroke="currentColor"
|
||||||
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
|
viewBox="0 0 24 24"
|
||||||
{row.indicator.description}
|
>
|
||||||
</td>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
<td className="px-3 py-2.5 min-w-[130px]">
|
</svg>
|
||||||
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
|
</button>
|
||||||
{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' && (
|
{isExpanded && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
|
||||||
{filtered.map((row, i) => {
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
const hex = getCatHex(row.category, indicatorType);
|
{cat.indicators.map((ind) => (
|
||||||
return (
|
<IndicatorCard key={ind.indicatorId} indicator={ind} />
|
||||||
<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>
|
||||||
<div className="p-4">
|
|
||||||
<div className="font-bold text-sm text-wing-text mb-0.5">
|
|
||||||
{row.indicator.fieldName}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-wing-muted mb-3">
|
|
||||||
{row.indicator.fieldKey}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-wing-text leading-relaxed mb-3">
|
|
||||||
{row.indicator.description}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
|
||||||
<div className="bg-red-50 rounded-lg p-2">
|
|
||||||
<div className="text-[10px] font-bold text-red-800 mb-1">
|
|
||||||
🔴 RED
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-red-800">
|
|
||||||
{row.indicator.conditionRed}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-50 rounded-lg p-2">
|
|
||||||
<div className="text-[10px] font-bold text-amber-800 mb-1">
|
|
||||||
🟡 AMBER
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-amber-800">
|
|
||||||
{row.indicator.conditionAmber}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-2">
|
|
||||||
<div className="text-[10px] font-bold text-green-800 mb-1">
|
|
||||||
🟢 GREEN
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-green-800">
|
|
||||||
{row.indicator.conditionGreen}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{row.indicator.collectionNote && (
|
|
||||||
<div className="bg-blue-50 rounded-lg p-3 text-[11px] text-blue-700 leading-relaxed">
|
|
||||||
💡 {row.indicator.collectionNote}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="font-bold text-sm text-wing-text">
|
||||||
|
{indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
{indicator.dataType && (
|
||||||
|
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
|
||||||
|
{indicator.dataType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{indicator.description && (
|
||||||
|
<div className="text-xs text-wing-muted leading-relaxed mb-3 whitespace-pre-line">
|
||||||
|
{indicator.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{indicator.conditionRed && (
|
||||||
|
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionAmber && (
|
||||||
|
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionGreen && (
|
||||||
|
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -166,12 +166,13 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선박 Compliance 탭 분류
|
// 선박 Compliance 탭 분류 (categoryCode 기반)
|
||||||
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [
|
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [
|
||||||
{ key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') },
|
{ key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') },
|
||||||
{ key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' },
|
{ key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
|
||||||
{ key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' },
|
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
|
||||||
{ key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' },
|
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
|
||||||
|
{ key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compliance 예외 처리
|
// Compliance 예외 처리
|
||||||
@ -252,7 +253,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
|
|||||||
const tabData = useMemo(() => {
|
const tabData = useMemo(() => {
|
||||||
const result: Record<string, IndicatorStatusResponse[]> = {};
|
const result: Record<string, IndicatorStatusResponse[]> = {};
|
||||||
for (const tab of SHIP_COMPLIANCE_TABS) {
|
for (const tab of SHIP_COMPLIANCE_TABS) {
|
||||||
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
|
result[tab.key] = filteredItems.filter((i) => tab.match(i.categoryCode));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [filteredItems]);
|
}, [filteredItems]);
|
||||||
@ -461,22 +462,24 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 이력 유형 선택 */}
|
{/* 이력 유형 선택 (언더라인 탭) */}
|
||||||
<div className="flex gap-2 flex-wrap justify-center">
|
<div className="border-b border-wing-border">
|
||||||
|
<div className="flex gap-6">
|
||||||
{HISTORY_TYPES.map((t) => (
|
{HISTORY_TYPES.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
onClick={() => handleTypeChange(t.key)}
|
onClick={() => handleTypeChange(t.key)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
|
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
|
||||||
historyType === t.key
|
historyType === t.key
|
||||||
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
? 'text-blue-600 border-blue-600'
|
||||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
: 'text-wing-muted border-transparent hover:text-wing-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex gap-3 items-center justify-center">
|
<div className="flex gap-3 items-center justify-center">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
|
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
interface MethodologyTabProps {
|
interface MethodologyTabProps {
|
||||||
@ -18,20 +18,60 @@ function getChangeTypeColor(changeType: string): string {
|
|||||||
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
|
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LangKey = 'KO' | 'EN';
|
||||||
|
|
||||||
|
interface LangCache {
|
||||||
|
history: MethodologyHistoryResponse[];
|
||||||
|
banner: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
||||||
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
|
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
|
||||||
|
const [banner, setBanner] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState('전체');
|
const [selectedType, setSelectedType] = useState('전체');
|
||||||
|
const cache = useRef<Map<LangKey, LangCache>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string) => {
|
||||||
|
return Promise.all([
|
||||||
|
screeningGuideApi.getMethodologyHistory(fetchLang),
|
||||||
|
screeningGuideApi.getMethodologyBanner(fetchLang).catch(() => ({ data: null })),
|
||||||
|
]).then(([historyRes, bannerRes]) => {
|
||||||
|
const data: LangCache = {
|
||||||
|
history: historyRes.data ?? [],
|
||||||
|
banner: bannerRes.data?.description ?? '',
|
||||||
|
};
|
||||||
|
cache.current.set(fetchLang as LangKey, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드: KO/EN 데이터 모두 가져와서 캐싱
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
screeningGuideApi
|
cache.current.clear();
|
||||||
.getMethodologyHistory(lang)
|
|
||||||
.then((res) => setHistory(res.data ?? []))
|
Promise.all([fetchData('KO'), fetchData('EN')])
|
||||||
|
.then(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setHistory(cached.history);
|
||||||
|
setBanner(cached.banner);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 언어 변경: 캐시에서 스위칭
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setHistory(cached.history);
|
||||||
|
setBanner(cached.banner);
|
||||||
|
}
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const sortedHistory = [...history].sort((a, b) =>
|
const sortedHistory = [...history].sort((a, b) =>
|
||||||
@ -67,11 +107,11 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 주의사항 배너 */}
|
{/* 주의사항 배너 */}
|
||||||
|
{banner && (
|
||||||
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
|
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
|
||||||
<strong>이력 관리 주의사항:</strong> 방법론 변경은 선박·기업의 컴플라이언스
|
{banner}
|
||||||
상태값을 자동으로 변경시킵니다. 상태 변경이 실제 리스크 변화인지, 방법론
|
|
||||||
업데이트 때문인지 반드시 교차 확인해야 합니다.
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 변경 유형 필터 */}
|
{/* 변경 유형 필터 */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@ -116,7 +156,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
|||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900 text-white">
|
<tr className="bg-slate-900 text-white">
|
||||||
{['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => (
|
{['날짜', '변경 유형', '설명'].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||||
@ -147,14 +187,8 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
|||||||
{row.changeType}
|
{row.changeType}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 min-w-[180px] font-semibold text-wing-text leading-relaxed">
|
|
||||||
{row.updateTitle}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
|
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
|
||||||
{row.description}
|
{row.description || '-'}
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 min-w-[200px] text-blue-600 leading-relaxed">
|
|
||||||
{row.collectionNote && `💡 ${row.collectionNote}`}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,106 +1,74 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
|
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
interface RiskTabProps {
|
interface RiskTabProps {
|
||||||
lang: string;
|
lang: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'table' | 'card';
|
type LangKey = 'KO' | 'EN';
|
||||||
|
|
||||||
const CAT_COLORS: Record<string, string> = {
|
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
'AIS': 'bg-blue-800',
|
'AIS': { bg: '#dbeafe', text: '#1e40af' },
|
||||||
'Port Calls': 'bg-emerald-800',
|
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
'Associated with Russia': 'bg-red-800',
|
'ASSOCIATED_WITH_RUSSIA': { bg: '#fee2e2', text: '#991b1b' },
|
||||||
'Behavioural Risk': 'bg-amber-800',
|
'BEHAVIOURAL_RISK': { bg: '#fef3c7', text: '#92400e' },
|
||||||
'Safety, Security & Inspections': 'bg-blue-600',
|
'SAFETY_SECURITY_AND_INSPECTIONS': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
'Flag Risk': 'bg-purple-800',
|
'FLAG_RISK': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
'Owner & Classification': 'bg-teal-700',
|
'OWNER_AND_CLASSIFICATION': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAT_HEX: Record<string, string> = {
|
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
|
||||||
'AIS': '#1e40af',
|
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
|
||||||
'Port Calls': '#065f46',
|
|
||||||
'Associated with Russia': '#991b1b',
|
|
||||||
'Behavioural Risk': '#92400e',
|
|
||||||
'Safety, Security & Inspections': '#1d4ed8',
|
|
||||||
'Flag Risk': '#6b21a8',
|
|
||||||
'Owner & Classification': '#0f766e',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCatColor(categoryName: string): string {
|
|
||||||
return CAT_COLORS[categoryName] ?? 'bg-slate-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCatHex(categoryName: string): string {
|
|
||||||
return CAT_HEX[categoryName] ?? '#374151';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlatRow {
|
|
||||||
category: string;
|
|
||||||
indicator: RiskIndicatorResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RiskTab({ lang }: RiskTabProps) {
|
export default function RiskTab({ lang }: RiskTabProps) {
|
||||||
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
|
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('전체');
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
const cache = useRef<Map<LangKey, RiskCategoryResponse[]>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string) => {
|
||||||
|
return screeningGuideApi
|
||||||
|
.getRiskIndicators(fetchLang)
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data ?? [];
|
||||||
|
cache.current.set(fetchLang as LangKey, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
screeningGuideApi
|
cache.current.clear();
|
||||||
.getRiskIndicators(lang)
|
|
||||||
.then((res) => setCategories(res.data ?? []))
|
Promise.all([fetchData('KO'), fetchData('EN')])
|
||||||
|
.then(() => {
|
||||||
|
setCategories(cache.current.get(lang as LangKey) ?? []);
|
||||||
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setCategories(cached);
|
||||||
|
}
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const flatRows: FlatRow[] = categories.flatMap((cat) =>
|
const toggleCategory = (code: string) => {
|
||||||
cat.indicators.map((ind) => ({ category: cat.categoryName, indicator: ind })),
|
setExpandedCategories((prev) => {
|
||||||
);
|
const next = new Set(prev);
|
||||||
|
if (next.has(code)) {
|
||||||
const filtered: FlatRow[] =
|
next.delete(code);
|
||||||
selectedCategory === '전체'
|
} else {
|
||||||
? flatRows
|
next.add(code);
|
||||||
: flatRows.filter((r) => r.category === selectedCategory);
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -122,226 +90,95 @@ export default function RiskTab({ lang }: RiskTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
{/* 카테고리 요약 카드 */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 lg:grid-cols-7">
|
|
||||||
{categories.map((cat) => {
|
{categories.map((cat) => {
|
||||||
const isActive = selectedCategory === cat.categoryName;
|
const isExpanded = expandedCategories.has(cat.categoryCode);
|
||||||
const hex = getCatHex(cat.categoryName);
|
const badge = getBadgeColor(cat.categoryCode);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={cat.categoryCode}
|
key={cat.categoryCode}
|
||||||
onClick={() =>
|
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
|
||||||
setSelectedCategory(isActive ? '전체' : cat.categoryName)
|
|
||||||
}
|
|
||||||
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2"
|
|
||||||
style={{
|
|
||||||
background: isActive ? hex : undefined,
|
|
||||||
borderColor: isActive ? hex : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
|
onClick={() => toggleCategory(cat.categoryCode)}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
|
||||||
>
|
>
|
||||||
{cat.indicators.length}
|
<span
|
||||||
</div>
|
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||||
<div
|
style={{ background: badge.bg, color: badge.text }}
|
||||||
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
|
|
||||||
>
|
>
|
||||||
{cat.categoryName}
|
{cat.categoryName}
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컨트롤 바 */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedCategory('전체')}
|
|
||||||
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${
|
|
||||||
selectedCategory === '전체'
|
|
||||||
? 'bg-slate-900 text-white border-slate-900'
|
|
||||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
전체 ({flatRows.length})
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<span className="text-xs text-wing-muted">
|
|
||||||
표시:{' '}
|
|
||||||
<strong className="text-wing-text">{filtered.length}</strong>개 항목
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<span className="text-sm font-semibold text-wing-text text-left flex-1">
|
||||||
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
|
{cat.categoryName}
|
||||||
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
|
</span>
|
||||||
|
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
|
||||||
|
{cat.indicators.length}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 테이블 뷰 */}
|
{isExpanded && (
|
||||||
{viewMode === 'table' && (
|
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
|
||||||
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
<div className="overflow-x-auto">
|
{cat.indicators.map((ind) => (
|
||||||
<table className="w-full text-xs border-collapse">
|
<IndicatorCard key={ind.indicatorId} indicator={ind} />
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-900 text-white">
|
|
||||||
{[
|
|
||||||
'카테고리',
|
|
||||||
'필드명',
|
|
||||||
'설명',
|
|
||||||
'🔴 RED',
|
|
||||||
'🟡 AMBER',
|
|
||||||
'🟢 GREEN',
|
|
||||||
'데이터 타입',
|
|
||||||
'이력 관리 참고',
|
|
||||||
].map((h) => (
|
|
||||||
<th
|
|
||||||
key={h}
|
|
||||||
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
{filtered.map((row, i) => {
|
|
||||||
const showCat =
|
|
||||||
i === 0 ||
|
|
||||||
filtered[i - 1].category !== row.category;
|
|
||||||
const hex = getCatHex(row.category);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${row.indicator.indicatorId}-${i}`}
|
|
||||||
className="border-b border-wing-border align-top even:bg-wing-card"
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2.5 min-w-[110px]">
|
|
||||||
{showCat && (
|
|
||||||
<span
|
|
||||||
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
|
||||||
style={{ background: hex }}
|
|
||||||
>
|
|
||||||
{row.category}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[160px]">
|
|
||||||
<div className="font-bold text-wing-text">
|
|
||||||
{row.indicator.fieldName}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-wing-muted text-[10px] mt-0.5">
|
|
||||||
{row.indicator.fieldKey}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
|
|
||||||
{row.indicator.description}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[130px]">
|
|
||||||
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
|
|
||||||
{row.indicator.conditionRed}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[130px]">
|
|
||||||
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
|
|
||||||
{row.indicator.conditionAmber}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[130px]">
|
|
||||||
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
|
|
||||||
{row.indicator.conditionGreen}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
|
|
||||||
{row.indicator.dataType}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
|
|
||||||
{row.indicator.collectionNote &&
|
|
||||||
`💡 ${row.indicator.collectionNote}`}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
}
|
||||||
|
|
||||||
{/* 카드 뷰 */}
|
function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) {
|
||||||
{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 (
|
return (
|
||||||
<div
|
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
|
||||||
key={`${row.indicator.indicatorId}-${i}`}
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
<div className="font-bold text-sm text-wing-text">
|
||||||
>
|
{indicator.fieldName}
|
||||||
<div
|
</div>
|
||||||
className={`${colorClass} px-4 py-2.5 flex justify-between items-center`}
|
{indicator.dataType && (
|
||||||
style={{ background: hex }}
|
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
|
||||||
>
|
{indicator.dataType}
|
||||||
<span className="text-white text-[10px] font-bold">
|
|
||||||
{row.category}
|
|
||||||
</span>
|
</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.description && (
|
||||||
|
<div className="text-xs text-wing-muted leading-relaxed mb-3">
|
||||||
|
{indicator.description}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{indicator.conditionRed && (
|
||||||
|
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionAmber && (
|
||||||
|
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionGreen && (
|
||||||
|
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
311
frontend/src/pages/BypassCatalog.tsx
Normal file
311
frontend/src/pages/BypassCatalog.tsx
Normal file
@ -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 [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
||||||
|
const [codeGenEnabled, setCodeGenEnabled] = useState(true);
|
||||||
|
|
||||||
const loadConfigs = useCallback(async () => {
|
const loadConfigs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -59,6 +60,10 @@ export default function BypassConfig() {
|
|||||||
bypassApi.getWebclientBeans()
|
bypassApi.getWebclientBeans()
|
||||||
.then((res) => setWebclientBeans(res.data ?? []))
|
.then((res) => setWebclientBeans(res.data ?? []))
|
||||||
.catch((err) => console.error(err));
|
.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]);
|
}, [loadConfigs]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
@ -314,7 +319,13 @@ export default function BypassConfig() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmAction({ type: 'generate', config })}
|
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 ? '재생성' : '코드 생성'}
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
</button>
|
</button>
|
||||||
@ -416,7 +427,13 @@ export default function BypassConfig() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmAction({ type: 'generate', config })}
|
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 ? '재생성' : '코드 생성'}
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -96,32 +96,32 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
|
|||||||
|
|
||||||
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
|
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
|
||||||
{step.apiLogSummary ? (
|
{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>
|
<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="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-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">총 호출</p>
|
<p className="text-[10px] text-wing-muted">총 호출</p>
|
||||||
</div>
|
</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-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">성공</p>
|
<p className="text-[10px] text-wing-muted">성공</p>
|
||||||
</div>
|
</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'}`}>
|
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
|
||||||
{step.apiLogSummary.errorCount.toLocaleString()}
|
{step.apiLogSummary.errorCount.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-wing-muted">에러</p>
|
<p className="text-[10px] text-wing-muted">에러</p>
|
||||||
</div>
|
</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-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
||||||
</div>
|
</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-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
||||||
</div>
|
</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-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
||||||
</div>
|
</div>
|
||||||
@ -132,7 +132,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : step.apiCallInfo && (
|
) : 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>
|
<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 className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
@ -163,7 +163,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step.exitMessage && (
|
{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 font-medium text-red-700 mb-1">Exit Message</p>
|
||||||
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||||
{step.exitMessage}
|
{step.exitMessage}
|
||||||
@ -468,7 +468,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@ -522,7 +522,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left">
|
<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>
|
<tr>
|
||||||
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
||||||
<th className="px-2 py-1.5 font-medium">에러 메시지</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>
|
<th className="px-2 py-1.5 font-medium">생성 시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-red-100">
|
<tbody className="divide-y divide-red-500/20">
|
||||||
{pagedRecords.map((record) => (
|
{pagedRecords.map((record) => (
|
||||||
<tr
|
<tr
|
||||||
key={record.id}
|
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">
|
<td className="px-2 py-1.5 font-mono text-red-900">
|
||||||
{record.recordKey}
|
{record.recordKey}
|
||||||
@ -581,19 +581,19 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 재수집 확인 다이얼로그 */}
|
{/* 재수집 확인 다이얼로그 */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
실패 건 재수집 확인
|
실패 건 재수집 확인
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-wing-muted mb-3">
|
<p className="text-sm text-wing-muted mb-3">
|
||||||
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
||||||
</p>
|
</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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{failedRecords.map((r) => (
|
{failedRecords.map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r.id}
|
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}
|
{r.recordKey}
|
||||||
</span>
|
</span>
|
||||||
@ -604,7 +604,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
disabled={retrying}
|
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>
|
</button>
|
||||||
@ -630,7 +630,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
||||||
{showResolveConfirm && (
|
{showResolveConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
일괄 RESOLVED 확인
|
일괄 RESOLVED 확인
|
||||||
</h3>
|
</h3>
|
||||||
@ -642,7 +642,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowResolveConfirm(false)}
|
onClick={() => setShowResolveConfirm(false)}
|
||||||
disabled={resolving}
|
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>
|
</button>
|
||||||
@ -668,7 +668,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 재시도 초기화 확인 다이얼로그 */}
|
{/* 재시도 초기화 확인 다이얼로그 */}
|
||||||
{showResetConfirm && (
|
{showResetConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
재시도 초기화 확인
|
재시도 초기화 확인
|
||||||
</h3>
|
</h3>
|
||||||
@ -680,7 +680,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowResetConfirm(false)}
|
onClick={() => setShowResetConfirm(false)}
|
||||||
disabled={resetting}
|
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>
|
</button>
|
||||||
|
|||||||
@ -376,11 +376,11 @@ export default function Jobs() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 pt-0.5">
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
{job.scheduleCron ? (
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -478,11 +478,11 @@ export default function Jobs() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{job.scheduleCron ? (
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const sections = [
|
|||||||
title: 'S&P Bypass',
|
title: 'S&P Bypass',
|
||||||
description: 'S&P Bypass API 관리',
|
description: 'S&P Bypass API 관리',
|
||||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
detail: 'API 등록, 코드 생성 관리, 테스트',
|
||||||
path: '/bypass-config',
|
path: '/bypass-catalog',
|
||||||
icon: '🔗',
|
icon: '🔗',
|
||||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||||
menuCount: 1,
|
menuCount: 1,
|
||||||
@ -24,7 +24,7 @@ const sections = [
|
|||||||
title: 'S&P Risk & Compliance',
|
title: 'S&P Risk & Compliance',
|
||||||
description: 'S&P 위험 지표 및 규정 준수',
|
description: 'S&P 위험 지표 및 규정 준수',
|
||||||
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
|
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
|
||||||
path: '/screening-guide',
|
path: '/risk-compliance-history',
|
||||||
icon: '⚖️',
|
icon: '⚖️',
|
||||||
iconClass: 'gc-card-icon gc-card-icon-nexus',
|
iconClass: 'gc-card-icon gc-card-icon-nexus',
|
||||||
menuCount: 2,
|
menuCount: 2,
|
||||||
|
|||||||
@ -75,32 +75,32 @@ function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; j
|
|||||||
|
|
||||||
{/* API 호출 로그 요약 (batch_api_log 기반) */}
|
{/* API 호출 로그 요약 (batch_api_log 기반) */}
|
||||||
{summary && (
|
{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>
|
<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="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-sm font-bold text-wing-text">{summary.totalCalls.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">총 호출</p>
|
<p className="text-[10px] text-wing-muted">총 호출</p>
|
||||||
</div>
|
</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-sm font-bold text-emerald-600">{summary.successCount.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">성공</p>
|
<p className="text-[10px] text-wing-muted">성공</p>
|
||||||
</div>
|
</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'}`}>
|
<p className={`text-sm font-bold ${summary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
|
||||||
{summary.errorCount.toLocaleString()}
|
{summary.errorCount.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-wing-muted">에러</p>
|
<p className="text-[10px] text-wing-muted">에러</p>
|
||||||
</div>
|
</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-sm font-bold text-blue-600">{Math.round(summary.avgResponseMs).toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
<p className="text-[10px] text-wing-muted">평균(ms)</p>
|
||||||
</div>
|
</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-sm font-bold text-red-500">{summary.maxResponseMs.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
<p className="text-[10px] text-wing-muted">최대(ms)</p>
|
||||||
</div>
|
</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-sm font-bold text-emerald-500">{summary.minResponseMs.toLocaleString()}</p>
|
||||||
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
<p className="text-[10px] text-wing-muted">최소(ms)</p>
|
||||||
</div>
|
</div>
|
||||||
@ -118,7 +118,7 @@ function StepCard({ step, jobName, jobExecutionId }: { step: StepExecutionDto; j
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step.exitMessage && (
|
{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 font-medium text-red-700 mb-1">Exit Message</p>
|
||||||
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
|
||||||
{step.exitMessage}
|
{step.exitMessage}
|
||||||
@ -361,7 +361,7 @@ export default function RecollectDetail() {
|
|||||||
<h2 className="text-lg font-semibold text-red-600 mb-3">
|
<h2 className="text-lg font-semibold text-red-600 mb-3">
|
||||||
실패 사유
|
실패 사유
|
||||||
</h2>
|
</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}
|
{history.failureReason}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@ -536,7 +536,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@ -590,7 +590,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs text-left">
|
<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>
|
<tr>
|
||||||
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
<th className="px-2 py-1.5 font-medium">Record Key</th>
|
||||||
<th className="px-2 py-1.5 font-medium">에러 메시지</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>
|
<th className="px-2 py-1.5 font-medium">생성 시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-red-100">
|
<tbody className="divide-y divide-red-500/20">
|
||||||
{pagedRecords.map((record) => (
|
{pagedRecords.map((record) => (
|
||||||
<tr
|
<tr
|
||||||
key={record.id}
|
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">
|
<td className="px-2 py-1.5 font-mono text-red-900">
|
||||||
{record.recordKey}
|
{record.recordKey}
|
||||||
@ -649,19 +649,19 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 재수집 확인 다이얼로그 */}
|
{/* 재수집 확인 다이얼로그 */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
실패 건 재수집 확인
|
실패 건 재수집 확인
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-wing-muted mb-3">
|
<p className="text-sm text-wing-muted mb-3">
|
||||||
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
다음 {failedRecords.length}건의 IMO에 대해 재수집을 실행합니다.
|
||||||
</p>
|
</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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{failedRecords.map((r) => (
|
{failedRecords.map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r.id}
|
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}
|
{r.recordKey}
|
||||||
</span>
|
</span>
|
||||||
@ -672,7 +672,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
disabled={retrying}
|
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>
|
</button>
|
||||||
@ -698,7 +698,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
{/* 일괄 RESOLVED 확인 다이얼로그 */}
|
||||||
{showResolveConfirm && (
|
{showResolveConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
일괄 RESOLVED 확인
|
일괄 RESOLVED 확인
|
||||||
</h3>
|
</h3>
|
||||||
@ -710,7 +710,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowResolveConfirm(false)}
|
onClick={() => setShowResolveConfirm(false)}
|
||||||
disabled={resolving}
|
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>
|
</button>
|
||||||
@ -736,7 +736,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
{/* 재시도 초기화 확인 다이얼로그 */}
|
{/* 재시도 초기화 확인 다이얼로그 */}
|
||||||
{showResetConfirm && (
|
{showResetConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<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 className="text-lg font-semibold text-wing-text mb-2">
|
||||||
재시도 초기화 확인
|
재시도 초기화 확인
|
||||||
</h3>
|
</h3>
|
||||||
@ -748,7 +748,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowResetConfirm(false)}
|
onClick={() => setShowResetConfirm(false)}
|
||||||
disabled={resetting}
|
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>
|
</button>
|
||||||
|
|||||||
@ -6,42 +6,28 @@ export default function RiskComplianceHistory() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 + 언어 토글 */}
|
||||||
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs opacity-75 mb-1">
|
<div>
|
||||||
S&P Global · Maritime Intelligence Risk Suite (MIRS)
|
<h1 className="text-2xl font-bold text-wing-text">Risk & Compliance Change History</h1>
|
||||||
</div>
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
<h1 className="text-xl font-bold mb-1">
|
S&P 위험 지표 및 규정 준수 값 변경 이력
|
||||||
Risk & Compliance Change History
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm opacity-85">
|
|
||||||
위험 지표 및 컴플라이언스 값 변경 이력
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
|
||||||
{/* 언어 토글 */}
|
{(['EN', 'KO'] as const).map((l) => (
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setLang('EN')}
|
key={l}
|
||||||
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
onClick={() => setLang(l)}
|
||||||
lang === 'EN'
|
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
|
||||||
? 'bg-slate-900 text-white'
|
lang === l
|
||||||
|
? 'bg-wing-text text-wing-bg'
|
||||||
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
EN
|
{l}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,97 +3,70 @@ import RiskTab from '../components/screening/RiskTab';
|
|||||||
import ComplianceTab from '../components/screening/ComplianceTab';
|
import ComplianceTab from '../components/screening/ComplianceTab';
|
||||||
import MethodologyTab from '../components/screening/MethodologyTab';
|
import MethodologyTab from '../components/screening/MethodologyTab';
|
||||||
|
|
||||||
type ActiveTab = 'risk' | 'compliance' | 'methodology';
|
type ActiveTab = 'compliance' | 'risk' | 'methodology';
|
||||||
|
|
||||||
interface TabButtonProps {
|
const TABS: { key: ActiveTab; label: string }[] = [
|
||||||
active: boolean;
|
{ key: 'compliance', label: 'Compliance' },
|
||||||
onClick: () => void;
|
{ key: 'risk', label: 'Risk Indicators' },
|
||||||
children: React.ReactNode;
|
{ key: 'methodology', label: 'Methodology History' },
|
||||||
}
|
];
|
||||||
|
|
||||||
function TabButton({ active, onClick, children }: TabButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
|
|
||||||
active
|
|
||||||
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
|
||||||
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScreeningGuide() {
|
export default function ScreeningGuide() {
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('risk');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('compliance');
|
||||||
const [lang, setLang] = useState('KO');
|
const [lang, setLang] = useState('EN');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
|
<div className="flex items-start justify-between">
|
||||||
<div className="text-xs opacity-75 mb-1">
|
<div>
|
||||||
S&P Global · Maritime Intelligence Risk Suite (MIRS)
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-bold mb-1">
|
|
||||||
Risk & Compliance Screening Guide
|
Risk & Compliance Screening Guide
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm opacity-85">
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
위험 지표 및 컴플라이언스 심사 기준 가이드
|
S&P Risk Indicators and Regulatory Compliance Screening Guide
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 언어 토글 */}
|
||||||
{/* 탭 + 언어 토글 */}
|
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
{(['EN', 'KO'] as const).map((l) => (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<button
|
||||||
<TabButton
|
key={l}
|
||||||
active={activeTab === 'risk'}
|
onClick={() => setLang(l)}
|
||||||
onClick={() => setActiveTab('risk')}
|
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
|
||||||
|
lang === l
|
||||||
|
? 'bg-wing-text text-wing-bg'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Risk Indicators
|
{l}
|
||||||
</TabButton>
|
</button>
|
||||||
<TabButton
|
))}
|
||||||
active={activeTab === 'compliance'}
|
|
||||||
onClick={() => setActiveTab('compliance')}
|
|
||||||
>
|
|
||||||
Compliance
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'methodology'}
|
|
||||||
onClick={() => setActiveTab('methodology')}
|
|
||||||
>
|
|
||||||
Methodology History
|
|
||||||
</TabButton>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
|
</div>
|
||||||
|
|
||||||
|
{/* 언더라인 탭 */}
|
||||||
|
<div className="border-b border-wing-border">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLang('EN')}
|
key={tab.key}
|
||||||
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
onClick={() => setActiveTab(tab.key)}
|
||||||
lang === 'EN'
|
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
|
||||||
? 'bg-slate-900 text-white'
|
activeTab === tab.key
|
||||||
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
? 'text-blue-600 border-blue-600'
|
||||||
|
: 'text-wing-muted border-transparent hover:text-wing-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
EN
|
{tab.label}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 내용 */}
|
{/* 탭 내용 */}
|
||||||
{activeTab === 'risk' && <RiskTab lang={lang} />}
|
|
||||||
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
|
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
|
||||||
|
{activeTab === 'risk' && <RiskTab lang={lang} />}
|
||||||
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
|
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,16 +27,19 @@ body {
|
|||||||
/* Main Menu Cards */
|
/* Main Menu Cards */
|
||||||
.gc-cards {
|
.gc-cards {
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
align-items: stretch;
|
grid-auto-rows: 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-cards > * {
|
@media (max-width: 768px) {
|
||||||
flex: 1 1 0;
|
.gc-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-card {
|
.gc-card {
|
||||||
|
|||||||
@ -19,6 +19,12 @@
|
|||||||
--wing-hover: rgba(255, 255, 255, 0.05);
|
--wing-hover: rgba(255, 255, 255, 0.05);
|
||||||
--wing-input-bg: #0f172a;
|
--wing-input-bg: #0f172a;
|
||||||
--wing-input-border: #334155;
|
--wing-input-border: #334155;
|
||||||
|
--wing-rag-red-bg: rgba(127, 29, 29, 0.15);
|
||||||
|
--wing-rag-red-text: #fca5a5;
|
||||||
|
--wing-rag-amber-bg: rgba(120, 53, 15, 0.15);
|
||||||
|
--wing-rag-amber-text: #fcd34d;
|
||||||
|
--wing-rag-green-bg: rgba(5, 46, 22, 0.15);
|
||||||
|
--wing-rag-green-text: #86efac;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme */
|
/* Light theme */
|
||||||
@ -41,6 +47,12 @@
|
|||||||
--wing-hover: rgba(0, 0, 0, 0.04);
|
--wing-hover: rgba(0, 0, 0, 0.04);
|
||||||
--wing-input-bg: #ffffff;
|
--wing-input-bg: #ffffff;
|
||||||
--wing-input-border: #cbd5e1;
|
--wing-input-border: #cbd5e1;
|
||||||
|
--wing-rag-red-bg: #fef2f2;
|
||||||
|
--wing-rag-red-text: #b91c1c;
|
||||||
|
--wing-rag-amber-bg: #fffbeb;
|
||||||
|
--wing-rag-amber-text: #b45309;
|
||||||
|
--wing-rag-green-bg: #f0fdf4;
|
||||||
|
--wing-rag-green-text: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@ -62,5 +74,11 @@
|
|||||||
--color-wing-hover: var(--wing-hover);
|
--color-wing-hover: var(--wing-hover);
|
||||||
--color-wing-input-bg: var(--wing-input-bg);
|
--color-wing-input-bg: var(--wing-input-bg);
|
||||||
--color-wing-input-border: var(--wing-input-border);
|
--color-wing-input-border: var(--wing-input-border);
|
||||||
|
--color-wing-rag-red-bg: var(--wing-rag-red-bg);
|
||||||
|
--color-wing-rag-red-text: var(--wing-rag-red-text);
|
||||||
|
--color-wing-rag-amber-bg: var(--wing-rag-amber-bg);
|
||||||
|
--color-wing-rag-amber-text: var(--wing-rag-amber-text);
|
||||||
|
--color-wing-rag-green-bg: var(--wing-rag-green-bg);
|
||||||
|
--color-wing-rag-green-text: var(--wing-rag-green-text);
|
||||||
--font-sans: 'Noto Sans KR', sans-serif;
|
--font-sans: 'Noto Sans KR', sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,4 +25,21 @@ public abstract class BaseBypassController {
|
|||||||
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
|
.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 io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@ -40,6 +41,9 @@ public class BypassConfigController {
|
|||||||
private final BypassCodeGenerator bypassCodeGenerator;
|
private final BypassCodeGenerator bypassCodeGenerator;
|
||||||
private final BypassApiConfigRepository configRepository;
|
private final BypassApiConfigRepository configRepository;
|
||||||
|
|
||||||
|
@Value("${app.environment:dev}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
@Operation(summary = "설정 목록 조회")
|
@Operation(summary = "설정 목록 조회")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
||||||
@ -82,6 +86,10 @@ public class BypassConfigController {
|
|||||||
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestParam(defaultValue = "false") boolean force) {
|
@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
if ("prod".equals(environment)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(ApiResponse.error("운영 환경에서는 코드 생성이 불가합니다. 개발 환경에서 생성 후 배포해주세요."));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
BypassApiConfig config = configRepository.findById(id)
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + 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 빈 이름 목록을 반환합니다.")
|
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
|
||||||
@GetMapping("/webclient-beans")
|
@GetMapping("/webclient-beans")
|
||||||
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
||||||
|
|||||||
@ -60,6 +60,14 @@ public class ScreeningGuideController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "방법론 배너 조회", description = "방법론 변경 이력 페이지의 안내 배너 텍스트를 조회합니다.")
|
||||||
|
@GetMapping("/methodology-banner")
|
||||||
|
public ResponseEntity<ApiResponse<MethodologyHistoryResponse>> getMethodologyBanner(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyBanner(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
|
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
|
||||||
@GetMapping("/history/ship-risk")
|
@GetMapping("/history/ship-risk")
|
||||||
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(
|
||||||
|
|||||||
@ -15,10 +15,10 @@ public class WebViewController {
|
|||||||
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
|
@GetMapping({"/", "/dashboard", "/jobs", "/executions", "/executions/{id:\\d+}",
|
||||||
"/recollects", "/recollects/{id:\\d+}",
|
"/recollects", "/recollects/{id:\\d+}",
|
||||||
"/schedules", "/schedule-timeline", "/monitoring",
|
"/schedules", "/schedule-timeline", "/monitoring",
|
||||||
"/bypass-config", "/screening-guide", "/risk-compliance-history",
|
"/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||||
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
|
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
|
||||||
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
||||||
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
"/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
||||||
public String forward() {
|
public String forward() {
|
||||||
return "forward:/index.html";
|
return "forward:/index.html";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import java.util.List;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ComplianceCategoryResponse {
|
public class ComplianceCategoryResponse {
|
||||||
|
|
||||||
private String category;
|
private String categoryCode;
|
||||||
|
private String categoryName;
|
||||||
private String indicatorType;
|
private String indicatorType;
|
||||||
private List<ComplianceIndicatorResponse> indicators;
|
private List<ComplianceIndicatorResponse> indicators;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,5 +19,4 @@ public class ComplianceIndicatorResponse {
|
|||||||
private String conditionAmber;
|
private String conditionAmber;
|
||||||
private String conditionGreen;
|
private String conditionGreen;
|
||||||
private String dataType;
|
private String dataType;
|
||||||
private String collectionNote;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import lombok.*;
|
|||||||
public class IndicatorStatusResponse {
|
public class IndicatorStatusResponse {
|
||||||
private String columnName;
|
private String columnName;
|
||||||
private String fieldName;
|
private String fieldName;
|
||||||
|
private String categoryCode;
|
||||||
private String category;
|
private String category;
|
||||||
private String value;
|
private String value;
|
||||||
private String narrative;
|
private String narrative;
|
||||||
|
|||||||
@ -13,8 +13,7 @@ public class MethodologyHistoryResponse {
|
|||||||
|
|
||||||
private Integer historyId;
|
private Integer historyId;
|
||||||
private String changeDate;
|
private String changeDate;
|
||||||
|
private String changeTypeCode;
|
||||||
private String changeType;
|
private String changeType;
|
||||||
private String updateTitle;
|
|
||||||
private String description;
|
private String description;
|
||||||
private String collectionNote;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,5 +19,4 @@ public class RiskIndicatorResponse {
|
|||||||
private String conditionAmber;
|
private String conditionAmber;
|
||||||
private String conditionGreen;
|
private String conditionGreen;
|
||||||
private String dataType;
|
private String dataType;
|
||||||
private String collectionNote;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴플라이언스 카테고리 마스터 엔티티 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "compliance_category", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ComplianceCategory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "category_code", length = 50)
|
||||||
|
private String categoryCode;
|
||||||
|
|
||||||
|
@Column(name = "indicator_type", nullable = false, length = 20)
|
||||||
|
private String indicatorType;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.IdClass;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴플라이언스 카테고리 다국어 엔티티 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "compliance_category_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(ComplianceCategoryLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ComplianceCategoryLang {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "category_code", length = 50)
|
||||||
|
private String categoryCode;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "category_name", nullable = false, length = 200)
|
||||||
|
private String categoryName;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴플라이언스 카테고리 다국어 복합 PK
|
||||||
|
*/
|
||||||
|
public class ComplianceCategoryLangId implements Serializable {
|
||||||
|
|
||||||
|
private String categoryCode;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public ComplianceCategoryLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComplianceCategoryLangId(String categoryCode, String langCode) {
|
||||||
|
this.categoryCode = categoryCode;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof ComplianceCategoryLangId that)) return false;
|
||||||
|
return Objects.equals(categoryCode, that.categoryCode) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(categoryCode, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,8 +27,8 @@ public class ComplianceIndicator {
|
|||||||
@Column(name = "indicator_type", nullable = false, length = 20)
|
@Column(name = "indicator_type", nullable = false, length = 20)
|
||||||
private String indicatorType;
|
private String indicatorType;
|
||||||
|
|
||||||
@Column(name = "category", nullable = false, length = 100)
|
@Column(name = "category_code", nullable = false, length = 50)
|
||||||
private String category;
|
private String categoryCode;
|
||||||
|
|
||||||
@Column(name = "field_key", nullable = false, length = 200)
|
@Column(name = "field_key", nullable = false, length = 200)
|
||||||
private String fieldKey;
|
private String fieldKey;
|
||||||
@ -36,9 +36,6 @@ public class ComplianceIndicator {
|
|||||||
@Column(name = "data_type_code", length = 50)
|
@Column(name = "data_type_code", length = 50)
|
||||||
private String dataTypeCode;
|
private String dataTypeCode;
|
||||||
|
|
||||||
@Column(name = "collection_note", columnDefinition = "TEXT")
|
|
||||||
private String collectionNote;
|
|
||||||
|
|
||||||
@Column(name = "sort_order", nullable = false)
|
@Column(name = "sort_order", nullable = false)
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
|||||||
@ -31,9 +31,6 @@ public class MethodologyHistory {
|
|||||||
@Column(name = "change_type_code", nullable = false, length = 30)
|
@Column(name = "change_type_code", nullable = false, length = 30)
|
||||||
private String changeTypeCode;
|
private String changeTypeCode;
|
||||||
|
|
||||||
@Column(name = "collection_note", columnDefinition = "TEXT")
|
|
||||||
private String collectionNote;
|
|
||||||
|
|
||||||
@Column(name = "sort_order", nullable = false)
|
@Column(name = "sort_order", nullable = false)
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,6 @@ public class MethodologyHistoryLang {
|
|||||||
@Column(name = "lang_code", length = 5)
|
@Column(name = "lang_code", length = 5)
|
||||||
private String langCode;
|
private String langCode;
|
||||||
|
|
||||||
@Column(name = "update_title", length = 500)
|
|
||||||
private String updateTitle;
|
|
||||||
|
|
||||||
@Column(name = "description", columnDefinition = "TEXT")
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,9 +32,6 @@ public class RiskIndicator {
|
|||||||
@Column(name = "data_type_code", length = 50)
|
@Column(name = "data_type_code", length = 50)
|
||||||
private String dataTypeCode;
|
private String dataTypeCode;
|
||||||
|
|
||||||
@Column(name = "collection_note", columnDefinition = "TEXT")
|
|
||||||
private String collectionNote;
|
|
||||||
|
|
||||||
@Column(name = "sort_order", nullable = false)
|
@Column(name = "sort_order", nullable = false)
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceCategoryLang;
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceCategoryLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ComplianceCategoryLangRepository extends JpaRepository<ComplianceCategoryLang, ComplianceCategoryLangId> {
|
||||||
|
|
||||||
|
List<ComplianceCategoryLang> findByLangCode(String langCode);
|
||||||
|
}
|
||||||
@ -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 모드).
|
* Controller 코드 생성 (RAW 모드).
|
||||||
* 모든 엔드포인트가 ResponseEntity<ApiResponse<JsonNode>>를 반환합니다.
|
* 모든 엔드포인트가 ResponseEntity<JsonNode>를 반환합니다 (외부 API 원본 JSON 그대로).
|
||||||
*/
|
*/
|
||||||
private String generateControllerCode(String domain, List<BypassApiConfig> configs) {
|
private String generateControllerCode(String domain, List<BypassApiConfig> configs) {
|
||||||
String packageName = BASE_PACKAGE + "." + domain + ".controller";
|
String packageName = BASE_PACKAGE + "." + domain + ".controller";
|
||||||
@ -142,7 +142,6 @@ public class BypassCodeGenerator {
|
|||||||
// imports (중복 제거)
|
// imports (중복 제거)
|
||||||
Set<String> importSet = new LinkedHashSet<>();
|
Set<String> importSet = new LinkedHashSet<>();
|
||||||
importSet.add("import com.fasterxml.jackson.databind.JsonNode;");
|
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 com.snp.batch.common.web.controller.BaseBypassController;");
|
||||||
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
|
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
|
||||||
importSet.add("import io.swagger.v3.oas.annotations.Parameter;");
|
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(" description = \"").append(opDescription).append("\"\n");
|
||||||
methods.append(" )\n");
|
methods.append(" )\n");
|
||||||
methods.append(" ").append(mappingAnnotation).append(mappingPath).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()) {
|
if (!paramAnnotations.isEmpty()) {
|
||||||
methods.append(paramAnnotations);
|
methods.append(paramAnnotations);
|
||||||
}
|
}
|
||||||
methods.append(") {\n");
|
methods.append(") {\n");
|
||||||
methods.append(" return execute(() -> ").append(serviceField)
|
methods.append(" return executeRaw(() -> ").append(serviceField)
|
||||||
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
|
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
|
||||||
methods.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.CompanyDetailInfo;
|
||||||
import com.snp.batch.global.model.screening.ShipCountryCode;
|
import com.snp.batch.global.model.screening.ShipCountryCode;
|
||||||
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
|
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceCategoryLang;
|
||||||
import com.snp.batch.global.model.screening.ComplianceIndicator;
|
import com.snp.batch.global.model.screening.ComplianceIndicator;
|
||||||
import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
|
import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
|
||||||
import com.snp.batch.global.model.screening.MethodologyHistory;
|
import com.snp.batch.global.model.screening.MethodologyHistory;
|
||||||
@ -24,6 +25,7 @@ import com.snp.batch.global.model.screening.ShipComplianceHistory;
|
|||||||
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
|
import com.snp.batch.global.repository.screening.ChangeTypeLangRepository;
|
||||||
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
|
import com.snp.batch.global.repository.screening.CompanyComplianceHistoryRepository;
|
||||||
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository;
|
import com.snp.batch.global.repository.screening.CompanyDetailInfoRepository;
|
||||||
|
import com.snp.batch.global.repository.screening.ComplianceCategoryLangRepository;
|
||||||
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
|
import com.snp.batch.global.repository.screening.ComplianceIndicatorLangRepository;
|
||||||
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
|
import com.snp.batch.global.repository.screening.ComplianceIndicatorRepository;
|
||||||
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
|
import com.snp.batch.global.repository.screening.MethodologyHistoryLangRepository;
|
||||||
@ -63,6 +65,7 @@ public class ScreeningGuideService {
|
|||||||
private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo;
|
private final RiskIndicatorCategoryLangRepository riskCategoryLangRepo;
|
||||||
private final ComplianceIndicatorRepository complianceIndicatorRepo;
|
private final ComplianceIndicatorRepository complianceIndicatorRepo;
|
||||||
private final ComplianceIndicatorLangRepository complianceIndicatorLangRepo;
|
private final ComplianceIndicatorLangRepository complianceIndicatorLangRepo;
|
||||||
|
private final ComplianceCategoryLangRepository complianceCategoryLangRepo;
|
||||||
private final MethodologyHistoryRepository methodologyHistoryRepo;
|
private final MethodologyHistoryRepository methodologyHistoryRepo;
|
||||||
private final MethodologyHistoryLangRepository methodologyHistoryLangRepo;
|
private final MethodologyHistoryLangRepository methodologyHistoryLangRepo;
|
||||||
private final ChangeTypeLangRepository changeTypeLangRepo;
|
private final ChangeTypeLangRepository changeTypeLangRepo;
|
||||||
@ -106,7 +109,6 @@ public class ScreeningGuideService {
|
|||||||
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
|
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
|
||||||
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
|
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
|
||||||
.dataType(ri.getDataTypeCode())
|
.dataType(ri.getDataTypeCode())
|
||||||
.collectionNote(ri.getCollectionNote())
|
|
||||||
.build();
|
.build();
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@ -127,6 +129,9 @@ public class ScreeningGuideService {
|
|||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ComplianceCategoryResponse> getComplianceIndicators(String lang, String type) {
|
public List<ComplianceCategoryResponse> getComplianceIndicators(String lang, String type) {
|
||||||
|
Map<String, String> catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream()
|
||||||
|
.collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName));
|
||||||
|
|
||||||
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
||||||
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
||||||
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
||||||
@ -135,9 +140,10 @@ public class ScreeningGuideService {
|
|||||||
.collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, Function.identity()));
|
.collect(Collectors.toMap(ComplianceIndicatorLang::getIndicatorId, Function.identity()));
|
||||||
|
|
||||||
Map<String, List<ComplianceIndicator>> grouped = indicators.stream()
|
Map<String, List<ComplianceIndicator>> grouped = indicators.stream()
|
||||||
.collect(Collectors.groupingBy(ComplianceIndicator::getCategory, LinkedHashMap::new, Collectors.toList()));
|
.collect(Collectors.groupingBy(ComplianceIndicator::getCategoryCode, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
return grouped.entrySet().stream().map(entry -> {
|
return grouped.entrySet().stream().map(entry -> {
|
||||||
|
String catCode = entry.getKey();
|
||||||
List<ComplianceIndicatorResponse> indicatorResponses = entry.getValue().stream().map(ci -> {
|
List<ComplianceIndicatorResponse> indicatorResponses = entry.getValue().stream().map(ci -> {
|
||||||
ComplianceIndicatorLang langData = langMap.get(ci.getIndicatorId());
|
ComplianceIndicatorLang langData = langMap.get(ci.getIndicatorId());
|
||||||
return ComplianceIndicatorResponse.builder()
|
return ComplianceIndicatorResponse.builder()
|
||||||
@ -149,12 +155,12 @@ public class ScreeningGuideService {
|
|||||||
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
|
.conditionAmber(langData != null ? langData.getConditionAmber() : "")
|
||||||
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
|
.conditionGreen(langData != null ? langData.getConditionGreen() : "")
|
||||||
.dataType(ci.getDataTypeCode())
|
.dataType(ci.getDataTypeCode())
|
||||||
.collectionNote(ci.getCollectionNote())
|
|
||||||
.build();
|
.build();
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return ComplianceCategoryResponse.builder()
|
return ComplianceCategoryResponse.builder()
|
||||||
.category(entry.getKey())
|
.categoryCode(catCode)
|
||||||
|
.categoryName(catNameMap.getOrDefault(catCode, catCode))
|
||||||
.indicatorType(type)
|
.indicatorType(type)
|
||||||
.indicators(indicatorResponses)
|
.indicators(indicatorResponses)
|
||||||
.build();
|
.build();
|
||||||
@ -177,19 +183,42 @@ public class ScreeningGuideService {
|
|||||||
|
|
||||||
List<MethodologyHistory> histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc();
|
List<MethodologyHistory> histories = methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc();
|
||||||
|
|
||||||
return histories.stream().map(mh -> {
|
return histories.stream()
|
||||||
|
.filter(mh -> !"BANNER".equals(mh.getChangeTypeCode()))
|
||||||
|
.map(mh -> {
|
||||||
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
|
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
|
||||||
return MethodologyHistoryResponse.builder()
|
return MethodologyHistoryResponse.builder()
|
||||||
.historyId(mh.getHistoryId())
|
.historyId(mh.getHistoryId())
|
||||||
.changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "")
|
.changeDate(mh.getChangeDate() != null ? mh.getChangeDate().toString() : "")
|
||||||
|
.changeTypeCode(mh.getChangeTypeCode())
|
||||||
.changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode()))
|
.changeType(changeTypeMap.getOrDefault(mh.getChangeTypeCode(), mh.getChangeTypeCode()))
|
||||||
.updateTitle(langData != null ? langData.getUpdateTitle() : "")
|
|
||||||
.description(langData != null ? langData.getDescription() : "")
|
.description(langData != null ? langData.getDescription() : "")
|
||||||
.collectionNote(mh.getCollectionNote())
|
|
||||||
.build();
|
.build();
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방법론 배너 텍스트 조회
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MethodologyHistoryResponse getMethodologyBanner(String lang) {
|
||||||
|
Map<Integer, MethodologyHistoryLang> langMap = methodologyHistoryLangRepo.findByLangCode(lang).stream()
|
||||||
|
.collect(Collectors.toMap(MethodologyHistoryLang::getHistoryId, Function.identity()));
|
||||||
|
|
||||||
|
return methodologyHistoryRepo.findAllByOrderByChangeDateDescSortOrderAsc().stream()
|
||||||
|
.filter(mh -> "BANNER".equals(mh.getChangeTypeCode()))
|
||||||
|
.findFirst()
|
||||||
|
.map(mh -> {
|
||||||
|
MethodologyHistoryLang langData = langMap.get(mh.getHistoryId());
|
||||||
|
return MethodologyHistoryResponse.builder()
|
||||||
|
.historyId(mh.getHistoryId())
|
||||||
|
.changeTypeCode(mh.getChangeTypeCode())
|
||||||
|
.description(langData != null ? langData.getDescription() : "")
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 위험지표 값 변경 이력 조회
|
* 선박 위험지표 값 변경 이력 조회
|
||||||
*
|
*
|
||||||
@ -336,6 +365,7 @@ public class ScreeningGuideService {
|
|||||||
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
|
Map<String, String> fieldNameMap = getRiskFieldNameMap(lang);
|
||||||
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
|
Map<String, Integer> sortOrderMap = getRiskSortOrderMap();
|
||||||
Map<String, String> categoryMap = getRiskCategoryMap(lang);
|
Map<String, String> categoryMap = getRiskCategoryMap(lang);
|
||||||
|
Map<String, String> categoryCodeMap = getRiskCategoryCodeMap();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||||
@ -351,6 +381,7 @@ public class ScreeningGuideService {
|
|||||||
result.add(IndicatorStatusResponse.builder()
|
result.add(IndicatorStatusResponse.builder()
|
||||||
.columnName(colName)
|
.columnName(colName)
|
||||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||||
|
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
|
||||||
.category(categoryMap.getOrDefault(colName, ""))
|
.category(categoryMap.getOrDefault(colName, ""))
|
||||||
.value(codeVal != null ? codeVal.toString() : null)
|
.value(codeVal != null ? codeVal.toString() : null)
|
||||||
.narrative(descVal != null ? descVal.toString() : null)
|
.narrative(descVal != null ? descVal.toString() : null)
|
||||||
@ -371,7 +402,8 @@ public class ScreeningGuideService {
|
|||||||
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
|
public List<IndicatorStatusResponse> getShipComplianceStatus(String imoNo, String lang) {
|
||||||
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
|
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "SHIP");
|
||||||
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
|
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("SHIP");
|
||||||
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP");
|
Map<String, String> categoryMap = getComplianceCategoryMap("SHIP", lang);
|
||||||
|
Map<String, String> categoryCodeMap = getComplianceCategoryCodeMap("SHIP");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||||
@ -385,6 +417,7 @@ public class ScreeningGuideService {
|
|||||||
result.add(IndicatorStatusResponse.builder()
|
result.add(IndicatorStatusResponse.builder()
|
||||||
.columnName(colName)
|
.columnName(colName)
|
||||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||||
|
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
|
||||||
.category(categoryMap.getOrDefault(colName, ""))
|
.category(categoryMap.getOrDefault(colName, ""))
|
||||||
.value(codeVal != null ? codeVal.toString() : null)
|
.value(codeVal != null ? codeVal.toString() : null)
|
||||||
.sortOrder(entry.getValue())
|
.sortOrder(entry.getValue())
|
||||||
@ -404,7 +437,8 @@ public class ScreeningGuideService {
|
|||||||
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
|
public List<IndicatorStatusResponse> getCompanyComplianceStatus(String companyCode, String lang) {
|
||||||
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
|
Map<String, String> fieldNameMap = getComplianceFieldNameMap(lang, "COMPANY");
|
||||||
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
|
Map<String, Integer> sortOrderMap = getComplianceSortOrderMap("COMPANY");
|
||||||
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY");
|
Map<String, String> categoryMap = getComplianceCategoryMap("COMPANY", lang);
|
||||||
|
Map<String, String> categoryCodeMap = getComplianceCategoryCodeMap("COMPANY");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> row = jdbcTemplate.queryForMap(
|
Map<String, Object> row = jdbcTemplate.queryForMap(
|
||||||
@ -418,6 +452,7 @@ public class ScreeningGuideService {
|
|||||||
result.add(IndicatorStatusResponse.builder()
|
result.add(IndicatorStatusResponse.builder()
|
||||||
.columnName(colName)
|
.columnName(colName)
|
||||||
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
.fieldName(fieldNameMap.getOrDefault(colName, colName))
|
||||||
|
.categoryCode(categoryCodeMap.getOrDefault(colName, ""))
|
||||||
.category(categoryMap.getOrDefault(colName, ""))
|
.category(categoryMap.getOrDefault(colName, ""))
|
||||||
.value(codeVal != null ? codeVal.toString() : null)
|
.value(codeVal != null ? codeVal.toString() : null)
|
||||||
.sortOrder(entry.getValue())
|
.sortOrder(entry.getValue())
|
||||||
@ -462,6 +497,15 @@ public class ScreeningGuideService {
|
|||||||
(a, b) -> a));
|
(a, b) -> a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getRiskCategoryCodeMap() {
|
||||||
|
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
|
||||||
|
.filter(ri -> ri.getColumnName() != null)
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
RiskIndicator::getColumnName,
|
||||||
|
RiskIndicator::getCategoryCode,
|
||||||
|
(a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Integer> getRiskSortOrderMap() {
|
private Map<String, Integer> getRiskSortOrderMap() {
|
||||||
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
|
return riskIndicatorRepo.findAllByOrderByCategoryCodeAscSortOrderAsc().stream()
|
||||||
.filter(ri -> ri.getColumnName() != null)
|
.filter(ri -> ri.getColumnName() != null)
|
||||||
@ -488,7 +532,9 @@ public class ScreeningGuideService {
|
|||||||
(a, b) -> a));
|
(a, b) -> a));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> getComplianceCategoryMap(String type) {
|
private Map<String, String> getComplianceCategoryMap(String type, String lang) {
|
||||||
|
Map<String, String> catNameMap = complianceCategoryLangRepo.findByLangCode(lang).stream()
|
||||||
|
.collect(Collectors.toMap(ComplianceCategoryLang::getCategoryCode, ComplianceCategoryLang::getCategoryName));
|
||||||
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
||||||
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
||||||
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
||||||
@ -496,7 +542,19 @@ public class ScreeningGuideService {
|
|||||||
.filter(ci -> ci.getColumnName() != null)
|
.filter(ci -> ci.getColumnName() != null)
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
ComplianceIndicator::getColumnName,
|
ComplianceIndicator::getColumnName,
|
||||||
ComplianceIndicator::getCategory,
|
ci -> catNameMap.getOrDefault(ci.getCategoryCode(), ci.getCategoryCode()),
|
||||||
|
(a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getComplianceCategoryCodeMap(String type) {
|
||||||
|
List<ComplianceIndicator> indicators = (type != null && !type.isBlank())
|
||||||
|
? complianceIndicatorRepo.findByIndicatorTypeOrderBySortOrderAsc(type)
|
||||||
|
: complianceIndicatorRepo.findAllByOrderBySortOrderAsc();
|
||||||
|
return indicators.stream()
|
||||||
|
.filter(ci -> ci.getColumnName() != null)
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
ComplianceIndicator::getColumnName,
|
||||||
|
ComplianceIndicator::getCategoryCode,
|
||||||
(a, b) -> a));
|
(a, b) -> a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,7 @@ logging:
|
|||||||
|
|
||||||
# Custom Application Properties
|
# Custom Application Properties
|
||||||
app:
|
app:
|
||||||
|
environment: prod
|
||||||
batch:
|
batch:
|
||||||
chunk-size: 1000
|
chunk-size: 1000
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@ -76,6 +76,7 @@ logging:
|
|||||||
|
|
||||||
# Custom Application Properties
|
# Custom Application Properties
|
||||||
app:
|
app:
|
||||||
|
environment: dev
|
||||||
batch:
|
batch:
|
||||||
chunk-size: 1000
|
chunk-size: 1000
|
||||||
target-schema:
|
target-schema:
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user