diff --git a/.gitignore b/.gitignore index 6993c75..feba811 100644 --- a/.gitignore +++ b/.gitignore @@ -101,9 +101,7 @@ logs/ # Frontend (Vite + React) frontend/node_modules/ frontend/node/ -src/main/resources/static/assets/ -src/main/resources/static/index.html -src/main/resources/static/vite.svg +src/main/resources/static/ # Claude Code (개인 파일만 무시, 팀 파일은 추적) .claude/settings.local.json diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d251e59..6817940 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,38 @@ ## [Unreleased] +## [2026-03-31] + +### 추가 +- 프론트엔드 UI 개편 (#115) + - 메인 화면 3개 섹션 카드 (Collector/Bypass/Risk&Compliance) + - 섹션별 Navbar 분리 + - 플랫폼명 S&P Data Platform 변경 +- Risk&Compliance 값 변경 이력 확인 페이지 개발 (#111) + - 선박 위험지표/선박 제재/회사 제재 변경 이력 조회 + - 선박/회사 기본정보 및 현재 Risk&Compliance 상태 조회 + - Risk narrative(이전값/이후값) 표시 (LATERAL JOIN) + - indicator column_name 매핑으로 다국어 필드명 지원 + - 다국어 캐시 (KO/EN 동시 조회, 언어 토글 즉시 전환) + - 독립 페이지 분리 (/risk-compliance-history) + - 국가코드 ISO2 변환 → 국기 이모지 표시 + - Compliance 탭 분리 (Sanctions/Port Calls/STS/Suspicious) +- Risk&Compliance Screening Guide 페이지 생성 (#109) +- favicon 변경 (#105) +- BY PASS API 등록 프로세스 설계 및 개발 (#63) + - 화면에서 API 정보 입력 → Java 코드 자동 생성 (Controller, Service) + - 공통 베이스 클래스 (BaseBypassService, BaseBypassController) + - JSON 응답 RAW 패스스루 (JsonNode) + - 같은 도메인 다중 엔드포인트 지원 + - Swagger GroupedOpenApi 그룹 분리 및 사용자 설정 반영 + - SPA 새로고침 오류 수정 + +### 변경 +- API 응답 처리 방식에 따른 패키지 분리 (jobs/batch, jobs/web) (#66) + +### 기타 +- Swagger 서버 목록에서 불필요한 내부 IP 및 MDA 프록시 주소 제거 (#110) + ## [2026-03-25] ### 추가 diff --git a/docs/ddl/indicator_column_name_mapping.sql b/docs/ddl/indicator_column_name_mapping.sql new file mode 100644 index 0000000..c6ebc4f --- /dev/null +++ b/docs/ddl/indicator_column_name_mapping.sql @@ -0,0 +1,193 @@ +-- ============================================================================= +-- Risk & Compliance Indicator 테이블에 column_name 매핑 +-- 목적: flctn_col_nm (이력 테이블 변동컬럼명) → indicator 테이블 JOIN 가능하도록 +-- 참고: INSERT SQL의 field_key 기준으로 매핑 (risk_indicator.sql, compliance_indicator.sql) +-- ============================================================================= + +-- ※ column_name 컬럼이 이미 INSERT SQL에 NULL로 포함되어 있으므로 +-- ALTER TABLE은 column_name 컬럼이 없는 경우에만 실행 +-- ALTER TABLE std_snp_data.risk_indicator ADD COLUMN column_name varchar(100); +-- ALTER TABLE std_snp_data.compliance_indicator ADD COLUMN column_name varchar(100); + + +-- 1. UPDATE: Risk Indicator +-- ----------------------------------------------------------------------------- + +-- AIS +UPDATE std_snp_data.risk_indicator SET column_name = 'ais_notrcv_elps_days' WHERE field_key = 'Time since last seen on AIS'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ais_lwrnk_days' WHERE field_key = 'Days under AIS coverage (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'mmsi_anom_message' WHERE field_key = 'Anomalous AIS Messages from MMSI (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ais_up_imo_desc' WHERE field_key = 'IMO number transmitted correctly in AIS'; +UPDATE std_snp_data.risk_indicator SET column_name = 'othr_ship_nm_voy_yn' WHERE field_key = 'Sailing under name transmitted on AIS'; + +-- PORT_CALLS +UPDATE std_snp_data.risk_indicator SET column_name = 'port_prtcll' WHERE field_key = 'Port calls (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'recent_sanction_prtcll' WHERE field_key = 'Most recent sanctioned port call'; +UPDATE std_snp_data.risk_indicator SET column_name = 'port_risk' WHERE field_key = 'Highest ECR risk port call (last 12 months)'; + +-- ASSOCIATED_WITH_RUSSIA +UPDATE std_snp_data.risk_indicator SET column_name = 'rss_ownr_reg' WHERE field_key = 'Russian registration or ownership since February 2022'; +UPDATE std_snp_data.risk_indicator SET column_name = 'rss_port_call' WHERE field_key = 'Russian port calls since February 2022'; +UPDATE std_snp_data.risk_indicator SET column_name = 'rss_sts' WHERE field_key = 'Russian tanker STS since December 2022'; + +-- BEHAVIOURAL_RISK +UPDATE std_snp_data.risk_indicator SET column_name = 'recent_dark_actv' WHERE field_key = 'Most recent suspicious behavior detected'; +UPDATE std_snp_data.risk_indicator SET column_name = 'sts_job' WHERE field_key = 'Ship-to-Ship operations (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'draft_chg' WHERE field_key = 'Draught changes (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'drift_chg' WHERE field_key = 'Drifting high seas (last 12 months)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ilgl_fshr_viol' WHERE field_key = 'Illegal Unreported or Unregulated (IUU) Fishing Violation'; + +-- SAFETY_SECURITY_AND_INSPECTIONS +UPDATE std_snp_data.risk_indicator SET column_name = 'risk_event' WHERE field_key = 'Casualty & risk events (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'fltsfty' WHERE field_key = 'Fleet casualty & risk (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'vslage' WHERE field_key = 'Age of ship (compared to peer group average)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'psc_inspection' WHERE field_key = 'Inspection (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'psc_inspection_elps_hr' WHERE field_key = 'Time since last inspection'; +UPDATE std_snp_data.risk_indicator SET column_name = 'psc_defect' WHERE field_key = 'PSC defects (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'psc_detained' WHERE field_key = 'PSC detentions (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'now_smgrc_evdc' WHERE field_key = 'Current Safety Management Certificate inspected'; +UPDATE std_snp_data.risk_indicator SET column_name = 'flt_psc' WHERE field_key = 'Fleet PSC detentions (last 3 years)'; + +-- FLAG_RISK +UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_chg' WHERE field_key = 'Flag changes (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_prs_mou_perf' WHERE field_key = 'Flag Paris MOU performance'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_tky_mou_perf' WHERE field_key = 'Flag Tokyo MOU performance'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ntnlty_uscg_mou_perf' WHERE field_key = 'Flag US Coastguard MOU performance'; +UPDATE std_snp_data.risk_indicator SET column_name = 'uscg_excl_ship_cert' WHERE field_key = 'Flag US Coastguard Qualship 21'; +UPDATE std_snp_data.risk_indicator SET column_name = 'risk_data_maint' WHERE field_key = 'Risk Data Maintained For Vessel'; + +-- OWNER_AND_CLASSIFICATION +UPDATE std_snp_data.risk_indicator SET column_name = 'now_clfic' WHERE field_key = 'Classification Society'; +UPDATE std_snp_data.risk_indicator SET column_name = 'clfic_status_chg' WHERE field_key = 'Class status changes (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'spc_inspection_ovdue' WHERE field_key = 'Special survey overdue'; +UPDATE std_snp_data.risk_indicator SET column_name = 'pni_insrnc' WHERE field_key = 'P&I club check'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ship_nm_chg' WHERE field_key = 'Name changes (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'docc_chg' WHERE field_key = 'DOC company changes (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'gbo_chg' WHERE field_key = 'Group owner changes (last 3 years)'; +UPDATE std_snp_data.risk_indicator SET column_name = 'ownr_unk' WHERE field_key = 'Ownership unknown'; +UPDATE std_snp_data.risk_indicator SET column_name = 'sngl_ship_voy' WHERE field_key = 'Single-ship fleet (technical manager)'; + + +-- 2. UPDATE: Compliance Indicator - SHIP +-- ----------------------------------------------------------------------------- +-- ※ DB 컬럼이 없는 지표 (Suspicious Behavior, Ownership Screening 등)는 매핑하지 않음 + +-- Sanctions - Ship (US OFAC) +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_sanction_list' WHERE field_key = 'Ship on OFAC Sanctions List (SDN)' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_non_sdn_sanction_list' WHERE field_key = 'Ship on OFAC Consolidated (Non-SDN) List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ofac_cutn_list' WHERE field_key = 'Ship on OFAC Advisory List' AND indicator_type = 'SHIP'; + +-- Sanctions - Ownership (US OFAC) +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_list' WHERE field_key = 'Ownership on OFAC Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofcs_sanction_list' WHERE field_key = 'Ownership on OFAC SSI List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_country' WHERE field_key = 'Ownership in OFAC Sanctioned Country' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_ofac_sanction_hstry' WHERE field_key = 'Historical Ownership in OFAC Sanctioned Country' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_ofac_sanction_country' WHERE field_key = 'Parent Company in OFAC Sanctioned Country' AND indicator_type = 'SHIP'; + +-- Sanctions - Ship (Non-US) +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_un_sanction_list' WHERE field_key = 'Ship on UN Security Council Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_eu_sanction_list' WHERE field_key = 'Ship on EU Commission Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_swi_sanction_list' WHERE field_key = 'Ship on Swiss SECO Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_bes_sanction_list' WHERE field_key = 'Ship on HM Treasury (BES) Sanctions List' AND indicator_type = 'SHIP'; + +-- Sanctions - Ownership (Non-US) +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_un_sanction_list' WHERE field_key = 'Ownership on UN Security Council Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_eu_sanction_list' WHERE field_key = 'Ownership on EU Commission Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_swi_sanction_list' WHERE field_key = 'Ownership on Swiss SECO Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_bes_sanction_list' WHERE field_key = 'Ownership on HM Treasury (BES) Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_can_sanction_list' WHERE field_key = 'Ownership on Government of Canada Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_aus_sanction_list' WHERE field_key = 'Ownership on Australian DFAT Sanctions List' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_uae_sanction_list' WHERE field_key = 'Ownership on UAE Sanctions List' AND indicator_type = 'SHIP'; + +-- Sanctions - FATF +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_fatf_rgl_zone' WHERE field_key = 'Ownership in FATF High-risk or Non-cooperative Jurisdiction' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_fatf_rgl_zone' WHERE field_key = 'Parent Company in FATF High-risk or Non-cooperative Jurisdiction' AND indicator_type = 'SHIP'; + +-- Sanctions - Other +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_ownr_prnt_company_ncmplnc' WHERE field_key = 'Parent Company Noncompliant' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_sanction_country' WHERE field_key = 'Flag Country Sanctioned' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_sanction_country_hstry' WHERE field_key = 'Historical Flag Country Sanctioned' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_scrty_lgl_dspt_event' WHERE field_key = 'Security and Legal Dispute Event (Last 3 Years)' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_flg_dspt' WHERE field_key = 'Flag (MMSI, Call Sign) False or Flag Unknown' AND indicator_type = 'SHIP'; + +-- Port Calls +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_thr_m' WHERE field_key = 'Port Call Last 3 Months to Sanctioned Country' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_six_m' WHERE field_key = 'Port Call Last 180 Days to Sanctioned Country' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sanction_country_prtcll_last_twelve_m' WHERE field_key = 'Port Call Last 12 Months to Sanctioned Country' AND indicator_type = 'SHIP'; + +-- STS Activity +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_sts_prtnr_non_compliance_twelve_m' WHERE field_key = 'STS Activity – Partner Ship Compliance Status' AND indicator_type = 'SHIP'; + +-- Suspicious Behavior +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_dark_actv_ind' WHERE field_key = 'Dark for Extended Period in Watched Area (Severe)' AND indicator_type = 'SHIP'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'ship_dtld_info_ntmntd' WHERE field_key = 'Not Seen 7 Days Near Sanctioned/Sensitive Country' AND indicator_type = 'SHIP'; + +-- Compliance Screening History +UPDATE std_snp_data.compliance_indicator SET column_name = 'lgl_snths_sanction' WHERE field_key = 'Overall Compliance Change History' AND indicator_type = 'SHIP'; + + +-- 3. UPDATE: Compliance Indicator - COMPANY +-- ----------------------------------------------------------------------------- + +-- US Treasury Sanctions +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_sanction_list' WHERE field_key = 'Company on OFAC Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_non_sdn_sanction_list' WHERE field_key = 'Company on OFAC Non-SDN Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofacssi_sanction_list' WHERE field_key = 'Company on OFAC SSI Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_ofac_sanction_country' WHERE field_key = 'Company in US Treasury OFAC Sanctioned Country' AND indicator_type = 'COMPANY'; + +-- Non-US Sanctions +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_un_sanction_list' WHERE field_key = 'Company on UN Security Council Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_eu_sanction_list' WHERE field_key = 'Company on EU Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_bes_sanction_list' WHERE field_key = 'Company on HM Treasury (BES) Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_can_sanction_list' WHERE field_key = 'Company on Canadian Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_aus_sanction_list' WHERE field_key = 'Company on Australian DFAT Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_uae_sanction_list' WHERE field_key = 'Company on UAE Entity List' AND indicator_type = 'COMPANY'; +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_swiss_sanction_list' WHERE field_key = 'Company on Swiss SECO Entity List' AND indicator_type = 'COMPANY'; + +-- FATF Jurisdiction +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_fatf_cmptnc_country' WHERE field_key = 'Company in FATF High-risk & Non-cooperative Jurisdiction' AND indicator_type = 'COMPANY'; + +-- Parent Company +UPDATE std_snp_data.compliance_indicator SET column_name = 'prnt_company_compliance_risk' WHERE field_key = 'Parent Company Compliance Risk' AND indicator_type = 'COMPANY'; + +-- Compliance Screening Change History +UPDATE std_snp_data.compliance_indicator SET column_name = 'company_snths_compliance_status' WHERE field_key = 'Historical Compliance Change Date (Company)' AND indicator_type = 'COMPANY'; + + +-- 4. 검증 쿼리 +-- ----------------------------------------------------------------------------- + +-- 매핑된 항목 수 확인 +SELECT 'risk_indicator' as table_name, + COUNT(*) as total, + COUNT(column_name) as mapped, + COUNT(*) - COUNT(column_name) as unmapped +FROM std_snp_data.risk_indicator +UNION ALL +SELECT 'compliance_indicator (SHIP)', + COUNT(*), COUNT(column_name), COUNT(*) - COUNT(column_name) +FROM std_snp_data.compliance_indicator WHERE indicator_type = 'SHIP' +UNION ALL +SELECT 'compliance_indicator (COMPANY)', + COUNT(*), COUNT(column_name), COUNT(*) - COUNT(column_name) +FROM std_snp_data.compliance_indicator WHERE indicator_type = 'COMPANY'; + +-- 매핑 안 된 항목 확인 (DB 컬럼 없는 지표 = 정상적으로 NULL) +SELECT indicator_id, field_key, column_name +FROM std_snp_data.risk_indicator WHERE column_name IS NULL; + +SELECT indicator_id, field_key, indicator_type, column_name +FROM std_snp_data.compliance_indicator WHERE column_name IS NULL; + +-- 전체 매핑 확인 (다국어 포함) +SELECT ri.indicator_id, ri.field_key, ri.column_name, ril.field_name +FROM std_snp_data.risk_indicator ri +LEFT JOIN std_snp_data.risk_indicator_lang ril + ON ri.indicator_id = ril.indicator_id AND ril.lang_code = 'KO' +ORDER BY ri.indicator_id; + +SELECT ci.indicator_id, ci.field_key, ci.indicator_type, ci.column_name, cil.field_name +FROM std_snp_data.compliance_indicator ci +LEFT JOIN std_snp_data.compliance_indicator_lang cil + ON ci.indicator_id = cil.indicator_id AND cil.lang_code = 'KO' +ORDER BY ci.indicator_type, ci.indicator_id; diff --git a/frontend/index.html b/frontend/index.html index d8729c6..4958206 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,13 @@ - + + + + + - S&P 배치 관리 + S&P Data Platform
diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..5717dba Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png new file mode 100644 index 0000000..99b3cda Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..951b6fe Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..979ba88 Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..9e29032 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..13ffac1 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..6c7b599 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"S&P 배치 관리","short_name":"S&P Batch","icons":[{"src":"/snp-api/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/snp-api/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa9d191..96c6356 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,12 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; import { ToastProvider, useToastContext } from './contexts/ToastContext'; import { ThemeProvider } from './contexts/ThemeContext'; import Navbar from './components/Navbar'; import ToastContainer from './components/Toast'; import LoadingSpinner from './components/LoadingSpinner'; +const MainMenu = lazy(() => import('./pages/MainMenu')); const Dashboard = lazy(() => import('./pages/Dashboard')); const Jobs = lazy(() => import('./pages/Jobs')); const Executions = lazy(() => import('./pages/Executions')); @@ -14,17 +15,23 @@ const Recollects = lazy(() => import('./pages/Recollects')); 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 ScreeningGuide = lazy(() => import('./pages/ScreeningGuide')); +const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory')); function AppLayout() { const { toasts, removeToast } = useToastContext(); + const location = useLocation(); + const isMainMenu = location.pathname === '/'; return (
-
+
}> - } /> + } /> + } /> } /> } /> } /> @@ -32,6 +39,9 @@ function AppLayout() { } /> } /> } /> + } /> + } /> + } />
diff --git a/frontend/src/api/bypassApi.ts b/frontend/src/api/bypassApi.ts new file mode 100644 index 0000000..abdcab7 --- /dev/null +++ b/frontend/src/api/bypassApi.ts @@ -0,0 +1,109 @@ +// API 응답 타입 +interface ApiResponse { + success: boolean; + message: string; + data: T; + errorCode?: string; +} + +// 타입 정의 +export interface BypassParamDto { + id?: number; + paramName: string; + paramType: string; // STRING, INTEGER, LONG, BOOLEAN + paramIn: string; // PATH, QUERY, BODY + required: boolean; + description: string; + example: string; // Swagger @Parameter example 값 + sortOrder: number; +} + +export interface BypassConfigRequest { + domainName: string; + displayName: string; + webclientBean: string; + externalPath: string; + httpMethod: string; + description: string; + params: BypassParamDto[]; +} + +export interface BypassConfigResponse { + id: number; + domainName: string; + endpointName: string; + displayName: string; + webclientBean: string; + externalPath: string; + httpMethod: string; + description: string; + generated: boolean; + generatedAt: string | null; + createdAt: string; + updatedAt: string; + params: BypassParamDto[]; +} + +export interface CodeGenerationResult { + controllerPath: string; + servicePaths: string[]; + message: string; +} + +export interface WebClientBeanInfo { + name: string; + description: string; +} + +// BASE URL +const BASE = '/snp-api/api/bypass-config'; + +// 헬퍼 함수 (batchApi.ts 패턴과 동일) +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function postJson(url: string, body?: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function putJson(url: string, body?: unknown): Promise { + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function deleteJson(url: string): Promise { + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +export const bypassApi = { + getConfigs: () => + fetchJson>(BASE), + getConfig: (id: number) => + fetchJson>(`${BASE}/${id}`), + createConfig: (data: BypassConfigRequest) => + postJson>(BASE, data), + updateConfig: (id: number, data: BypassConfigRequest) => + putJson>(`${BASE}/${id}`, data), + deleteConfig: (id: number) => + deleteJson>(`${BASE}/${id}`), + generateCode: (id: number, force = false) => + postJson>(`${BASE}/${id}/generate?force=${force}`), + getWebclientBeans: () => + fetchJson>(`${BASE}/webclient-beans`), +}; diff --git a/frontend/src/api/screeningGuideApi.ts b/frontend/src/api/screeningGuideApi.ts new file mode 100644 index 0000000..4be75d1 --- /dev/null +++ b/frontend/src/api/screeningGuideApi.ts @@ -0,0 +1,148 @@ +// API 응답 타입 +interface ApiResponse { + success: boolean; + message: string; + data: T; +} + +// Risk 지표 타입 +export interface RiskIndicatorResponse { + indicatorId: number; + fieldKey: string; + fieldName: string; + description: string; + conditionRed: string; + conditionAmber: string; + conditionGreen: string; + dataType: string; + collectionNote: string; +} + +export interface RiskCategoryResponse { + categoryCode: string; + categoryName: string; + indicators: RiskIndicatorResponse[]; +} + +// Compliance 지표 타입 +export interface ComplianceIndicatorResponse { + indicatorId: number; + fieldKey: string; + fieldName: string; + description: string; + conditionRed: string; + conditionAmber: string; + conditionGreen: string; + dataType: string; + collectionNote: string; +} + +export interface ComplianceCategoryResponse { + category: string; + indicatorType: string; + indicators: ComplianceIndicatorResponse[]; +} + +// 방법론 변경 이력 타입 +export interface MethodologyHistoryResponse { + historyId: number; + changeDate: string; + changeType: string; + updateTitle: string; + description: string; + collectionNote: string; +} + +// 값 변경 이력 타입 +export interface ChangeHistoryResponse { + rowIndex: number; + searchKey: string; + lastModifiedDate: string; + changedColumnName: string; + beforeValue: string; + afterValue: string; + fieldName: string; + narrative: string; + prevNarrative: string; + sortOrder: number; +} + +// 선박 기본 정보 +export interface ShipInfoResponse { + imoNo: string; + shipName: string; + shipStatus: string; + nationalityCode: string; + nationalityIsoCode: string | null; + nationality: string; + shipType: string; + dwt: string; + gt: string; + buildYear: string; + mmsiNo: string; + callSign: string; + shipTypeGroup: string; +} + +// 회사 기본 정보 +export interface CompanyInfoResponse { + companyCode: string; + fullName: string; + abbreviation: string; + status: string; + parentCompanyCode: string | null; + parentCompanyName: string | null; + registrationCountry: string; + registrationCountryCode: string; + registrationCountryIsoCode: string | null; + controlCountry: string | null; + controlCountryCode: string | null; + controlCountryIsoCode: string | null; + foundedDate: string | null; + email: string | null; + phone: string | null; + website: string | null; +} + +// 지표 현재 상태 +export interface IndicatorStatusResponse { + columnName: string; + fieldName: string; + category: string; + value: string | null; + narrative: string | null; + sortOrder: number; +} + +const BASE = '/snp-api/api/screening-guide'; + +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API Error: ${res.status}`); + return res.json(); +} + +export const screeningGuideApi = { + getRiskIndicators: (lang = 'KO') => + fetchJson>(`${BASE}/risk-indicators?lang=${lang}`), + getComplianceIndicators: (lang = 'KO', type = 'SHIP') => + fetchJson>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`), + getMethodologyHistory: (lang = 'KO') => + fetchJson>(`${BASE}/methodology-history?lang=${lang}`), + getShipRiskHistory: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`), + getShipComplianceHistory: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`), + getCompanyComplianceHistory: (companyCode: string, lang = 'KO') => + fetchJson>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`), + getShipInfo: (imoNo: string) => + fetchJson>(`${BASE}/ship-info?imoNo=${imoNo}`), + getShipRiskStatus: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`), + getShipComplianceStatus: (imoNo: string, lang = 'KO') => + fetchJson>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`), + getCompanyInfo: (companyCode: string) => + fetchJson>(`${BASE}/company-info?companyCode=${companyCode}`), + getCompanyComplianceStatus: (companyCode: string, lang = 'KO') => + fetchJson>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`), +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index fa9be55..adf72d7 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,55 +1,107 @@ import { Link, useLocation } from 'react-router-dom'; import { useThemeContext } from '../contexts/ThemeContext'; -const navItems = [ - { path: '/', label: '대시보드', icon: '📊' }, - { path: '/executions', label: '실행 이력', icon: '📋' }, - { path: '/recollects', label: '재수집 이력', icon: '🔄' }, - { path: '/jobs', label: '작업', icon: '⚙️' }, - { path: '/schedules', label: '스케줄', icon: '🕐' }, - { path: '/schedule-timeline', label: '타임라인', icon: '📅' }, +interface NavSection { + key: string; + title: string; + paths: string[]; + items: { path: string; label: string; icon: string }[]; +} + +const sections: NavSection[] = [ + { + 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: '📅' }, + ], + }, + { + key: 'bypass', + title: 'S&P Bypass', + paths: ['/bypass-config'], + items: [ + { path: '/bypass-config', label: 'Bypass API', icon: '🔗' }, + ], + }, + { + 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: '📜' }, + ], + }, ]; -export default function Navbar() { - const location = useLocation(); - const { theme, toggle } = useThemeContext(); - - const isActive = (path: string) => { - if (path === '/') return location.pathname === '/'; - return location.pathname.startsWith(path); - }; - - return ( - - ); +function getCurrentSection(pathname: string): NavSection | null { + for (const section of sections) { + if (section.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) { + return section; + } + } + return null; +} + +export default function Navbar() { + const location = useLocation(); + 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'; + return location.pathname === path || location.pathname.startsWith(path + '/'); + }; + + return ( + + ); } diff --git a/frontend/src/components/bypass/BypassConfigModal.tsx b/frontend/src/components/bypass/BypassConfigModal.tsx new file mode 100644 index 0000000..6a3b854 --- /dev/null +++ b/frontend/src/components/bypass/BypassConfigModal.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect } from 'react'; +import type { + BypassConfigRequest, + BypassConfigResponse, + BypassParamDto, + WebClientBeanInfo, +} from '../../api/bypassApi'; +import BypassStepBasic from './BypassStepBasic'; +import BypassStepParams from './BypassStepParams'; + +interface BypassConfigModalProps { + open: boolean; + editConfig: BypassConfigResponse | null; + webclientBeans: WebClientBeanInfo[]; + onSave: (data: BypassConfigRequest) => Promise; + onClose: () => void; +} + +type StepNumber = 1 | 2; + +const STEP_LABELS: Record = { + 1: '기본 정보', + 2: '파라미터', +}; + +const DEFAULT_FORM: Omit = { + domainName: '', + displayName: '', + webclientBean: '', + externalPath: '', + httpMethod: 'GET', + description: '', +}; + +export default function BypassConfigModal({ + open, + editConfig, + webclientBeans, + onSave, + onClose, +}: BypassConfigModalProps) { + const [step, setStep] = useState(1); + const [domainName, setDomainName] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [webclientBean, setWebclientBean] = useState(''); + const [externalPath, setExternalPath] = useState(''); + const [httpMethod, setHttpMethod] = useState('GET'); + const [description, setDescription] = useState(''); + const [params, setParams] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + setStep(1); + if (editConfig) { + setDomainName(editConfig.domainName); + setDisplayName(editConfig.displayName); + setWebclientBean(editConfig.webclientBean); + setExternalPath(editConfig.externalPath); + setHttpMethod(editConfig.httpMethod); + setDescription(editConfig.description); + setParams(editConfig.params); + } else { + setDomainName(DEFAULT_FORM.domainName); + setDisplayName(DEFAULT_FORM.displayName); + setWebclientBean(DEFAULT_FORM.webclientBean); + setExternalPath(DEFAULT_FORM.externalPath); + setHttpMethod(DEFAULT_FORM.httpMethod); + setDescription(DEFAULT_FORM.description); + setParams([]); + } + }, [open, editConfig]); + + if (!open) return null; + + const handleBasicChange = (field: string, value: string) => { + switch (field) { + case 'domainName': setDomainName(value); break; + case 'displayName': setDisplayName(value); break; + case 'webclientBean': setWebclientBean(value); break; + case 'externalPath': setExternalPath(value); break; + case 'httpMethod': setHttpMethod(value); break; + case 'description': setDescription(value); break; + } + }; + + const handleSave = async () => { + setSaving(true); + try { + await onSave({ + domainName, + displayName, + webclientBean, + externalPath, + httpMethod, + description, + params, + }); + onClose(); + } finally { + setSaving(false); + } + }; + + const steps: StepNumber[] = [1, 2]; + + return ( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+

+ {editConfig ? 'Bypass API 수정' : 'Bypass API 등록'} +

+ + {/* 스텝 인디케이터 */} +
+ {steps.map((s, idx) => ( +
+
+ s + ? 'bg-wing-accent/30 text-wing-accent' + : 'bg-wing-card text-wing-muted border border-wing-border', + ].join(' ')} + > + {s} + + + {STEP_LABELS[s]} + +
+ {idx < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* 본문 */} +
+ {step === 1 && ( + + )} + {step === 2 && ( + + )} +
+ + {/* 하단 버튼 */} +
+
+ {step > 1 && ( + + )} +
+
+ {step === 1 && ( + + )} + {step < 2 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/bypass/BypassStepBasic.tsx b/frontend/src/components/bypass/BypassStepBasic.tsx new file mode 100644 index 0000000..5056b07 --- /dev/null +++ b/frontend/src/components/bypass/BypassStepBasic.tsx @@ -0,0 +1,142 @@ +import type { WebClientBeanInfo } from '../../api/bypassApi'; + +interface BypassStepBasicProps { + domainName: string; + displayName: string; + webclientBean: string; + externalPath: string; + httpMethod: string; + description: string; + webclientBeans: WebClientBeanInfo[]; + isEdit: boolean; + onChange: (field: string, value: string) => void; +} + +export default function BypassStepBasic({ + domainName, + displayName, + webclientBean, + externalPath, + httpMethod, + description, + webclientBeans, + isEdit, + onChange, +}: BypassStepBasicProps) { + return ( +
+

+ BYPASS API의 기본 정보를 입력하세요. 도메인명을 기반으로 코드가 생성됩니다. +

+ +
+ {/* 도메인명 */} +
+ + onChange('domainName', e.target.value)} + disabled={isEdit} + placeholder="예: riskByImo" + pattern="[a-zA-Z][a-zA-Z0-9]*" + className={[ + 'w-full px-3 py-2 text-sm rounded-lg border', + 'border-wing-border bg-wing-card text-wing-text', + 'placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50', + isEdit ? 'opacity-50 cursor-not-allowed' : '', + ].join(' ')} + /> +

영문 소문자/숫자 조합 (수정 불가)

+
+ + {/* 표시명 */} +
+ + onChange('displayName', e.target.value)} + placeholder="예: IMO 기반 리스크 조회" + className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50" + /> +
+ + {/* WebClient */} +
+ + +
+ + {/* 외부 API 경로 */} +
+ + onChange('externalPath', e.target.value)} + placeholder="/RiskAndCompliance/RisksByImos" + className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50" + /> +
+ + {/* HTTP 메서드 */} +
+ +
+ {['GET', 'POST'].map((method) => ( + + ))} +
+
+ + {/* 설명 */} +
+ +