diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6817940..3b9abec 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,36 @@ ## [Unreleased] +## [2026-04-01] + +### 추가 +- Risk & Compliance Screening Guide UI 개편 및 다중언어 지원 (#124) + - Screening Guide 아코디언 리스트 UI 개편 (카테고리별 접기/펼치기) + - 언더라인 탭 및 언어 토글 디자인 통일 + - 다중언어 데이터 캐싱 (화면 로드 시 KO/EN 동시 조회) + - Compliance 카테고리 다중언어 테이블 신규 생성 (compliance_category, compliance_category_lang) + - RAG 지표 색상 테마 CSS 변수화 (다크모드/라이트모드 대응) +- S&P Bypass 피드백 반영 (#123) + - Response JSON 원본 반환 (ApiResponse 래핑 제거) + - 사용자용 API 카탈로그 페이지 추가 (/bypass-catalog) + - 운영 환경 코드 생성 차단 (app.environment 기반) + - Bypass API 코드 생성 (compliance, risk 도메인) +- 공통 UI 피드백 반영 (#121) + - 2단 탭 네비게이션 (섹션 탭 + 서브 탭) + - 섹션 간 직접 이동 + - 메인화면 카드 높이 동기화 (CSS Grid) + +### 수정 +- Change History 선박 제재 KO 데이터 조회 누락 수정 (categoryCode 기반 분류로 변경) +- S&P Collector 다크모드 미적용 및 라벨 디자인 통일 (#122) + - 실행이력상세/재수집이력상세 API 호출 로그 다크모드 적용 + - 개별 호출 로그 필터/테이블 다크모드 적용 + - 작업관리 스케줄 라벨 rounded-full 디자인 통일 + +### 변경 +- Navbar 메인 섹션 왼쪽 정렬, 서브 섹션 오른쪽 정렬로 변경 +- 불필요한 DB 컬럼 참조 코드 제거 (collection_note, update_title) + ## [2026-03-31] ### 추가 diff --git a/docs/compliance_category_migration.sql b/docs/compliance_category_migration.sql new file mode 100644 index 0000000..9dc7586 --- /dev/null +++ b/docs/compliance_category_migration.sql @@ -0,0 +1,111 @@ +-- ============================================================ +-- Compliance 카테고리 다중언어 마이그레이션 스크립트 +-- ============================================================ + +-- 1. 카테고리 마스터 테이블 생성 +CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category ( + category_code VARCHAR(50) PRIMARY KEY, + indicator_type VARCHAR(20) NOT NULL, + sort_order INTEGER NOT NULL +); + +-- 2. 카테고리 다중언어 테이블 생성 +CREATE TABLE IF NOT EXISTS std_snp_data.compliance_category_lang ( + category_code VARCHAR(50) NOT NULL, + lang_code VARCHAR(5) NOT NULL, + category_name VARCHAR(200) NOT NULL, + PRIMARY KEY (category_code, lang_code) +); + +-- 3. 카테고리 마스터 데이터 삽입 +INSERT INTO std_snp_data.compliance_category (category_code, indicator_type, sort_order) VALUES + ('SANCTIONS_SHIP_US_OFAC', 'SHIP', 1), + ('SANCTIONS_OWNERSHIP_US_OFAC', 'SHIP', 2), + ('SANCTIONS_SHIP_NON_US', 'SHIP', 3), + ('SANCTIONS_OWNERSHIP_NON_US', 'SHIP', 4), + ('SANCTIONS_FATF', 'SHIP', 5), + ('SANCTIONS_OTHER', 'SHIP', 6), + ('PORT_CALLS', 'SHIP', 7), + ('STS_ACTIVITY', 'SHIP', 8), + ('SUSPICIOUS_BEHAVIOR', 'SHIP', 9), + ('OWNERSHIP_SCREENING', 'SHIP', 10), + ('COMPLIANCE_SCREENING_HISTORY', 'SHIP', 11), + ('US_TREASURY_SANCTIONS', 'COMPANY', 1), + ('NON_US_SANCTIONS', 'COMPANY', 2), + ('FATF_JURISDICTION', 'COMPANY', 3), + ('PARENT_COMPANY', 'COMPANY', 4), + ('OVERALL_COMPLIANCE_STATUS', 'COMPANY', 5), + ('RELATED_SCREENING', 'COMPANY', 6), + ('COMPANY_COMPLIANCE_HISTORY', 'COMPANY', 7); + +-- 4. 카테고리 다중언어 데이터 삽입 (EN) +INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES + ('SANCTIONS_SHIP_US_OFAC', 'EN', 'Sanctions – Ship (US OFAC)'), + ('SANCTIONS_OWNERSHIP_US_OFAC', 'EN', 'Sanctions – Ownership (US OFAC)'), + ('SANCTIONS_SHIP_NON_US', 'EN', 'Sanctions – Ship (Non-US)'), + ('SANCTIONS_OWNERSHIP_NON_US', 'EN', 'Sanctions – Ownership (Non-US)'), + ('SANCTIONS_FATF', 'EN', 'Sanctions – FATF'), + ('SANCTIONS_OTHER', 'EN', 'Sanctions – Other'), + ('PORT_CALLS', 'EN', 'Port Calls'), + ('STS_ACTIVITY', 'EN', 'STS Activity'), + ('SUSPICIOUS_BEHAVIOR', 'EN', 'Suspicious Behavior'), + ('OWNERSHIP_SCREENING', 'EN', 'Ownership Screening'), + ('COMPLIANCE_SCREENING_HISTORY', 'EN', 'Compliance Screening History'), + ('US_TREASURY_SANCTIONS', 'EN', 'US Treasury Sanctions'), + ('NON_US_SANCTIONS', 'EN', 'Non-US Sanctions'), + ('FATF_JURISDICTION', 'EN', 'FATF Jurisdiction'), + ('PARENT_COMPANY', 'EN', 'Parent Company'), + ('OVERALL_COMPLIANCE_STATUS', 'EN', 'Overall Compliance Status'), + ('RELATED_SCREENING', 'EN', 'Related Screening'), + ('COMPANY_COMPLIANCE_HISTORY', 'EN', 'Compliance Screening Change History'); + +-- 5. 카테고리 다중언어 데이터 삽입 (KO) +INSERT INTO std_snp_data.compliance_category_lang (category_code, lang_code, category_name) VALUES + ('SANCTIONS_SHIP_US_OFAC', 'KO', '제재 – 선박 (US OFAC)'), + ('SANCTIONS_OWNERSHIP_US_OFAC', 'KO', '제재 – 소유권 (US OFAC)'), + ('SANCTIONS_SHIP_NON_US', 'KO', '제재 – 선박 (비미국)'), + ('SANCTIONS_OWNERSHIP_NON_US', 'KO', '제재 – 소유권 (비미국)'), + ('SANCTIONS_FATF', 'KO', '제재 – FATF'), + ('SANCTIONS_OTHER', 'KO', '제재 – 기타'), + ('PORT_CALLS', 'KO', '입항 이력'), + ('STS_ACTIVITY', 'KO', 'STS 활동'), + ('SUSPICIOUS_BEHAVIOR', 'KO', '의심 행위'), + ('OWNERSHIP_SCREENING', 'KO', '소유권 심사'), + ('COMPLIANCE_SCREENING_HISTORY', 'KO', '컴플라이언스 심사 이력'), + ('US_TREASURY_SANCTIONS', 'KO', '미국 재무부 제재'), + ('NON_US_SANCTIONS', 'KO', '비미국 제재'), + ('FATF_JURISDICTION', 'KO', 'FATF 관할지역'), + ('PARENT_COMPANY', 'KO', '모회사'), + ('OVERALL_COMPLIANCE_STATUS', 'KO', '종합 컴플라이언스 상태'), + ('RELATED_SCREENING', 'KO', '관련 심사'), + ('COMPANY_COMPLIANCE_HISTORY', 'KO', '컴플라이언스 심사 변경 이력'); + +-- 6. compliance_indicator 테이블: category → category_code 변환 +-- 먼저 컬럼 추가 +ALTER TABLE std_snp_data.compliance_indicator ADD COLUMN category_code VARCHAR(50); + +-- 기존 category 값을 category_code로 매핑 +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_US_OFAC' WHERE category = 'Sanctions – Ship (US OFAC)'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_US_OFAC' WHERE category = 'Sanctions – Ownership (US OFAC)'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_SHIP_NON_US' WHERE category = 'Sanctions – Ship (Non-US)'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OWNERSHIP_NON_US' WHERE category = 'Sanctions – Ownership (Non-US)'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_FATF' WHERE category = 'Sanctions – FATF'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SANCTIONS_OTHER' WHERE category = 'Sanctions – Other'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'PORT_CALLS' WHERE category = 'Port Calls'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'STS_ACTIVITY' WHERE category = 'STS Activity'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'SUSPICIOUS_BEHAVIOR' WHERE category = 'Suspicious Behavior'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'OWNERSHIP_SCREENING' WHERE category = 'Ownership Screening'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPLIANCE_SCREENING_HISTORY' WHERE category = 'Compliance Screening History'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'US_TREASURY_SANCTIONS' WHERE category = 'US Treasury Sanctions'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'NON_US_SANCTIONS' WHERE category = 'Non-US Sanctions'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'FATF_JURISDICTION' WHERE category = 'FATF Jurisdiction'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'PARENT_COMPANY' WHERE category = 'Parent Company'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'OVERALL_COMPLIANCE_STATUS' WHERE category = 'Overall Compliance Status'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'RELATED_SCREENING' WHERE category = 'Related Screening'; +UPDATE std_snp_data.compliance_indicator SET category_code = 'COMPANY_COMPLIANCE_HISTORY' WHERE category = 'Compliance Screening Change History'; + +-- category_code NOT NULL 설정 +ALTER TABLE std_snp_data.compliance_indicator ALTER COLUMN category_code SET NOT NULL; + +-- 기존 category 컬럼 삭제 +ALTER TABLE std_snp_data.compliance_indicator DROP COLUMN category; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96c6356..6a4d349 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const RecollectDetail = lazy(() => import('./pages/RecollectDetail')); const Schedules = lazy(() => import('./pages/Schedules')); const Timeline = lazy(() => import('./pages/Timeline')); const BypassConfig = lazy(() => import('./pages/BypassConfig')); +const BypassCatalog = lazy(() => import('./pages/BypassCatalog')); const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide')); const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory')); @@ -25,26 +26,41 @@ function AppLayout() { const isMainMenu = location.pathname === '/'; return ( -
-
- - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - -
+
+ {/* 메인 화면: 전체화면, 섹션 페이지: 탭 + 스크롤 콘텐츠 */} + {isMainMenu ? ( +
+ }> + + } /> + + +
+ ) : ( + <> +
+ +
+
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+ + )}
); diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts index 4be75d1..fe42291 100644 --- a/frontend/src/api/screeningGuideApi.ts +++ b/frontend/src/api/screeningGuideApi.ts @@ -15,7 +15,6 @@ export interface RiskIndicatorResponse { conditionAmber: string; conditionGreen: string; dataType: string; - collectionNote: string; } export interface RiskCategoryResponse { @@ -34,11 +33,11 @@ export interface ComplianceIndicatorResponse { conditionAmber: string; conditionGreen: string; dataType: string; - collectionNote: string; } export interface ComplianceCategoryResponse { - category: string; + categoryCode: string; + categoryName: string; indicatorType: string; indicators: ComplianceIndicatorResponse[]; } @@ -47,10 +46,9 @@ export interface ComplianceCategoryResponse { export interface MethodologyHistoryResponse { historyId: number; changeDate: string; + changeTypeCode: string; changeType: string; - updateTitle: string; description: string; - collectionNote: string; } // 값 변경 이력 타입 @@ -108,6 +106,7 @@ export interface CompanyInfoResponse { export interface IndicatorStatusResponse { columnName: string; fieldName: string; + categoryCode: string; category: string; value: string | null; narrative: string | null; @@ -129,6 +128,8 @@ export const screeningGuideApi = { fetchJson>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`), getMethodologyHistory: (lang = 'KO') => fetchJson>(`${BASE}/methodology-history?lang=${lang}`), + getMethodologyBanner: (lang = 'KO') => + fetchJson>(`${BASE}/methodology-banner?lang=${lang}`), getShipRiskHistory: (imoNo: string, lang = 'KO') => fetchJson>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`), getShipComplianceHistory: (imoNo: string, lang = 'KO') => diff --git a/frontend/src/components/ApiLogSection.tsx b/frontend/src/components/ApiLogSection.tsx index 4da7cb3..7f89b6d 100644 --- a/frontend/src/components/ApiLogSection.tsx +++ b/frontend/src/components/ApiLogSection.tsx @@ -71,11 +71,11 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${ status === key ? key === 'ERROR' - ? 'bg-red-100 text-red-700' + ? 'bg-red-500/15 text-red-500' : key === 'SUCCESS' - ? 'bg-emerald-100 text-emerald-700' - : 'bg-blue-100 text-blue-700' - : 'bg-gray-100 text-gray-500 hover:bg-gray-200' + ? 'bg-emerald-500/15 text-emerald-500' + : 'bg-blue-500/15 text-blue-500' + : 'bg-wing-card text-wing-muted hover:bg-wing-hover' }`} > {label} ({count.toLocaleString()}) @@ -92,7 +92,7 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio <>
- + @@ -104,24 +104,24 @@ export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectio - + {logData.content.map((log, idx) => { const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; return ( - + - -
# URI에러
{page * 10 + idx + 1}
- + {log.requestUri}
{log.httpMethod}{log.httpMethod} + {log.responseTimeMs?.toLocaleString() ?? '-'} + {log.responseCount?.toLocaleString() ?? '-'} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index adf72d7..1c2adf2 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,49 +1,76 @@ -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useThemeContext } from '../contexts/ThemeContext'; -interface NavSection { - key: string; - title: string; - paths: string[]; - items: { path: string; label: string; icon: string }[]; +interface MenuItem { + id: string; + label: string; + path: string; } -const sections: NavSection[] = [ +interface MenuSection { + id: string; + label: string; + shortLabel: string; + icon: React.ReactNode; + defaultPath: string; + children: MenuItem[]; +} + +const MENU_STRUCTURE: MenuSection[] = [ { - key: 'collector', - title: 'S&P Collector', - paths: ['/dashboard', '/jobs', '/executions', '/recollects', '/schedules', '/schedule-timeline'], - items: [ - { path: '/dashboard', label: '대시보드', icon: '📊' }, - { path: '/executions', label: '실행 이력', icon: '📋' }, - { path: '/recollects', label: '재수집 이력', icon: '🔄' }, - { path: '/jobs', label: '작업', icon: '⚙️' }, - { path: '/schedules', label: '스케줄', icon: '🕐' }, - { path: '/schedule-timeline', label: '타임라인', icon: '📅' }, + id: 'collector', + label: 'S&P Collector', + shortLabel: 'Collector', + icon: ( + + + + ), + defaultPath: '/dashboard', + children: [ + { id: 'dashboard', label: '대시보드', path: '/dashboard' }, + { id: 'executions', label: '실행 이력', path: '/executions' }, + { id: 'recollects', label: '재수집 이력', path: '/recollects' }, + { id: 'jobs', label: '작업 관리', path: '/jobs' }, + { id: 'schedules', label: '스케줄', path: '/schedules' }, + { id: 'timeline', label: '타임라인', path: '/schedule-timeline' }, ], }, { - key: 'bypass', - title: 'S&P Bypass', - paths: ['/bypass-config'], - items: [ - { path: '/bypass-config', label: 'Bypass API', icon: '🔗' }, + id: 'bypass', + label: 'S&P Bypass', + shortLabel: 'Bypass', + icon: ( + + + + ), + defaultPath: '/bypass-catalog', + children: [ + { id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' }, + { id: 'bypass-config', label: 'API 관리', path: '/bypass-config' }, ], }, { - key: 'risk', - title: 'S&P Risk & Compliance', - paths: ['/screening-guide', '/risk-compliance-history'], - items: [ - { path: '/screening-guide', label: 'Screening Guide', icon: '⚖️' }, - { path: '/risk-compliance-history', label: 'Change History', icon: '📜' }, + id: 'risk', + label: 'S&P Risk & Compliance', + shortLabel: 'Risk & Compliance', + icon: ( + + + + ), + defaultPath: '/risk-compliance-history', + children: [ + { id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' }, + { id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' }, ], }, ]; -function getCurrentSection(pathname: string): NavSection | null { - for (const section of sections) { - if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) { +function getCurrentSection(pathname: string): MenuSection | null { + for (const section of MENU_STRUCTURE) { + if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) { return section; } } @@ -52,56 +79,75 @@ function getCurrentSection(pathname: string): NavSection | null { export default function Navbar() { const location = useLocation(); + const navigate = useNavigate(); const { theme, toggle } = useThemeContext(); const currentSection = getCurrentSection(location.pathname); - // 메인 화면에서는 Navbar 숨김 + // 메인 화면에서는 숨김 if (!currentSection) return null; - const isActive = (path: string) => { - if (path === '/dashboard') return location.pathname === '/dashboard'; + const isActivePath = (path: string) => { return location.pathname === path || location.pathname.startsWith(path + '/'); }; return ( - + ); } diff --git a/frontend/src/components/screening/ComplianceTab.tsx b/frontend/src/components/screening/ComplianceTab.tsx index a4e563e..2fa8635 100644 --- a/frontend/src/components/screening/ComplianceTab.tsx +++ b/frontend/src/components/screening/ComplianceTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { screeningGuideApi, type ComplianceCategoryResponse, @@ -9,135 +9,105 @@ interface ComplianceTabProps { lang: string; } -type ViewMode = 'table' | 'card'; type IndicatorType = 'SHIP' | 'COMPANY'; +type CacheKey = string; -const SHIP_CAT_COLORS: Record = { - 'Sanctions – Ship (US OFAC)': '#1e3a5f', - 'Sanctions – Ownership (US OFAC)': '#1d4ed8', - 'Sanctions – Ship (Non-US)': '#065f46', - 'Sanctions – Ownership (Non-US)': '#0f766e', - 'Sanctions – FATF': '#6b21a8', - 'Sanctions – Other': '#991b1b', - 'Port Calls': '#065f46', - 'STS Activity': '#0f766e', - 'Dark Activity': '#374151', +const CAT_BADGE_COLORS: Record = { + // SHIP + 'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' }, + 'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' }, + 'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' }, + 'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' }, + 'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' }, + 'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' }, + 'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' }, + 'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' }, + 'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' }, + 'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' }, + 'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' }, + // COMPANY + 'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' }, + 'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' }, + 'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' }, + 'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' }, + 'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' }, }; -const COMPANY_CAT_COLORS: Record = { - 'Company Sanctions (US OFAC)': '#1e3a5f', - 'Company Sanctions (Non-US)': '#065f46', - 'Company Compliance': '#6b21a8', - 'Company Risk': '#92400e', -}; - -function getCatHex(categoryName: string, type: IndicatorType): string { - const map = type === 'SHIP' ? SHIP_CAT_COLORS : COMPANY_CAT_COLORS; - return map[categoryName] ?? '#374151'; -} - -interface FlatRow { - category: string; - indicatorType: string; - indicator: ComplianceIndicatorResponse; +function getBadgeColor(categoryCode: string): { bg: string; text: string } { + return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' }; } export default function ComplianceTab({ lang }: ComplianceTabProps) { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedCategory, setSelectedCategory] = useState('전체'); - const [viewMode, setViewMode] = useState('table'); const [indicatorType, setIndicatorType] = useState('SHIP'); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const cache = useRef>(new Map()); + + const fetchData = useCallback((fetchLang: string, type: IndicatorType) => { + return screeningGuideApi + .getComplianceIndicators(fetchLang, type) + .then((res) => { + const data = res.data ?? []; + cache.current.set(`${type}_${fetchLang}`, data); + return data; + }); + }, []); useEffect(() => { setLoading(true); setError(null); - setSelectedCategory('전체'); - screeningGuideApi - .getComplianceIndicators(lang, indicatorType) - .then((res) => setCategories(res.data ?? [])) + setExpandedCategories(new Set()); + cache.current.clear(); + + Promise.all([ + fetchData('KO', indicatorType), + fetchData('EN', indicatorType), + ]) + .then(() => { + setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []); + }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); + }, [indicatorType, fetchData]); + + useEffect(() => { + const cached = cache.current.get(`${indicatorType}_${lang}`); + if (cached) { + setCategories(cached); + } }, [lang, indicatorType]); - const flatRows: FlatRow[] = categories.flatMap((cat) => - cat.indicators.map((ind) => ({ - category: cat.category, - indicatorType: cat.indicatorType, - indicator: ind, - })), - ); - - const filtered: FlatRow[] = - selectedCategory === '전체' - ? flatRows - : flatRows.filter((r) => r.category === selectedCategory); - - const uniqueCategories = Array.from(new Set(flatRows.map((r) => r.category))); - - function downloadCSV() { - const bom = '\uFEFF'; - const headers = [ - '카테고리', - '타입', - '필드키', - '필드명', - '설명', - 'RED 조건', - 'AMBER 조건', - 'GREEN 조건', - '데이터 타입', - '이력 관리 참고사항', - ]; - const rows = flatRows.map((r) => - [ - r.category, - r.indicatorType, - r.indicator.fieldKey, - r.indicator.fieldName, - r.indicator.description, - r.indicator.conditionRed, - r.indicator.conditionAmber, - r.indicator.conditionGreen, - r.indicator.dataType, - r.indicator.collectionNote, - ] - .map((v) => `"${(v ?? '').replace(/"/g, '""')}"`) - .join(','), - ); - const csv = bom + [headers.join(','), ...rows].join('\n'); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `MIRS_Compliance_${indicatorType}.csv`; - a.click(); - } + const toggleCategory = (category: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; return (
- {/* SHIP / COMPANY 토글 */} + {/* Ship / Company 토글 */}
- - + {(['SHIP', 'COMPANY'] as const).map((type) => ( + + ))}
{loading && ( @@ -156,229 +126,100 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) { )} {!loading && !error && ( - <> - {/* 카테고리 요약 카드 */} -
- {uniqueCategories.map((catName) => { - const count = flatRows.filter((r) => r.category === catName).length; - const isActive = selectedCategory === catName; - const hex = getCatHex(catName, indicatorType); - return ( +
+ {categories.map((cat) => { + const isExpanded = expandedCategories.has(cat.categoryCode); + const badge = getBadgeColor(cat.categoryCode); + return ( +
+ {/* 아코디언 헤더 */} - ); - })} -
- {/* 컨트롤 바 */} -
- -
- - 표시:{' '} - {filtered.length}개 항목 - - - -
- - {/* 테이블 뷰 */} - {viewMode === 'table' && ( -
-
- - - - {[ - '카테고리', - '필드명', - '설명', - '🔴 RED', - '🟡 AMBER', - '🟢 GREEN', - '데이터 타입', - '이력 관리 참고', - ].map((h) => ( - + {/* 아코디언 콘텐츠 */} + {isExpanded && ( +
+
+ {cat.indicators.map((ind) => ( + ))} -
- - - {filtered.map((row, i) => { - const showCat = - i === 0 || - filtered[i - 1].category !== row.category; - const hex = getCatHex(row.category, indicatorType); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {showCat && ( - - {row.category} - - )} - -
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} - -
- {row.indicator.conditionRed} -
-
-
- {row.indicator.conditionAmber} -
-
-
- {row.indicator.conditionGreen} -
-
- {row.indicator.dataType} - - {row.indicator.collectionNote && - `💡 ${row.indicator.collectionNote}`} -
-
-
- )} - - {/* 카드 뷰 */} - {viewMode === 'card' && ( -
- {filtered.map((row, i) => { - const hex = getCatHex(row.category, indicatorType); - return ( -
-
- - {row.category} - - - {row.indicator.dataType} - -
-
-
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} -
-
-
-
- 🔴 RED -
-
- {row.indicator.conditionRed} -
-
-
-
- 🟡 AMBER -
-
- {row.indicator.conditionAmber} -
-
-
-
- 🟢 GREEN -
-
- {row.indicator.conditionGreen} -
-
-
- {row.indicator.collectionNote && ( -
- 💡 {row.indicator.collectionNote} -
- )}
- ); - })} -
- )} - + )} +
+ ); + })} +
+ )} +
+ ); +} + +function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) { + return ( +
+
+
+ {indicator.fieldName} +
+ {indicator.dataType && ( + + {indicator.dataType} + + )} +
+ {indicator.description && ( +
+ {indicator.description} +
+ )} + {(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && ( +
+ {indicator.conditionRed && ( +
+
🔴
+
{indicator.conditionRed}
+
+ )} + {indicator.conditionAmber && ( +
+
🟡
+
{indicator.conditionAmber}
+
+ )} + {indicator.conditionGreen && ( +
+
🟢
+
{indicator.conditionGreen}
+
+ )} +
)}
); diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx index 0507406..dcbfbd8 100644 --- a/frontend/src/components/screening/HistoryTab.tsx +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -166,12 +166,13 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) { ); } -// 선박 Compliance 탭 분류 -const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (category: string) => boolean }[] = [ - { key: 'sanctions', label: 'Sanctions', match: (cat) => cat.includes('Sanctions') || cat.includes('FATF') || cat.includes('Other Compliance') }, - { key: 'portcalls', label: 'Port Calls', match: (cat) => cat === 'Port Calls' }, - { key: 'sts', label: 'STS Activity', match: (cat) => cat === 'STS Activity' }, - { key: 'suspicious', label: 'Suspicious Behavior', match: (cat) => cat === 'Suspicious Behavior' }, +// 선박 Compliance 탭 분류 (categoryCode 기반) +const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [ + { key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') }, + { key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' }, + { key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' }, + { key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' }, + { key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' }, ]; // Compliance 예외 처리 @@ -252,7 +253,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp const tabData = useMemo(() => { const result: Record = {}; for (const tab of SHIP_COMPLIANCE_TABS) { - result[tab.key] = filteredItems.filter((i) => tab.match(i.category)); + result[tab.key] = filteredItems.filter((i) => tab.match(i.categoryCode)); } return result; }, [filteredItems]); @@ -461,21 +462,23 @@ export default function HistoryTab({ lang }: HistoryTabProps) { return (
- {/* 이력 유형 선택 */} -
- {HISTORY_TYPES.map((t) => ( - - ))} + {/* 이력 유형 선택 (언더라인 탭) */} +
+
+ {HISTORY_TYPES.map((t) => ( + + ))} +
{/* 검색 */} diff --git a/frontend/src/components/screening/MethodologyTab.tsx b/frontend/src/components/screening/MethodologyTab.tsx index fb9aa14..cca8945 100644 --- a/frontend/src/components/screening/MethodologyTab.tsx +++ b/frontend/src/components/screening/MethodologyTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi'; interface MethodologyTabProps { @@ -18,20 +18,60 @@ function getChangeTypeColor(changeType: string): string { return CHANGE_TYPE_COLORS[changeType] ?? '#374151'; } +type LangKey = 'KO' | 'EN'; + +interface LangCache { + history: MethodologyHistoryResponse[]; + banner: string; +} + export default function MethodologyTab({ lang }: MethodologyTabProps) { const [history, setHistory] = useState([]); + const [banner, setBanner] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedType, setSelectedType] = useState('전체'); + const cache = useRef>(new Map()); + const fetchData = useCallback((fetchLang: string) => { + return Promise.all([ + screeningGuideApi.getMethodologyHistory(fetchLang), + screeningGuideApi.getMethodologyBanner(fetchLang).catch(() => ({ data: null })), + ]).then(([historyRes, bannerRes]) => { + const data: LangCache = { + history: historyRes.data ?? [], + banner: bannerRes.data?.description ?? '', + }; + cache.current.set(fetchLang as LangKey, data); + return data; + }); + }, []); + + // 초기 로드: KO/EN 데이터 모두 가져와서 캐싱 useEffect(() => { setLoading(true); setError(null); - screeningGuideApi - .getMethodologyHistory(lang) - .then((res) => setHistory(res.data ?? [])) + cache.current.clear(); + + Promise.all([fetchData('KO'), fetchData('EN')]) + .then(() => { + const cached = cache.current.get(lang as LangKey); + if (cached) { + setHistory(cached.history); + setBanner(cached.banner); + } + }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); + }, [fetchData]); + + // 언어 변경: 캐시에서 스위칭 + useEffect(() => { + const cached = cache.current.get(lang as LangKey); + if (cached) { + setHistory(cached.history); + setBanner(cached.banner); + } }, [lang]); const sortedHistory = [...history].sort((a, b) => @@ -67,11 +107,11 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { return (
{/* 주의사항 배너 */} -
- 이력 관리 주의사항: 방법론 변경은 선박·기업의 컴플라이언스 - 상태값을 자동으로 변경시킵니다. 상태 변경이 실제 리스크 변화인지, 방법론 - 업데이트 때문인지 반드시 교차 확인해야 합니다. -
+ {banner && ( +
+ {banner} +
+ )} {/* 변경 유형 필터 */}
@@ -116,7 +156,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { - {['날짜', '변경 유형', '제목', '설명', '참고사항'].map((h) => ( + {['날짜', '변경 유형', '설명'].map((h) => ( - ); diff --git a/frontend/src/components/screening/RiskTab.tsx b/frontend/src/components/screening/RiskTab.tsx index 92b6e40..d8b6c13 100644 --- a/frontend/src/components/screening/RiskTab.tsx +++ b/frontend/src/components/screening/RiskTab.tsx @@ -1,106 +1,74 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi'; interface RiskTabProps { lang: string; } -type ViewMode = 'table' | 'card'; +type LangKey = 'KO' | 'EN'; -const CAT_COLORS: Record = { - 'AIS': 'bg-blue-800', - 'Port Calls': 'bg-emerald-800', - 'Associated with Russia': 'bg-red-800', - 'Behavioural Risk': 'bg-amber-800', - 'Safety, Security & Inspections': 'bg-blue-600', - 'Flag Risk': 'bg-purple-800', - 'Owner & Classification': 'bg-teal-700', +const CAT_BADGE_COLORS: Record = { + 'AIS': { bg: '#dbeafe', text: '#1e40af' }, + 'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' }, + 'ASSOCIATED_WITH_RUSSIA': { bg: '#fee2e2', text: '#991b1b' }, + 'BEHAVIOURAL_RISK': { bg: '#fef3c7', text: '#92400e' }, + 'SAFETY_SECURITY_AND_INSPECTIONS': { bg: '#dbeafe', text: '#1d4ed8' }, + 'FLAG_RISK': { bg: '#ede9fe', text: '#6b21a8' }, + 'OWNER_AND_CLASSIFICATION': { bg: '#ccfbf1', text: '#0f766e' }, }; -const CAT_HEX: Record = { - 'AIS': '#1e40af', - 'Port Calls': '#065f46', - 'Associated with Russia': '#991b1b', - 'Behavioural Risk': '#92400e', - 'Safety, Security & Inspections': '#1d4ed8', - 'Flag Risk': '#6b21a8', - 'Owner & Classification': '#0f766e', -}; - -function getCatColor(categoryName: string): string { - return CAT_COLORS[categoryName] ?? 'bg-slate-700'; -} - -function getCatHex(categoryName: string): string { - return CAT_HEX[categoryName] ?? '#374151'; -} - -interface FlatRow { - category: string; - indicator: RiskIndicatorResponse; +function getBadgeColor(categoryCode: string): { bg: string; text: string } { + return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' }; } export default function RiskTab({ lang }: RiskTabProps) { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedCategory, setSelectedCategory] = useState('전체'); - const [viewMode, setViewMode] = useState('table'); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const cache = useRef>(new Map()); + + const fetchData = useCallback((fetchLang: string) => { + return screeningGuideApi + .getRiskIndicators(fetchLang) + .then((res) => { + const data = res.data ?? []; + cache.current.set(fetchLang as LangKey, data); + return data; + }); + }, []); useEffect(() => { setLoading(true); setError(null); - screeningGuideApi - .getRiskIndicators(lang) - .then((res) => setCategories(res.data ?? [])) + cache.current.clear(); + + Promise.all([fetchData('KO'), fetchData('EN')]) + .then(() => { + setCategories(cache.current.get(lang as LangKey) ?? []); + }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); + }, [fetchData]); + + useEffect(() => { + const cached = cache.current.get(lang as LangKey); + if (cached) { + setCategories(cached); + } }, [lang]); - const flatRows: FlatRow[] = categories.flatMap((cat) => - cat.indicators.map((ind) => ({ category: cat.categoryName, indicator: ind })), - ); - - const filtered: FlatRow[] = - selectedCategory === '전체' - ? flatRows - : flatRows.filter((r) => r.category === selectedCategory); - - function downloadCSV() { - const bom = '\uFEFF'; - const headers = [ - '카테고리', - '필드키', - '필드명', - '설명', - 'RED 조건', - 'AMBER 조건', - 'GREEN 조건', - '데이터 타입', - '이력 관리 참고사항', - ]; - const rows = flatRows.map((r) => - [ - r.category, - r.indicator.fieldKey, - r.indicator.fieldName, - r.indicator.description, - r.indicator.conditionRed, - r.indicator.conditionAmber, - r.indicator.conditionGreen, - r.indicator.dataType, - r.indicator.collectionNote, - ] - .map((v) => `"${(v ?? '').replace(/"/g, '""')}"`) - .join(','), - ); - const csv = bom + [headers.join(','), ...rows].join('\n'); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'MIRS_Risk_Indicators.csv'; - a.click(); - } + const toggleCategory = (code: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(code)) { + next.delete(code); + } else { + next.add(code); + } + return next; + }); + }; if (loading) { return ( @@ -122,226 +90,95 @@ export default function RiskTab({ lang }: RiskTabProps) { } return ( -
- {/* 카테고리 요약 카드 */} -
- {categories.map((cat) => { - const isActive = selectedCategory === cat.categoryName; - const hex = getCatHex(cat.categoryName); - return ( +
+ {categories.map((cat) => { + const isExpanded = expandedCategories.has(cat.categoryCode); + const badge = getBadgeColor(cat.categoryCode); + return ( +
- ); - })} -
- {/* 컨트롤 바 */} -
- -
- - 표시:{' '} - {filtered.length}개 항목 - - - -
- - {/* 테이블 뷰 */} - {viewMode === 'table' && ( -
-
-
- - {row.updateTitle} - - {row.description} - - {row.collectionNote && `💡 ${row.collectionNote}`} + {row.description || '-'}
- - - {[ - '카테고리', - '필드명', - '설명', - '🔴 RED', - '🟡 AMBER', - '🟢 GREEN', - '데이터 타입', - '이력 관리 참고', - ].map((h) => ( - + {isExpanded && ( +
+
+ {cat.indicators.map((ind) => ( + ))} -
- - - {filtered.map((row, i) => { - const showCat = - i === 0 || - filtered[i - 1].category !== row.category; - const hex = getCatHex(row.category); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {showCat && ( - - {row.category} - - )} - -
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} - -
- {row.indicator.conditionRed} -
-
-
- {row.indicator.conditionAmber} -
-
-
- {row.indicator.conditionGreen} -
-
- {row.indicator.dataType} - - {row.indicator.collectionNote && - `💡 ${row.indicator.collectionNote}`} -
+
+
+ )}
+ ); + })} +
+ ); +} + +function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) { + return ( +
+
+
+ {indicator.fieldName} +
+ {indicator.dataType && ( + + {indicator.dataType} + + )} +
+ {indicator.description && ( +
+ {indicator.description}
)} - - {/* 카드 뷰 */} - {viewMode === 'card' && ( -
- {filtered.map((row, i) => { - const hex = getCatHex(row.category); - const colorClass = getCatColor(row.category); - return ( -
-
- - {row.category} - - - {row.indicator.dataType} - -
-
-
- {row.indicator.fieldName} -
-
- {row.indicator.fieldKey} -
-
- {row.indicator.description} -
-
-
-
- 🔴 RED -
-
- {row.indicator.conditionRed} -
-
-
-
- 🟡 AMBER -
-
- {row.indicator.conditionAmber} -
-
-
-
- 🟢 GREEN -
-
- {row.indicator.conditionGreen} -
-
-
- {row.indicator.collectionNote && ( -
- 💡 {row.indicator.collectionNote} -
- )} -
-
- ); - })} + {(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && ( +
+ {indicator.conditionRed && ( +
+
🔴
+
{indicator.conditionRed}
+
+ )} + {indicator.conditionAmber && ( +
+
🟡
+
{indicator.conditionAmber}
+
+ )} + {indicator.conditionGreen && ( +
+
🟢
+
{indicator.conditionGreen}
+
+ )}
)}
diff --git a/frontend/src/pages/BypassCatalog.tsx b/frontend/src/pages/BypassCatalog.tsx new file mode 100644 index 0000000..e74e7f7 --- /dev/null +++ b/frontend/src/pages/BypassCatalog.tsx @@ -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 { + success: boolean; + data: T; +} + +type ViewMode = 'card' | 'table'; + +const METHOD_COLORS: Record = { + 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([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedDomain, setSelectedDomain] = useState(''); + const [viewMode, setViewMode] = useState('table'); + + useEffect(() => { + fetch('/snp-api/api/bypass-config') + .then(res => res.json()) + .then((res: ApiResponse) => 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 ( +
+
API 목록을 불러오는 중...
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

Bypass API 카탈로그

+

+ 등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다. +

+
+ + Swagger UI + +
+ + {/* 검색 + 필터 + 뷰 전환 */} +
+
+ {/* 검색 */} +
+ + + + + + 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 && ( + + )} +
+ + {/* 도메인 드롭다운 필터 */} + + + {/* 뷰 전환 토글 */} +
+ + +
+
+ + {(searchTerm || selectedDomain) && ( +

+ {filtered.length}개 API 검색됨 +

+ )} +
+ + {/* 빈 상태 */} + {configs.length === 0 ? ( +
+

등록된 API가 없습니다.

+

관리자에게 문의해주세요.

+
+ ) : filtered.length === 0 ? ( +
+

검색 결과가 없습니다.

+

다른 검색어를 사용해 보세요.

+
+ ) : viewMode === 'card' ? ( + /* 카드 뷰 */ +
+ {filtered.map((config) => ( +
+
+
+

{config.displayName}

+

{config.domainName}

+
+ + {config.httpMethod} + +
+
+

{config.externalPath}

+ {config.description && ( +

{config.description}

+ )} +
+ {config.params.length > 0 && ( +
+
Parameters
+
+ {config.params.map((p) => ( + + {p.paramName} + {p.required && *} + + ))} +
+
+ )} + +
+ ))} +
+ ) : ( + /* 테이블 뷰 */ +
+
+ + + + + + + + + + + + + {filtered.map((config) => ( + + + + + + + + + ))} + +
도메인명표시명HTTP외부 경로파라미터Swagger
{config.domainName}{config.displayName} + + {config.httpMethod} + + + {config.externalPath} + +
+ {config.params.map((p) => ( + + {p.paramName} + {p.required && *} + + ))} +
+
+ + 테스트 → + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/BypassConfig.tsx b/frontend/src/pages/BypassConfig.tsx index a1ea498..77446cc 100644 --- a/frontend/src/pages/BypassConfig.tsx +++ b/frontend/src/pages/BypassConfig.tsx @@ -41,6 +41,7 @@ export default function BypassConfig() { const [confirmAction, setConfirmAction] = useState(null); const [generationResult, setGenerationResult] = useState(null); + const [codeGenEnabled, setCodeGenEnabled] = useState(true); const loadConfigs = useCallback(async () => { try { @@ -59,6 +60,10 @@ export default function BypassConfig() { bypassApi.getWebclientBeans() .then((res) => setWebclientBeans(res.data ?? [])) .catch((err) => console.error(err)); + fetch('/snp-api/api/bypass-config/environment') + .then(res => res.json()) + .then(res => setCodeGenEnabled(res.data?.codeGenerationEnabled ?? true)) + .catch(() => {}); }, [loadConfigs]); const handleCreate = () => { @@ -314,7 +319,13 @@ export default function BypassConfig() { @@ -416,7 +427,13 @@ export default function BypassConfig() { diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index f861986..68c2b5c 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -96,32 +96,32 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) { {/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */} {step.apiLogSummary ? ( -
+

API 호출 정보

-
+

{step.apiLogSummary.totalCalls.toLocaleString()}

총 호출

-
+

{step.apiLogSummary.successCount.toLocaleString()}

성공

-
+

0 ? 'text-red-500' : 'text-wing-text'}`}> {step.apiLogSummary.errorCount.toLocaleString()}

에러

-
+

{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}

평균(ms)

-
+

{step.apiLogSummary.maxResponseMs.toLocaleString()}

최대(ms)

-
+

{step.apiLogSummary.minResponseMs.toLocaleString()}

최소(ms)

@@ -132,7 +132,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) { )}
) : step.apiCallInfo && ( -
+

API 호출 정보

@@ -163,7 +163,7 @@ function StepCard({ step, jobName, jobExecutionId }: StepCardProps) { )} {step.exitMessage && ( -
+

Exit Message

{step.exitMessage} @@ -468,7 +468,7 @@ function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: Fa }; return ( -

+